Merge branch 'perf/value-pipeline-batching' — end-to-end value-path batching (IBatchSubscribable, batched actor messages, single-lock chunked WriteValues, RealmKey re-key, dispatcher throughput 100)

This commit is contained in:
Joseph Doherty
2026-08-15 19:42:01 -04:00
41 changed files with 3841 additions and 276 deletions
+318
View File
@@ -0,0 +1,318 @@
# Surgical Deploys Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Make routine config edits non-events instead of fleet-wide outages: a raw-tag attribute edit or a UNS-reference add/remove/re-point no longer tears down the whole address space (killing every client subscription), the artifact is read from SQL once per apply instead of three times, fully JSON-parsed once per actor instead of ~8–11 times, only the delta is materialised instead of the whole composition, blocking apply I/O leaves the hot actor mailboxes, and the artifact blob is deflate-compressed inside the hashed bytes.
**Architecture:** Three prongs. (1) **Surgical routing:** `AddressSpaceChangeClassifier` learns that a non-alarm `RawTagDelta` and any `UnsReferenceDelta` are surgical-eligible; `AddressSpaceApplier` applies raw-tag attribute edits in place via the existing `ISurgicalAddressSpaceSink.UpdateTagAttributes` (Raw realm) and handles UNS-reference deltas as remove(previous-node)+recreate(current) — the recreate rides the delta materialise pass. Alarm-bearing raw tags keep the full-rebuild path (the WP4 notifier-wiring guard). (2) **Parse/read-once:** Direct mode threads the already-read blob into the rebuild + subscription pushes (the FetchAndCache path is the template, `DriverHostActor.cs:1650-1654`); a new `DeploymentArtifact.Open()` returns a `ParsedDeploymentArtifact` that parses the JSON once and serves all consumers; the materialise passes gain subset overloads so non-rebuild applies materialise only the plan's added/changed nodes. (3) **Mechanics:** the LocalDb cache write leaves the mailbox via `PipeTo`, the Applying-state dispatch deferral stops busy-spinning, and `ConfigComposer` emits a deflate-compressed blob (magic-sniffed on the read side, hash computed over the compressed bytes so `RevisionHash == SHA-256(stored/wire bytes)` stays true).
**Tech Stack:** C# / .NET 10, System.Text.Json (`JsonDocument`), Akka.NET, System.IO.Compression, xUnit + Shouldly.
**Branch:** `perf/surgical-deploys` (branched off `perf/value-pipeline-batching` — plans 1 and 2 touch the same actors; do NOT branch off master).
---
## Context for implementers (read before any task)
Verified facts — do not re-derive:
- **The rebuild cliff:** `AddressSpaceChangeClassifier.Classify` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs:69-78`) routes ANY non-empty `ChangedRawTags` / `ChangedUnsReferenceVariables` (among others) to `Rebuild`. `RawTagPlan.ReferencingEquipmentPaths` participates in record equality (`AddressSpaceComposer.cs`, `RawTagPlan.Equals`), so adding/removing a `UnsTagReference` flips the backing raw tag to Changed ⇒ full rebuild. The WP4 comment block (`AddressSpaceChangeClassifier.cs:62-68`) is the binding guard: any surgical `ChangedRawTags` path for ALARM tags must re-wire notifiers — we sidestep it by keeping alarm-bearing deltas on the Rebuild path.
- **The applier already has every surgical primitive:** `ISurgicalAddressSpaceSink` (`src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/ISurgicalAddressSpaceSink.cs`) — `UpdateTagAttributes(nodeId, writable, historianTagname, dataType, isArray, arrayLength, realm)` (realm-aware, returns false ⇒ caller rebuilds), `RemoveVariableNode`, `RemoveAlarmConditionNode`, `RemoveEquipmentSubtree`, `UpdateFolderDisplayName`. The applier's surgical branch is `AddressSpaceApplier.Apply` (`AddressSpaceApplier.cs:151-224`); its false/throw ⇒ full-rebuild ratchet must be preserved for every new surgical operation.
- **Apply flow (publish actor):** `OpcUaPublishActor.HandleRebuild` (`OpcUaPublishActor.cs:361-512`): load blob (msg.Artifact wins; else ConfigDb) → `FeedDataPlaneAuthority(artifact)` (MUST stay ahead of the `plan.IsEmpty` return — #520 stage 4) → `ParseComposition` → `AddressSpacePlanner.Compute(_lastApplied, composition)` → `plan.IsEmpty` return → `_applier.Apply(plan)` → six full-composition materialise passes (`:456-480`) → `AnnounceAddedNodes` when `!outcome.RebuildCalled`. `_lastApplied` advances only after Apply.
- **Apply flow (host actor):** Direct mode `ApplyAndAck` (`DriverHostActor.cs:1680-1754`): `ReconcileDrivers(deploymentId)` reads the blob from SQL and RETURNS it (`appliedBlob`), then `RebuildAddressSpace(correlation, deploymentId)` (NO bytes ⇒ publish actor re-reads SQL at `OpcUaPublishActor.cs:578-593`) and `PushDesiredSubscriptions(deploymentId)` (re-reads SQL again). FetchAndCache mode `HandleFetchedForApply` (`:1609-1674`) already does it right: `ReconcileDriversFromBlob(id, bytes)` → `RebuildAddressSpace(corr, id, appliedBlob)` → `PushDesiredSubscriptionsFromArtifact(id, appliedBlob)` → `CacheAppliedArtifact`.
- **Parse entry points** (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs`): `ParseDriverInstances(ReadOnlySpan<byte>)` `:89`, `ResolveClusterScope(ReadOnlySpan<byte>, string)` `:513`, `ParseDriverInstances(blob, nodeId)` `:555` (scope+parse), `ParseDataPlaneAuthority(blob, nodeId)` `:591`, `ParseComposition(blob)` `:893`, `ParseComposition(blob, nodeId, onInconsistency)` `:955` (scope+parse+`BuildClusterSets` `:1018`). EVERY one does `JsonDocument.Parse(blob.ToArray())` — a full copy per parse. The private `Build*` helpers already take `JsonElement root`, which is what makes the parse-once refactor mechanical.
- **Per-apply parse count today:** host: `ReconcileDrivers*` (scope+instances = 2) + `PushDesiredSubscriptions*` (scope+composition+clustersets = 3) + `CacheAppliedArtifact` (scope = 1); publish actor: `FeedDataPlaneAuthority` (authority+scope = 2) + `ParseComposition` (3). Target: ONE `JsonDocument` per actor per apply.
- **Blocking I/O on mailboxes:** `CacheAppliedArtifact` does `.GetAwaiter().GetResult()` (`DriverHostActor.cs:1901-1904`, comment at `:1870-1875` acknowledges it); the Applying-state deferral re-`Forward`s to Self in a hot loop (`DriverHostActor.cs:944-955`); `BeginFetchAndCacheApply` (`:1571-1602`) is the `PipeTo` template.
- **Compression constraint (CLAUDE.md, load-bearing):** `RevisionHash == SHA-256(artifact bytes)` — `ConfigComposer` computes the hash of the exact blob, `GrpcDeploymentArtifactFetcher` verifies fetched bytes against it, chunked LocalDb cache stores those bytes. Compressing BEFORE hashing preserves the invariant everywhere (hash of the compressed bytes). The dispatch stays payload-free.
- **Invariants to preserve:**
1. **#485 "no bytes is no answer"** — a null/empty artifact fails the apply and keeps last-known-good; never let compression or parse-once break that path.
2. **`FeedDataPlaneAuthority` runs ahead of `plan.IsEmpty`** — a key-revocation-only artifact must still land.
3. **The full-rebuild ratchet** — any surgical false/throw falls back to `SafeRebuild()`.
4. **`_lastApplied` advances only on a successful Apply** — the retry diffs against what is actually materialised.
5. **Deterministic composition** — `ConfigComposer`'s deterministic sort is what makes revision-hash no-ops real; compression must not reorder anything (it can't — it wraps the final bytes).
6. **The classifier stays default-closed** — any delta shape not explicitly whitelisted still lands on `Rebuild`.
- **Key records:** `RawTagPlan(TagId, NodeId, ParentNodeId, DriverInstanceId, Name, DataType, Writable, Alarm, ReferencingEquipmentPaths, IsHistorized, HistorianTagname, IsArray, ArrayLength, Realm)` with custom `Equals` (`AddressSpaceComposer.cs:141+`); `UnsReferenceVariable(UnsTagReferenceId, EquipmentId, NodeId, EffectiveName, BackingRawPath, DataType, Writable, Realm)` (`:204+`); `AddressSpacePlan.RawTagDelta(Previous, Current)` / `UnsReferenceDelta(Previous, Current)` (`AddressSpacePlan.cs:134-138`).
- **Materialise passes** (`AddressSpaceApplier.cs`): `MaterialiseHierarchy(composition)` `:643`, `MaterialiseRawSubtree(composition)` `:682` (needs `BuildEquipmentIdByFolderPath(composition)` for alarm notifier wiring), `MaterialiseUnsReferences(composition)` `:776` (builds `rawByPath` from `composition.RawTags` for inherited shape/historian), `MaterialiseScriptedAlarms`, `MaterialiseEquipmentTags`, `MaterialiseEquipmentVirtualTags` (in the `:820+` region). All idempotent loops over composition lists.
- **Existing tests to keep green:** `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierTests.cs` + `AddressSpaceChangeClassifierDualNamespaceTests.cs` (some assert `ChangedRawTags ⇒ Rebuild` / `ChangedUnsReferenceVariables ⇒ Rebuild` — those assertions CHANGE deliberately in Task 4; update them to assert the new contract, alarm-delta cases still Rebuild), applier tests, `OpcUaPublishActorApplyFailureTests`, `DriverHostActorFetchAndCacheBootstrapTests`, `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/DeploymentArtifactProtoTests.cs`, and `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs` (the live gate shape for "subscriptions survive").
- **Commit style:** `feat(...)` / `perf(...)` / `refactor(...)` with scope; one commit per task.
---
### Task 1: Direct mode threads the already-read blob
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 2
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs:1726-1730`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/` — extend the existing apply-path test class that exercises `ApplyAndAck` (grep for it; the FetchAndCache tests show the probe idiom)
**Step 1: Failing test** — drive a Direct-mode apply with a real blob in the fake ConfigDb; assert the publish-actor probe's `RebuildAddressSpace` message carries `Artifact != null` with the exact blob bytes (today it carries null).
**Step 3: Implement** — in `ApplyAndAck`, replace lines `:1726` and `:1729`:
```csharp
_opcUaPublishActor?.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.RebuildAddressSpace(correlation, deploymentId, appliedBlob));
PushDesiredSubscriptionsFromArtifact(deploymentId, appliedBlob);
```
(`appliedBlob` is non-null here — the null case returned at `:1706`.) Leave `PushDesiredSubscriptions(deploymentId)` in place for any other caller; if it becomes call-site-dead, delete it and its SQL read. Update the comment block: the publish actor no longer reads central SQL on the Direct path either — `LoadArtifact`/`LoadLatestArtifact` remain only as the legacy/dev fallback for a bytes-less `RebuildAddressSpace`.
**Step 4:** `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests` — PASS.
**Step 5:** `git add -A && git commit -m "perf(runtime): Direct-mode apply threads the in-hand artifact (1 SQL read per apply, not 3)"`
---
### Task 2: `DeploymentArtifact.Open()` — parse once
**Classification:** high-risk (config-parsing seam; behavior must be byte-identical)
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 1
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/ParsedDeploymentArtifactTests.cs` (create)
**Step 1: Failing test** — build a representative artifact JSON (reuse/borrow the builder from existing `DeploymentArtifact` tests — grep `ParseComposition` under tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests for the fixture); assert parity: `using var parsed = DeploymentArtifact.Open(blob);` then `parsed.ResolveClusterScope(node)`, `parsed.ParseDriverInstances(node)`, `parsed.ParseComposition(node, onInconsistency)`, `parsed.ParseDataPlaneAuthority(node)` each equal the static counterparts' results (deep equality on the records; compositions compare with their record equality).
**Step 3: Implement**
- Add:
```csharp
/// <summary>One-parse view over an artifact blob: a single JsonDocument serves every consumer of an
/// apply (driver reconcile, subscription push, composition, data-plane authority, cluster scope) instead
/// of each entry point re-parsing — at production sizes each static parse was a full LOH copy + document
/// build, and one apply performed ~8-11 of them. Dispose when the apply completes.</summary>
public sealed class ParsedDeploymentArtifact : IDisposable
{
private readonly JsonDocument _doc;
private readonly byte[] _blob; // decoded (post-decompression) bytes
private ClusterScope? _scope; // memoized per node — every consumer resolves the same node
private string? _scopeNodeId;
internal ParsedDeploymentArtifact(JsonDocument doc, byte[] blob) { _doc = doc; _blob = blob; }
public JsonElement Root => _doc.RootElement;
public void Dispose() => _doc.Dispose();
}
```
- `public static ParsedDeploymentArtifact Open(byte[] blob)` — parse ONCE (`JsonDocument.Parse(blob)` — no `.ToArray()` copy; the caller's array is not mutated afterward on any call path).
- Move the body of each static entry point into an instance method on `ParsedDeploymentArtifact` operating on `Root` (the private `Build*` helpers already take `JsonElement root` — pass `Root` straight in). Memoize `ResolveClusterScope(nodeId)` per node id (`_scope`/`_scopeNodeId`) since `ParseDriverInstances`/`ParseComposition`/`ParseDataPlaneAuthority` each begin with the same scope resolution.
- Re-express every existing static as a thin wrapper: `using var parsed = Open(blob.ToArray-if-span-else-array); return parsed.X(...)` — public signatures may stay `ReadOnlySpan<byte>` for compatibility (the wrapper pays one copy; the hot path stops using the statics in Task 3). Do not change any output shape.
**Step 4:** `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests` + `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests` — PASS.
**Step 5:** `git commit -m "refactor(runtime): ParsedDeploymentArtifact — one JsonDocument per apply"`
---
### Task 3: Actors consume `Open()`
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none (needs 1 + 2)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` (`ReconcileDriversFromBlob`, `PushDesiredSubscriptionsFromArtifact`, `CacheAppliedArtifact` call sites within one apply)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs` (`HandleRebuild` + `FeedDataPlaneAuthority`)
**Implement (behavior-preserving):**
- Host: in `ApplyAndAck` / `HandleFetchedForApply`, `using var parsed = DeploymentArtifact.Open(appliedBlob);` and pass `parsed` down (add internal overloads `ReconcileDriversFromParsed`, `PushDesiredSubscriptionsFromParsed`, `CacheAppliedArtifact(deploymentId, revision, blob, parsed)` — cache still stores the BYTES; it only needs `parsed` for the scope lookup it currently re-parses for). The existing blob-based methods stay as wrappers for the boot paths.
- Publish actor: `HandleRebuild` opens once after the #485 guard; `FeedDataPlaneAuthority(parsed, artifactBytes)` (the SHA-256 at `:535` still hashes the raw bytes); the composition parse uses `parsed.ParseComposition(...)`.
- Tests: the Task 2 parity tests + the existing apply suites ARE the net; add one assertion-level test only if a seam demands it.
Run both Runtime + OpcUaServer test projects. Commit: `perf(runtime): apply path parses the artifact once per actor`
---
### Task 4: Classifier — surgical eligibility for raw-tag and UNS-reference deltas
**Classification:** high-risk (the outage-per-edit fix; default-closed semantics)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierTests.cs` + `AddressSpaceChangeClassifierDualNamespaceTests.cs` (update the deliberately-changed assertions; add the new cases)
**Step 1: Write the failing tests** (these define the new contract):
1. `Reference_only_raw_tag_delta_is_not_a_rebuild` — a `RawTagDelta` whose ONLY difference is `ReferencingEquipmentPaths`, `Alarm == null` both sides ⇒ kind is NOT `Rebuild` (with no other deltas ⇒ `AttributeOnly`).
2. `Attribute_raw_tag_delta_is_not_a_rebuild` — non-alarm delta differing in `Writable`/`IsHistorized`/`HistorianTagname`/`DataType`/`IsArray`/`ArrayLength` ⇒ not Rebuild.
3. `Alarm_bearing_raw_tag_delta_still_rebuilds` — `Alarm != null` on either side ⇒ `Rebuild` (the WP4 guard).
4. `Identity_field_raw_tag_delta_still_rebuilds` — e.g. `DriverInstanceId` or `Name` differs ⇒ `Rebuild` (default-closed).
5. `Uns_reference_delta_is_not_a_rebuild` — a `UnsReferenceDelta` (re-point or display-name case) ⇒ not Rebuild.
6. Combination: UNS-ref ADD + the induced reference-only raw-tag delta ⇒ `PureAdd` (the headline case — one added UNS variable, zero teardown).
**Step 3: Implement**
- Add the predicate (mirror `TagDeltaIsSurgicalEligible`'s whitelist-via-`with` idiom, which auto-closes over future fields):
```csharp
// v3 surgical raw-tag eligibility: a NON-alarm raw tag whose only differences are the in-place-updatable
// attributes (Writable / IsHistorized / HistorianTagname / DataType / IsArray / ArrayLength) and/or its
// ReferencingEquipmentPaths. The referencing set is node-inert for a VALUE tag — it only drives native-alarm
// notifier wiring (the WP4 guard), which is why Alarm != null on EITHER side disqualifies. Identity fields
// (NodeId is the diff key; TagId / DriverInstanceId / Name / ParentNodeId / Realm) still force a rebuild via
// the with-equality default-closed pattern.
internal static bool RawTagDeltaIsSurgicalEligible(AddressSpacePlan.RawTagDelta d) =>
d.Previous.Alarm is null && d.Current.Alarm is null &&
(d.Previous with
{
Writable = d.Current.Writable,
IsHistorized = d.Current.IsHistorized,
HistorianTagname = d.Current.HistorianTagname,
DataType = d.Current.DataType,
IsArray = d.Current.IsArray,
ArrayLength = d.Current.ArrayLength,
ReferencingEquipmentPaths = d.Current.ReferencingEquipmentPaths,
}).Equals(d.Current);
```
- In `Classify` rule 2 (`:69-78`): replace `plan.ChangedRawTags.Count > 0` with `plan.ChangedRawTags.Any(d => !RawTagDeltaIsSurgicalEligible(d))`, and DELETE `plan.ChangedUnsReferenceVariables.Count > 0` from the rebuild condition (every `UnsReferenceDelta` is handled surgically in Tasks 5-6: remove-previous + recreate-current).
- Rewrite the WP4 comment block to state the NEW contract: alarm-bearing raw-tag deltas still rebuild (notifier wiring); value-tag deltas are surgical; the applier owns `UpdateTagAttributes` for them; UNS-ref deltas are remove+recreate.
**Step 4:** `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests --filter "FullyQualifiedName~AddressSpaceChangeClassifier"` — PASS (including the updated legacy assertions).
**Step 5:** `git commit -m "feat(opcuaserver): classifier — non-alarm raw-tag + UNS-reference deltas are surgical, not rebuilds"`
---
### Task 5: Applier — apply the new surgical deltas
**Classification:** high-risk (rebuild ratchet semantics)
**Estimated implement time:** ~5 min
**Parallelizable with:** none (needs 4)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs` (`Apply`, `:151-224`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/` — extend the applier test class (find it via grep `AddressSpaceApplier` in that project; use its recording-fake surgical sink)
**Step 1: Failing tests**
1. `Surgical_raw_tag_delta_updates_in_place_raw_realm` — plan with one eligible `RawTagDelta` (historize flip); fake surgical sink records `UpdateTagAttributes(d.Current.NodeId, writable, historian, dataType, isArray, arrayLength, AddressSpaceRealm.Raw)` with historian resolved as `MaterialiseRawSubtree` resolves it (override else NodeId, `AddressSpaceApplier.cs:748-750`) and `writable = Writable && !IsArray`; NO `RebuildAddressSpace` call.
2. `Reference_only_raw_tag_delta_is_a_noop_update` — only `ReferencingEquipmentPaths` differs ⇒ either no sink call or one idempotent `UpdateTagAttributes` (assert no rebuild; prefer SKIPPING the call when the with-comparison excluding the referencing set shows attribute equality — implement that skip).
3. `Uns_reference_delta_removes_previous_node` — plan with one `UnsReferenceDelta`; sink records terminal-Bad `WriteValue(Previous.NodeId, ..., Uns)` then `RemoveVariableNode(Previous.NodeId, Uns)`; no rebuild. (The recreate happens in the publish actor's delta materialise pass — Task 6 — NOT here.)
4. `Surgical_false_falls_back_to_rebuild` — surgical sink returns false for the raw-tag update ⇒ `RebuildAddressSpace` called (ratchet preserved).
**Step 3: Implement** in `Apply`:
- Compute `var surgicalRawTagDeltas = plan.ChangedRawTags.Where(AddressSpaceChangeClassifier.RawTagDeltaIsSurgicalEligible).ToList();` (when kind != Rebuild this equals ALL of ChangedRawTags — the classifier guarantees it; still filter, defense-in-depth) and `var unsRefDeltas = plan.ChangedUnsReferenceVariables;`
- Extend the surgical-branch trigger (`:171`): `surgicalTagDeltas.Count > 0 || renamedFolders.Count > 0 || hasRemovePass || surgicalRawTagDeltas.Count > 0 || unsRefDeltas.Count > 0`.
- Inside the `ISurgicalAddressSpaceSink` branch, after the existing equipment-tag loop:
- Raw-tag loop: skip when attribute-equal apart from `ReferencingEquipmentPaths` (test 2); else `surgical.UpdateTagAttributes(d.Current.NodeId, d.Current.Writable && !d.Current.IsArray, d.Current.IsHistorized ? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.NodeId : d.Current.HistorianTagname) : null, d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength, AddressSpaceRealm.Raw)` with the same try/catch + `allApplied` ratchet as the equipment-tag loop.
- UNS-ref loop: `SafeWriteValue(d.Previous.NodeId, null, OpcUaStatus.Bad, ts, AddressSpaceRealm.Uns);` then `SafeRemoveVariable(surgical, d.Previous.NodeId, AddressSpaceRealm.Uns)` into the ratchet. Doc-comment WHY remove+recreate (a re-point must move the `Organizes` edge and the sink has no RemoveReference; a display-name edit changes the NodeId; remove+recreate covers both, at the cost of that one node's monitored items — vs. every subscription server-wide today).
- `changedCount` accounting already includes both sets (`:115-121`) — verify, don't double-count.
- Also update the `FeedHistorizedRefs` changed-raw-tags handling: it already handles `plan.ChangedRawTags` (`:571-578`) — now those deltas reach it on the surgical path too; confirm the logic is delta-shape-agnostic (it is) and leave it.
**Step 4:** run the OpcUaServer.Tests project — PASS.
**Step 5:** `git commit -m "feat(opcuaserver): applier applies raw-tag + UNS-reference deltas surgically"`
---
### Task 6: Delta materialisation (publish actor materialises the plan, not the world)
**Classification:** high-risk (the O(fleet)-per-apply fix)
**Estimated implement time:** ~5 min
**Parallelizable with:** none (needs 5)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs` (subset overloads on the six passes)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs:456-480`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/` applier tests + `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/` publish-actor tests
**Step 1: Failing tests**
1. `Pure_add_materialises_only_the_added_nodes` — publish-actor-level: apply composition A (N nodes), then A+1 tag; the fake sink's `EnsureVariable`/`EnsureFolder` calls after the second apply cover ONLY the added tag (+ its added containers), not all N. (This is the headline test.)
2. `Rebuild_still_materialises_the_full_composition` — force a rebuild-kind delta; full-composition passes run as today.
3. `Uns_reference_delta_recreates_current_node` — a re-point delta: after Task 5's remove, the UNS pass re-ensures `Current.NodeId` with shape inherited from the FULL composition's `rawByPath` (backing tag NOT in the delta), plus the `AddReference` Organizes edge to the NEW backing path.
4. Applier-level: `MaterialiseRawSubtree(full, subsetTags, subsetContainers)` wires alarm notifiers using the FULL composition's equipment map.
**Step 3: Implement**
- Applier: give the passes subset parameters, full-composition context retained for lookups:
- `MaterialiseRawSubtree(AddressSpaceComposition composition, IReadOnlyList<RawContainerNode>? onlyContainers = null, IReadOnlyList<RawTagPlan>? onlyTags = null)` — iterate `onlyContainers ?? composition.RawContainers` / `onlyTags ?? composition.RawTags`; `BuildEquipmentIdByFolderPath(composition)` stays full.
- `MaterialiseUnsReferences(AddressSpaceComposition composition, IReadOnlyList<UnsReferenceVariable>? only = null)` — iterate `only ?? composition.UnsReferenceVariables`; `rawByPath` stays full.
- `MaterialiseScriptedAlarms` / `MaterialiseEquipmentTags` / `MaterialiseEquipmentVirtualTags`: same `only` pattern over their respective lists.
- `MaterialiseHierarchy` stays full-composition ALWAYS (folders are O(areas+lines+equipment), cheap, and parents must exist for any add).
- Publish actor (`:456-480`): after `var outcome = _applier.Apply(plan);` branch on `outcome.RebuildCalled`:
- Rebuild ⇒ the six passes exactly as today (full composition).
- Non-rebuild ⇒ `MaterialiseHierarchy(composition)` full; then subsets: containers = `plan.AddedRawContainers`, raw tags = `plan.AddedRawTags`, UNS refs = `plan.AddedUnsReferenceVariables` concat `plan.ChangedUnsReferenceVariables.Select(d => d.Current)`, scripted alarms = `plan.AddedAlarms`, equipment tags = `plan.AddedEquipmentTags`, vtags = `plan.AddedEquipmentVirtualTags`. Skip a pass entirely when its subset is empty.
- Comment WHY: the passes are idempotent but not free — each call is a lock acquisition; a one-tag add on a 50k-node server was ~50k sink calls.
- `AnnounceAddedNodes` stays as-is (known pre-existing gap: raw/UNS adds are not announced — out of scope, note it in the comment).
**Step 4:** OpcUaServer.Tests + Runtime.Tests — PASS (watch `OpcUaPublishActorApplyFailureTests` + `DarkAddressSpaceReasons` for full-pass assumptions; update only assertions that pinned the OLD full-pass behavior explicitly, preserving their intent).
**Step 5:** `git commit -m "perf(opcuaserver): non-rebuild applies materialise the delta, not the composition"`
---
### Task 7: Blocking I/O off the host mailbox
**Classification:** high-risk (actor lifecycle)
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 8
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs` (`CacheAppliedArtifact` `:1896-1904` region; Applying-state deferral `:944-955`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/` — extend the apply/dispatch test classes
**Part A — async cache write.** `CacheAppliedArtifact`'s `.GetAwaiter().GetResult()` becomes fire-and-forget with observed completion:
```csharp
_artifactCache.StoreAsync(...).PipeTo(Self,
success: () => new ArtifactCached(deploymentId),
failure: ex => new ArtifactCacheFailed(deploymentId, ex.Message));
```
(private records; handlers just log Info/Warning — the cache is a boot fallback, not apply-critical; the docstring at `:1870-1875` already debates exactly this and the apply outcome does not depend on it). CHECK first whether `BootstrapFromCache` (`:788, :859`) races a concurrent store — it runs in PreStart before any apply, so no; note that in the comment. Keep the store idempotent-skip (`IsAlreadyCachedAsync`) inside the async flow.
Failing test: apply completes and acks BEFORE a deliberately-slow fake cache resolves; a follow-up `ArtifactCached` self-message is observed (TestKit probe or log assertion per the project's idiom).
**Part B — stash instead of forward-spin.** The Applying-state deferral for a DIFFERENT deployment (`:944-955`): make `DriverHostActor` implement `IWithUnboundedStash` (`public IStash Stash { get; set; }`), replace `Self.Forward(msg)` with `Stash.Stash()`, and call `Stash.UnstashAll()` at every `Become(Steady)` transition (both `finally` blocks + the fetch-failure path). Failing test: send dispatch B while A's fetch is in flight (slow fake fetcher); assert no busy-loop (the log does NOT contain the deferral line more than once) and B applies after A completes.
**Step 4:** Runtime.Tests — PASS. **Step 5:** `git commit -m "perf(runtime): apply-path cache write off-mailbox; stash deferred dispatches"`
---
### Task 8: Compressed artifact blob
**Classification:** high-risk (wire/storage format; hash invariant)
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 7
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.ControlPlane/AdminOperations/ConfigComposer.cs` (emit compressed; hash unchanged in derivation — SHA-256 of the emitted bytes)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs` (`Open()` + static wrappers sniff-and-decompress)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/ParsedDeploymentArtifactTests.cs` + the ConfigComposer test class (grep `SnapshotAndFlattenAsync` under tests/Server)
**Format decision (locked):** gzip via `System.IO.Compression.GZipStream` (`CompressionLevel.Fastest`), detected by the gzip magic bytes `0x1F 0x8B` at offset 0 (a JSON artifact can never start with 0x1F). No custom framing. Compression happens INSIDE `ConfigComposer` immediately after `SerializeToUtf8Bytes`, BEFORE the revision hash is computed — so `RevisionHash == SHA-256(stored bytes)` holds and `GrpcDeploymentArtifactFetcher`'s verification (`GrpcDeploymentArtifactFetcher.cs:89-90`) is untouched. The LocalDb cache + gRPC serve stores/ships the compressed bytes as-is (they never look inside). Decompression lives in exactly one place: `DeploymentArtifact.Open()` (and thus every static wrapper). `OpcUaPublishActor.FeedDataPlaneAuthority`'s SHA at `OpcUaPublishActor.cs:535` hashes the RAW (compressed) bytes it received — verify that call site hashes the bytes as-carried, not a decompressed copy.
**Step 1: Failing tests**
1. `Open_reads_gzip_compressed_artifacts` — compress a fixture blob, parity-assert `ParseComposition` vs the uncompressed parse.
2. `Open_still_reads_uncompressed_artifacts` (backward compat — pre-upgrade rows).
3. `Composer_emits_gzip_and_hash_of_compressed_bytes` — `ArtifactBlob` starts with `1F 8B`; the deployment's `RevisionHash` equals `SHA256(ArtifactBlob)` lowercase hex.
**Step 3: Implement** — decompress with a size guard (refuse decompressed output > 512 MB — a corrupt/hostile blob must not OOM the node; treat over-limit exactly like unparseable JSON: the existing catch paths). Note in a comment: gzip output is not guaranteed byte-stable across .NET releases; the hash is computed from the emitted bytes at compose time and never re-derived, so the only consequence of an impl change is one spurious "changed" revision after a runtime upgrade.
**Step 4:** Runtime.Tests + ControlPlane tests (if the composer test project is DB-backed and the DB is unreachable, run the artifact round-trip tests and note the skip). **Step 5:** `git commit -m "perf(controlplane): gzip artifact blob inside the hashed bytes (~10-20x smaller)"`
---
### Task 9: Full verification + docs
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (last)
**Files:**
- Modify: `CLAUDE.md` — the "v3 OPC UA Address Space" / classifier-adjacent statements that now describe the old behavior (grep CLAUDE.md for `ChangedRawTags` and `Rebuild`); keep the edits surgical and truthful to the new contract (alarm-bearing deltas still rebuild).
- Modify: `docs/Uns.md` + `docs/Raw.md` — one short paragraph each: reference add/remove and attribute edits are now surgical; what still rebuilds (alarm deltas, container renames, identity changes).
Steps: full `dotnet build ZB.MOM.WW.OtOpcUa.slnx`; run every non-DB test project (same list as plan 1 Task 12 + `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests`); record skipped DB-backed suites in the commit message. Commit: `docs: record surgical-deploy semantics (plan 2026-08-15)`.
---
## Explicit non-goals
- **No surgical raw-container (folder/device/group) renames** — still rebuild (no surgical folder-remove on the sink; pre-existing).
- **No alarm-bearing raw-tag surgical path** — the WP4 notifier-rewire stays owned by the rebuild.
- **No central-side deploy-start dedup** (`AdminOperationsActor` triple DB read) — separate follow-up.
- **No `AnnounceAddedNodes` extension to raw/UNS adds** — pre-existing gap, noted in comments.
- **No cross-actor shared `ParsedDeploymentArtifact`** — one parse per actor is the target; sharing a disposable across mailboxes isn't worth the ownership complexity.
- **No artifact schema changes** — the JSON inside the (now compressed) blob is byte-identical.
@@ -0,0 +1,15 @@
{
"planPath": "docs/plans/2026-08-15-surgical-deploys.md",
"tasks": [
{"id": 1, "subject": "Task 1: Direct mode threads the already-read blob", "status": "pending"},
{"id": 2, "subject": "Task 2: DeploymentArtifact.Open() — parse once", "status": "pending"},
{"id": 3, "subject": "Task 3: Actors consume Open()", "status": "pending", "blockedBy": [1, 2]},
{"id": 4, "subject": "Task 4: Classifier — surgical eligibility for raw-tag and UNS-reference deltas", "status": "pending"},
{"id": 5, "subject": "Task 5: Applier — apply the new surgical deltas", "status": "pending", "blockedBy": [4]},
{"id": 6, "subject": "Task 6: Delta materialisation", "status": "pending", "blockedBy": [5]},
{"id": 7, "subject": "Task 7: Blocking I/O off the host mailbox", "status": "pending", "blockedBy": [3]},
{"id": 8, "subject": "Task 8: Compressed artifact blob", "status": "pending", "blockedBy": [2]},
{"id": 9, "subject": "Task 9: Full verification + docs", "status": "pending", "blockedBy": [3, 6, 7, 8]}
],
"lastUpdated": "2026-08-15T00:00:00Z"
}
@@ -0,0 +1,631 @@
# Value-Pipeline Batching Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Batch the hot value path end to end — one message per poll cycle instead of one per tag at every hop (driver → DriverInstanceActor → DriverHostActor → OpcUaPublishActor → node manager), one SDK-lock acquisition per batch instead of per value, and zero per-write key allocations in the node manager.
**Architecture:** A new optional `IBatchSubscribable` capability lets a driver raise one `OnDataChangeBatch` per production burst (poll cycle); `PollGroupEngine` gains a batch callback so the four engine-based poll drivers get it for free. `DriverInstanceActor` forwards one `AttributeValueBatchPublished` per burst (wrapping per-tag events from non-batch drivers as single-item batches); `DriverHostActor` resolves the whole batch's NodeRealmRefs and sends ONE `AttributeValueBatchUpdate` to `OpcUaPublishActor`, plus ONE batch Tell to the dependency mux. The sink gains `WriteValues(IReadOnlyList<ValueWrite>)` (default-interface-method fallback loops `WriteValue`, so fakes/Null sink are untouched); `OtOpcUaNodeManager` overrides it taking `Lock` once per batch, and its node maps are re-keyed by a `(realm, id)` struct so the per-write `new NodeId(...).ToString()` allocations disappear. The pinned dispatcher's `throughput` rises from 1 to 100 (same single thread, fewer mailbox re-schedules).
**Tech Stack:** C# / .NET 10, Akka.NET (classic actors), OPC Foundation UA .NET Standard, xUnit + Shouldly, Akka.TestKit.
**Branch:** `perf/value-pipeline-batching` (off `master`).
---
## Context for implementers (read before any task)
Verified facts about the current code — do not re-derive, and do not violate the invariants:
- **The per-tag path today:** `PollGroupEngine.PollOnceAsync` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/PollGroupEngine.cs:202-226`) diffs per tag and invokes `_onChange` per changed tag. Drivers forward that to `ISubscribable.OnDataChange` (per-tag event, `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ISubscribable.cs:36`). `DriverInstanceActor.HandleSubscribeAsync` attaches `_dataChangeHandler` at `DriverInstanceActor.cs:880` (one `DataChangeForward` Tell per tag), `OnDataChangeForward` (`:1138-1143`) Tells parent one `AttributeValuePublished` per tag. `DriverHostActor.ForwardToMux` (`DriverHostActor.cs:976-1006`) Tells the mux per value and Tells `OpcUaPublishActor` one `AttributeValueUpdate` **per NodeRealmRef** (UNS fan-out at `:997-999`). `OpcUaPublishActor.HandleAttributeUpdate` (`OpcUaPublishActor.cs:319-331`) calls `_sink.WriteValue` per message. `OtOpcUaNodeManager.WriteValue` (`OtOpcUaNodeManager.cs:508-533`) takes the manager-wide `lock (Lock)` per value and allocates via `MapKey(realm, id)` = `new NodeId(id, ns).ToString()` (`:133`).
- **Invariants to preserve:**
1. **#519 status codes ride verbatim** — the full `uint` status code, sub-code included, must reach the SDK unchanged at every hop.
2. **Timestamps are carried from the driver** — never stamp `DateTime.UtcNow` on the value path.
3. **PollGroupEngine's no-overlap guarantee** (delay-after-completion, one loop task per subscription) must not change in this plan.
4. **Per-tag `OnDataChange` keeps working** — Galaxy's cold-read path and driver CLIs subscribe it. A batch-capable driver raises BOTH events (batch once per cycle + per-tag per item); a consumer subscribes to exactly ONE of the two. `DriverInstanceActor` prefers batch when the driver implements `IBatchSubscribable`.
5. **`DriverInstanceActor.DetachSubscription`** (`:934-943`) must detach the batch handler too — the detach is load-bearing (the `IDriver` outlives the actor across respawns; see CLAUDE.md §DeployWatcher).
6. **The mux's per-item receive stays** — `DriverHostActor.FanCommsLossToDependencyMux` (`:1135-1142`) fabricates per-item `AttributeValuePublished` messages; `DependencyMuxActor` keeps that receive and gains a batch receive alongside.
7. **`OpcUaPublishActor` keeps its per-item `AttributeValueUpdate` receive** — `VirtualTagHostActor` and the alarm paths still send it. The batch message is additive.
8. **The pinned dispatcher stays pinned** — the OPC UA SDK requires a single thread. Only `throughput` changes.
9. **`_writes` increments** (`OpcUaPublishActor.cs:324`) and the `OtOpcUaTelemetry.OpcUaSinkWrite` meter must count per-VALUE (per item), not per batch, so dashboards don't drop by 100×.
- **Message-record homes:** `AttributeValuePublished` is declared in `DriverInstanceActor` (`:109`); `AttributeValueUpdate` in `OpcUaPublishActor` (`:52`); `NodeRealmRef` is a private struct of `DriverHostActor` (`:183`).
- **Sink implementors that must keep compiling:** `NullOpcUaAddressSpaceSink` (same file as the interface), `DeferredAddressSpaceSink` (`src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs`), `SdkAddressSpaceSink` (`src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs`), plus test fakes. Using a **default interface method** for `WriteValues` means only the ones that want batching override it.
- **Commands:** build `dotnet build ZB.MOM.WW.OtOpcUa.slnx`; single project `dotnet test tests/<module>/<project>`; single test `dotnet test --filter "FullyQualifiedName~<Class>.<Method>"`. DB-backed suites need the central SQL Server — on this Mac, prefer running the specific unit projects named per task, not the whole solution, until the final task.
- **Commit style:** follow recent history (`feat(...)`, `fix(...)`, `chore(...)` with a scope). One commit per task.
---
### Task 1: Batch contract types in Core.Abstractions
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 10
**Files:**
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ISubscribable.cs`
- Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/DataChangeBatchContractTests.cs` (create)
**Step 1: Write the failing test**
```csharp
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Core.Tests;
public class DataChangeBatchContractTests
{
private sealed record FakeHandle(string DiagnosticId) : ISubscriptionHandle;
[Fact]
public void Batch_args_carry_handle_and_items()
{
var handle = new FakeHandle("h1");
var snap = new DataValueSnapshot(42, 0u, DateTime.UtcNow, DateTime.UtcNow);
var args = new DataChangeBatchEventArgs(handle, new[] { new DataChangeItem("plc/tag1", snap) });
args.SubscriptionHandle.ShouldBe(handle);
args.Items.Count.ShouldBe(1);
args.Items[0].FullReference.ShouldBe("plc/tag1");
args.Items[0].Snapshot.ShouldBe(snap);
}
}
```
Note: check `DataValueSnapshot`'s actual constructor shape in `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/` first and adjust the test's construction to it (positional record with Value/StatusCode/timestamps). Do not change `DataValueSnapshot`.
**Step 2: Run test to verify it fails**
Run: `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests --filter "FullyQualifiedName~DataChangeBatchContractTests"`
Expected: FAIL — `DataChangeBatchEventArgs` / `DataChangeItem` not defined.
**Step 3: Implement**
Append to `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ISubscribable.cs`:
```csharp
/// <summary>One changed attribute within a <see cref="DataChangeBatchEventArgs"/>.</summary>
/// <param name="FullReference">Driver-side full reference of the changed attribute.</param>
/// <param name="Snapshot">New value + quality + timestamps.</param>
public sealed record DataChangeItem(string FullReference, DataValueSnapshot Snapshot);
/// <summary>Event payload for <see cref="IBatchSubscribable.OnDataChangeBatch"/> — every attribute
/// that changed within ONE production burst (a poll cycle, an inbound message, a stream chunk).</summary>
/// <param name="SubscriptionHandle">The handle returned by the original <see cref="ISubscribable.SubscribeAsync"/> call.</param>
/// <param name="Items">The changed attributes, one entry per changed tag. Never empty.</param>
public sealed record DataChangeBatchEventArgs(
ISubscriptionHandle SubscriptionHandle,
IReadOnlyList<DataChangeItem> Items);
/// <summary>
/// Optional driver capability: data changes delivered one event per production burst instead of
/// one event per tag. A driver implementing this raises <see cref="OnDataChangeBatch"/> once per
/// burst AND continues raising the per-tag <see cref="ISubscribable.OnDataChange"/> for each item
/// (legacy consumers: Galaxy cold reads, driver CLIs). A consumer subscribes to EXACTLY ONE of the
/// two events — subscribing both double-delivers. DriverInstanceActor prefers the batch event.
/// </summary>
public interface IBatchSubscribable : ISubscribable
{
/// <summary>Batched data-change notification — one raise per production burst.</summary>
event EventHandler<DataChangeBatchEventArgs>? OnDataChangeBatch;
}
```
**Step 4: Run test to verify it passes**
Run: same filter. Expected: PASS.
**Step 5: Commit**
```bash
git add src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/ISubscribable.cs tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/DataChangeBatchContractTests.cs
git commit -m "feat(core): add IBatchSubscribable batch data-change contract"
```
---
### Task 2: PollGroupEngine batch callback
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (Task 1 first)
**Files:**
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/PollGroupEngine.cs`
- Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests/PollGroupEngineBatchTests.cs` (create)
**Step 1: Write the failing tests** (find the existing PollGroupEngine test file first — if one exists in `tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests`, put these in a new class beside it and copy its reader-fake idiom):
Test cases (write all four):
1. `Batch_fires_once_per_cycle_with_only_changed_items` — reader returns 3 refs; first poll (initial push) batch has all 3; then reader changes only 1 value; next batch has exactly that 1 item; per-tag `onChange` also fired for the same items (both callbacks see identical content).
2. `No_batch_when_nothing_changed` — second poll with identical snapshots ⇒ no batch invocation (and no per-tag invocation).
3. `Batch_is_not_invoked_when_callback_absent` — construct without `onBatch`; engine behaves exactly as today (per-tag only). Assert per-tag count.
4. `Batch_items_are_in_reader_order`.
Use a short interval (e.g. 30 ms) + `TaskCompletionSource`/polling-with-timeout to await callbacks, mirroring the existing engine tests' style. Never `Thread.Sleep` long constants.
**Step 2: Run to verify failure**
Run: `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests --filter "FullyQualifiedName~PollGroupEngineBatchTests"`
Expected: FAIL — constructor has no `onBatch` parameter.
**Step 3: Implement**
In `PollGroupEngine`:
- Add field `private readonly Action<ISubscriptionHandle, IReadOnlyList<DataChangeItem>>? _onBatch;`
- Add optional ctor param `Action<ISubscriptionHandle, IReadOnlyList<DataChangeItem>>? onBatch = null` (AFTER the existing optional params, so every existing call site compiles unchanged) and assign it.
- In `PollOnceAsync` (currently `:202-226`): collect changed items lazily and raise the batch after the loop:
```csharp
List<DataChangeItem>? changed = null;
for (var i = 0; i < state.TagReferences.Count; i++)
{
var tagRef = state.TagReferences[i];
var current = snapshots[i];
var lastSeen = state.LastValues.TryGetValue(tagRef, out var prev) ? prev : default;
if (forceRaise || ValuesAreDifferent(lastSeen?.Value, current.Value) || lastSeen?.StatusCode != current.StatusCode)
{
state.LastValues[tagRef] = current;
_onChange(state.Handle, tagRef, current);
if (_onBatch is not null) (changed ??= new List<DataChangeItem>()).Add(new DataChangeItem(tagRef, current));
}
}
if (changed is { Count: > 0 }) _onBatch!(state.Handle, changed);
```
Keep the surrounding contract-violation check and everything else byte-identical. Update the class remarks to document the batch callback (one raise per cycle, only when non-empty, after all per-tag callbacks of that cycle).
**Step 4: Run tests**
Run: `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Core.Tests` (whole project — the existing engine tests must stay green).
Expected: PASS.
**Step 5: Commit**
```bash
git add -A && git commit -m "feat(core): PollGroupEngine optional per-cycle batch callback"
```
---
### Task 3a: Modbus + S7 raise the batch event
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 3b, Task 5
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus/ModbusDriver.cs`
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs`
- Test: `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests/ModbusBatchSubscriptionTests.cs` (create; mirror the fixture idiom of `ModbusSubscriptionTests.cs`)
**Step 1: Write the failing test** — subscribe via the driver against the existing in-test transport fake (copy `ModbusSubscriptionTests`' setup); assert:
1. `ModbusDriver` is `IBatchSubscribable`.
2. After one poll cycle with N changed registers, `OnDataChangeBatch` fired once with N items AND `OnDataChange` fired N times (both events, identical content — contract from Task 1).
**Step 2:** Run: `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests --filter "FullyQualifiedName~ModbusBatchSubscriptionTests"` — FAIL (interface not implemented).
**Step 3: Implement** — in each driver:
- Change the class declaration to implement `IBatchSubscribable` (it already implements `ISubscribable`).
- Add `public event EventHandler<DataChangeBatchEventArgs>? OnDataChangeBatch;`
- Find the `new PollGroupEngine(...)` construction; pass `onBatch: (handle, items) => OnDataChangeBatch?.Invoke(this, new DataChangeBatchEventArgs(handle, items))`.
Per-tag raising is already wired via the engine's `onChange` — leave it.
**Step 4:** Run the Modbus + S7 test projects:
`dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests && dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests` — PASS (all existing tests too).
**Step 5:** `git add -A && git commit -m "feat(drivers): Modbus + S7 implement IBatchSubscribable via PollGroupEngine batch callback"`
**Deviation (landed):** Modbus does **not** forward the engine's `items` argument — it ignores it and
raises the batch from its own per-cycle buffer of the items that passed `ShouldPublish`. The engine
collects its batch list unconditionally and has no knowledge of the driver's deadband filter, so a
straight pass-through would carry changes the per-tag event suppressed, and a consumer that preferred
the batch event would see deadband silently stop working (review finding). `onBatch` is therefore used
only as the end-of-cycle signal, draining the buffer; a cycle whose every change was suppressed raises
nothing. Because that buffer is the only piece of cross-cycle state any of the four poll drivers holds,
the driver's `onChange` bridge also wraps `OnDataChange?.Invoke(...)` in a swallowing try/catch
(mirroring `PollGroupEngine.ReportError`): the engine invokes the bridge unguarded, so a throwing
external subscriber would unwind the cycle before `onBatch` ran and the undrained items would merge
into a later cycle's batch. The buffer append stays outside the catch — the item was published as far
as the batch contract is concerned; only the per-tag subscriber faulted. S7/AbCip/FOCAS forward the
engine's list as this task describes and hold no such state.
---
### Task 3b: AbCip + FOCAS raise the batch event
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 3a, Task 5
**Files:**
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs`
- Modify: `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs`
Same mechanical change as Task 3a (interface + event + `onBatch` at the `new PollGroupEngine(...)` site). If either driver constructs the engine differently (check first), adapt but keep the identical contract. No new per-driver test required (the engine behavior is pinned by Task 2; the wiring pattern is pinned by Task 3a's test) — but the projects' existing suites must stay green.
Run: `dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests && dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests`
Commit: `feat(drivers): AbCip + FOCAS implement IBatchSubscribable`
---
### Task 4: DriverInstanceActor batch forwarding
**Classification:** high-risk (actor model, subscription lifecycle)
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 5
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorBatchForwardTests.cs` (create; copy the TestKit + stub-driver idiom from the existing `DriverInstanceActor*Tests` in that directory)
**Step 1: Write the failing tests**
1. `Batch_driver_forwards_one_batch_message` — stub driver implementing `IBatchSubscribable`; raise `OnDataChangeBatch` with 3 items after subscribe; parent probe receives exactly ONE `AttributeValueBatchPublished` with 3 items (driver id + per-item FullReference/Value/StatusCode/timestamp mapped as `OnDataChangeForward` maps them today: `ts = Snapshot.SourceTimestampUtc ?? Snapshot.ServerTimestampUtc`).
2. `Batch_driver_per_tag_event_not_double_forwarded` — the stub raises BOTH events (per contract); parent receives only the batch message, no legacy `AttributeValuePublished`.
3. `Per_tag_driver_forwards_single_item_batches` — stub implementing only `ISubscribable`; one `OnDataChange` ⇒ parent receives one `AttributeValueBatchPublished` with 1 item.
4. `Detach_removes_batch_handler` — after Unsubscribe (or Connected→Reconnecting, whichever the existing detach tests exercise), raising the batch event produces no message.
**Step 2:** Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~DriverInstanceActorBatchForwardTests"` — FAIL.
**Step 3: Implement**
- Add records next to `AttributeValuePublished` (`:109`):
```csharp
/// <summary>One value within an <see cref="AttributeValueBatchPublished"/>.</summary>
public sealed record AttributeValueItem(string FullReference, object? Value, uint StatusCode, DateTime TimestampUtc);
/// <summary>Batched form of <see cref="AttributeValuePublished"/> — every changed attribute of ONE
/// driver production burst in one message. The parent fans the whole batch to the mux and the OPC UA
/// publish path in one hop each. Status codes ride verbatim (#519).</summary>
public sealed record AttributeValueBatchPublished(string DriverInstanceId, IReadOnlyList<AttributeValueItem> Items);
```
- Private message `private sealed record DataChangeBatchForward(IReadOnlyList<DataChangeItem> Items);` + field `private EventHandler<DataChangeBatchEventArgs>? _batchChangeHandler;`
- In `HandleSubscribeAsync` (`:878-882`): when `subscribable is IBatchSubscribable batchable`, attach ONLY the batch handler (`_batchChangeHandler = (_, args) => self.Tell(new DataChangeBatchForward(args.Items)); batchable.OnDataChangeBatch += _batchChangeHandler;`) and do NOT attach `_dataChangeHandler`; else attach `_dataChangeHandler` exactly as today.
- `Receive<DataChangeBatchForward>(OnDataChangeBatchForward);` beside the existing receive (`:480`); handler builds the items list once and Tells parent one `AttributeValueBatchPublished`.
- `OnDataChangeForward` (`:1138`) becomes a single-item batch: `Context.Parent.Tell(new AttributeValueBatchPublished(_driverInstanceId, new[] { new AttributeValueItem(msg.FullReference, msg.Snapshot.Value, msg.Snapshot.StatusCode, ts) }));` — keep the `AttributeValuePublished` record declared (the mux comms-loss path and existing tests use the type), but the actor no longer sends it.
- `DetachSubscription` (`:934-943`): also detach `_batchChangeHandler` from `IBatchSubscribable` and null it.
**IMPORTANT — downstream compile/behavior:** `DriverHostActor` currently receives `AttributeValuePublished` (`DriverHostActor.cs:960`). Until Task 8 lands, add a minimal bridge in THIS task so the system stays green: in `DriverHostActor`, add `Receive<DriverInstanceActor.AttributeValueBatchPublished>(batch => { foreach (var i in batch.Items) ForwardToMux(new DriverInstanceActor.AttributeValuePublished(batch.DriverInstanceId, i.FullReference, i.Value, i.StatusCode, i.TimestampUtc)); });` — Task 8 replaces this bridge with the real batched fan-out. Existing `DriverHostActor` tests that send `AttributeValuePublished` directly stay green (the receive stays).
**Step 4:** Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests` — PASS (existing DriverInstanceActor forward tests may need updating IF they assert the parent receives `AttributeValuePublished` from a live driver event — update those to expect the single-item batch; do NOT weaken assertions on content).
**Step 5:** `git add -A && git commit -m "feat(runtime): DriverInstanceActor forwards batched data changes"`
---
### Task 5: `ValueWrite` + sink `WriteValues` default method
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 3a, 3b, 4
**Files:**
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/IOpcUaAddressSpaceSink.cs`
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Commons/OpcUa/DeferredAddressSpaceSink.cs`
- Test: `tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/AddressSpaceSinkBatchTests.cs` (create)
**Step 1: Failing tests**
1. `Default_WriteValues_loops_WriteValue` — a minimal fake implementing only the existing members; call `WriteValues` with 3 items; the fake's recorded `WriteValue` calls match in order/content.
2. `Deferred_sink_forwards_WriteValues_to_inner_once` — a fake inner that overrides `WriteValues` (records one batch call); `DeferredAddressSpaceSink.WriteValues` reaches the inner's override (NOT 3 `WriteValue` calls).
**Step 3: Implement**
In `IOpcUaAddressSpaceSink.cs`:
```csharp
/// <summary>One value write within a <see cref="IOpcUaAddressSpaceSink.WriteValues"/> batch.
/// Same semantics per field as <see cref="IOpcUaAddressSpaceSink.WriteValue"/> (#519: status verbatim).</summary>
public readonly record struct ValueWrite(
string NodeId, object? Value, uint StatusCode, DateTime SourceTimestampUtc, AddressSpaceRealm Realm);
```
Add to the interface (after `WriteValue`):
```csharp
/// <summary>Write a batch of Variable values. Semantically identical to calling
/// <see cref="WriteValue"/> per item; an SDK-backed sink overrides this to take its internal
/// lock ONCE per batch instead of once per value (the hot-path win). The default implementation
/// loops <see cref="WriteValue"/> so existing sinks and fakes behave unchanged.</summary>
void WriteValues(IReadOnlyList<ValueWrite> values)
{
ArgumentNullException.ThrowIfNull(values);
for (var i = 0; i < values.Count; i++)
{
var v = values[i];
WriteValue(v.NodeId, v.Value, v.StatusCode, v.SourceTimestampUtc, v.Realm);
}
}
```
In `DeferredAddressSpaceSink`, add the forwarding override: `public void WriteValues(IReadOnlyList<ValueWrite> values) => _inner.WriteValues(values);`
**Step 4:** `dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests` — PASS.
**Step 5:** `git add -A && git commit -m "feat(commons): batched WriteValues on the address-space sink"`
---
### Task 6: OtOpcUaNodeManager batched write under one lock
**Classification:** high-risk (SDK locking)
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 7
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs` (WriteValue region, `:508-533`)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/SdkAddressSpaceSink.cs` (forward `WriteValues` to the manager)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerBatchWriteTests.cs` (create; copy the manager-construction idiom from an existing OpcUaServer.Tests file that instantiates `OtOpcUaNodeManager`)
**Step 1: Failing tests**
1. `WriteValues_lands_every_value` — materialise 3 variables (or rely on create-on-first-write), `WriteValues` a 3-item batch, read each variable's Value/StatusCode/Timestamp back via the manager's public surface.
2. `WriteValues_creates_missing_variables` — same lazy-create semantics as `WriteValue`.
**Step 3: Implement**
- Extract the body of `WriteValue`'s `lock (Lock)` block into `private void WriteValueLocked(string nodeId, ushort ns, string key, object? value, uint statusCode, DateTime ts)` (verbatim move).
- `WriteValue` computes `ns`/`key` then `lock (Lock) { WriteValueLocked(...); }` — behavior identical.
- Add:
```csharp
/// <summary>Batched WriteValue: one Lock acquisition for the whole batch. Same per-item semantics.</summary>
public void WriteValues(IReadOnlyList<ValueWrite> values)
{
ArgumentNullException.ThrowIfNull(values);
if (values.Count == 0) return;
EnsureAddressSpaceCreated();
lock (Lock)
{
for (var i = 0; i < values.Count; i++)
{
var v = values[i];
WriteValueLocked(v.NodeId, NamespaceIndexForRealm(v.Realm), MapKey(v.Realm, v.NodeId), v.Value, v.StatusCode, v.SourceTimestampUtc);
}
}
}
```
- In `SdkAddressSpaceSink`, override `WriteValues` forwarding to the manager's method (it currently forwards `WriteValue` 1:1 — mirror that).
**Step 4:** `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests` — PASS.
**Step 5:** `git add -A && git commit -m "feat(opcuaserver): node manager batched WriteValues under one lock"`
---
### Task 7: OpcUaPublishActor batch message
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 6
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorBatchTests.cs` (create; copy the actor-construction idiom from `OpcUaPublishActorApplyFailureTests.cs`)
**Step 1: Failing tests**
1. `Batch_message_reaches_sink_as_one_WriteValues_call` — fake sink records `WriteValues` batches; send `AttributeValueBatchUpdate` with 3 items; sink saw one batch of 3.
2. `Batch_increments_write_counter_per_item` — whatever surface exposes `_writes` (check for an existing status/metrics accessor in the actor's tests; if none, assert via the telemetry meter or skip the counter assertion and note it) — at minimum assert behavior parity: 3 items produce the same observable effect as 3 single `AttributeValueUpdate` messages.
**Step 3: Implement**
- Add next to `AttributeValueUpdate` (`:52`):
```csharp
/// <summary>Batched form of <see cref="AttributeValueUpdate"/>: every fan-out write of one driver
/// production burst in one message — the sink takes its lock once for the whole batch.</summary>
public sealed record AttributeValueBatchUpdate(IReadOnlyList<ValueWrite> Items);
```
(`ValueWrite` comes from `ZB.MOM.WW.OtOpcUa.Commons.OpcUa` — already imported.)
- Register `Receive<AttributeValueBatchUpdate>(HandleAttributeBatchUpdate);` beside `:288`.
- Handler mirrors `HandleAttributeUpdate` (`:319-331`): try/catch WARN on throw, `_sink.WriteValues(msg.Items)`, then `_writes += msg.Items.Count` via `Interlocked.Add(ref _writes, msg.Items.Count)` and `OtOpcUaTelemetry.OpcUaSinkWrite.Add(msg.Items.Count, new KeyValuePair<string, object?>("kind", "value"))` — per-item counting (invariant 9).
**Step 4:** `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~OpcUaPublishActorBatch"` then the whole project — PASS.
**Step 5:** `git add -A && git commit -m "feat(runtime): OpcUaPublishActor batched value updates"`
---
### Task 8: DriverHostActor batched fan-out (replaces the Task 4 bridge)
**Classification:** high-risk (the hottest routing seam)
**Estimated implement time:** ~5 min
**Parallelizable with:** none (needs 4 + 7; Task 9 lands with it)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs` (Task 9 content — same commit is fine if splitting is awkward, but prefer two commits)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorBatchFanOutTests.cs` (create; copy the host-actor fixture idiom from `DriverHostActorNativeAlarmTests.cs`)
**Step 1: Failing tests**
1. `Batch_fans_out_to_all_node_realm_refs_in_one_publish_message` — arrange the host with an applied composition where tag A maps to 1 raw + 1 UNS NodeId and tag B maps to 1 raw; send `AttributeValueBatchPublished` with A+B; the publish-actor probe receives exactly ONE `AttributeValueBatchUpdate` containing 3 `ValueWrite`s with correct NodeId/Realm/value/status/timestamp.
2. `Unmapped_items_are_dropped_not_faulted` — batch with one unmapped ref: other items still land; no crash.
3. `Batch_reaches_mux_once` — mux probe receives ONE batch message carrying all items (not N).
**Step 3: Implement**
Replace the Task 4 bridge with a real handler:
```csharp
private void ForwardBatchToMux(DriverInstanceActor.AttributeValueBatchPublished msg)
{
_dependencyMux?.Tell(msg); // one envelope per burst; the mux filters per item (Task 9)
if (_opcUaPublishActor is null) return;
List<ValueWrite>? writes = null;
foreach (var item in msg.Items)
{
if (_nodeIdByDriverRef.TryGetValue((msg.DriverInstanceId, item.FullReference), out var nodeIds))
{
writes ??= new List<ValueWrite>(msg.Items.Count * 2);
foreach (var n in nodeIds)
writes.Add(new ValueWrite(n.NodeId, item.Value, item.StatusCode, item.TimestampUtc, n.Realm));
}
else if (_log.IsDebugEnabled)
{
_log.Debug("DriverHost {Node}: no bound NodeId for ({Driver},{Ref}) — value dropped",
_localNode, msg.DriverInstanceId, item.FullReference);
}
}
if (writes is not null)
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AttributeValueBatchUpdate(writes));
}
```
Keep `ForwardToMux` and its `Receive<AttributeValuePublished>` registration untouched (comms-loss fan + legacy tests). Register `Receive<DriverInstanceActor.AttributeValueBatchPublished>(ForwardBatchToMux);` in place of the Task 4 bridge.
**Step 4:** `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests` — PASS.
**Step 5:** `git add -A && git commit -m "feat(runtime): DriverHostActor batched value fan-out"`
---
### Task 9: DependencyMuxActor batch receive
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 8 (same PR-let; do after 8's records exist)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/` — add a batch case beside the existing mux tests (find them via `CalculationDependencyFlowTests.cs` / grep `DependencyMuxActor` in that project)
**Step 1: Failing test** — register a subscriber for ref X; send a batch containing X and an uninteresting Y; subscriber receives exactly one `DependencyValueChanged` for X, nothing for Y.
**Step 3: Implement** — add:
```csharp
Receive<DriverInstanceActor.AttributeValueBatchPublished>(OnAttributeValueBatchPublished);
```
```csharp
private void OnAttributeValueBatchPublished(DriverInstanceActor.AttributeValueBatchPublished msg)
{
foreach (var item in msg.Items)
{
if (!_byRef.TryGetValue(item.FullReference, out var subscribers) || subscribers.Count == 0) continue;
var dep = new VirtualTagActor.DependencyValueChanged(item.FullReference, item.Value, item.TimestampUtc, item.StatusCode);
foreach (var sub in subscribers) sub.Tell(dep);
}
}
```
Keep the per-item receive (comms-loss path).
**Step 4/5:** project tests green; `git commit -m "feat(runtime): dependency mux accepts batched publishes"`
---
### Task 10: Raise pinned-dispatcher throughput
**Classification:** trivial
**Estimated implement time:** ~2 min
**Parallelizable with:** Task 1
**Files:**
- Modify: `src/Core/ZB.MOM.WW.OtOpcUa.Cluster/Resources/akka.conf:112-116`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs:301-312` (the inline HOCON copy)
Change `throughput = 1` → `throughput = 100` in BOTH places (they must match — the inline HOCON exists for consumers without akka.conf; see the comment at `ServiceCollectionExtensions.cs:301-303`). Update the comments: pinning alone guarantees the SDK's single-thread requirement; throughput only sets how many queued messages drain per scheduling run, so 100 removes a mailbox re-schedule round-trip per message with zero concurrency change.
Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests --filter "FullyQualifiedName~ServiceCollectionExtensionsTests"`
Commit: `perf(runtime): raise opcua pinned dispatcher throughput 1 -> 100`
---
### Task 11: Re-key node-manager maps (kill per-write key allocations)
**Classification:** high-risk (38 call sites, mechanical but wide)
**Estimated implement time:** ~5 min (compiler-driven)
**Parallelizable with:** none (after Task 6 — same file)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs`
**Step 1: Baseline** — run `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests` green BEFORE starting (this task relies on the existing suite as its net; it should need no new tests, but the whole suite must pass unchanged after).
**Step 2: Implement**
- Add near `MapKey` (`:126-139`):
```csharp
/// <summary>Zero-allocation realm-qualified map key. Replaces the formatted "ns=N;s=id" string key —
/// the (realm, id) pair is the same identity without the per-write NodeId + string allocations.</summary>
private readonly record struct RealmKey(AddressSpaceRealm Realm, string Id);
```
- Change `MapKey(AddressSpaceRealm realm, string id)` to return `new RealmKey(realm, id)`; change `MapKey(NodeId nodeId)` to `new RealmKey(RealmOf(nodeId.NamespaceIndex), (string)nodeId.Identifier)` — `RealmOf` already exists (~`:123`); confirm its exact name/signature and that every `MapKey(NodeId)` call site only ever passes nodes from the two owned namespaces (they do — SDK-facing seams on owned handles). If `nodeId.Identifier` can be non-string on any call path (numeric-id nodes), guard with `as string ?? nodeId.Identifier?.ToString() ?? string.Empty`.
- Change every map/set keyed by the old string key to `RealmKey`: `_variables`, `_folders`, `_alarmConditions`, `_historizedTagnames`, `_eventNotifierSources`, `_nativeAlarmNodeIds`, and the notifier-wiring bookkeeping — let the compiler find all of them (38 `MapKey` call sites).
- Prefix scans (`RemoveEquipmentSubtree` ~`:2539-2596`, `InScope` usages): match on `key.Realm == realm && key.Id.StartsWith(prefix, ...)` — semantics identical (the old string key embedded the realm as `ns=N;`).
- `ResolveNodeState(string key)` (~`:2240-2261`): change its parameter to `RealmKey`.
- Do NOT change public API surface or any behavior — this is a pure key-representation change.
**Step 3:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx` then `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests` and `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests` — ALL green, zero test edits expected (tests drive the public surface). If a test pokes the key format directly, stop and reassess rather than bending the test.
**Step 4:** `git add -A && git commit -m "perf(opcuaserver): struct realm keys replace formatted NodeId string keys"`
---
### Task 12: Full verification + docs
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none (last)
**Files:**
- Modify: `CLAUDE.md` (only if a statement it makes is now false — check the pinned-dispatcher/`throughput` mentions; keep edits surgical)
- Modify: `docs/v2/Galaxy.Performance.md` — add a short "value-path batching" note (one paragraph: the batch seams, the invariants, the dispatcher change)
Steps:
1. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — zero warnings introduced.
2. `dotnet test ZB.MOM.WW.OtOpcUa.slnx` — run what the environment can (DB-backed suites need `10.100.0.35`; if unreachable, run every non-DB project under tests/Core, tests/Drivers (unit), tests/Server/Runtime.Tests, tests/Server/OpcUaServer.Tests and record which suites were skipped and why in the commit message).
3. Docs + commit: `docs: record value-pipeline batching (plan 2026-08-15)`.
---
## Explicit non-goals (do NOT let scope creep into these)
- **No conflation / bounded mailboxes** — batching per burst is this plan's backpressure story; keyed conflation is a follow-up.
- **No fan-out-map push-down to DriverInstanceActor** — the host stays the router.
- **No MQTT / MTConnect / Sparkplug / Galaxy / OpcUaClient batch implementations** — they flow through the single-item-batch path unchanged.
- **No change to alarm paths** (`AttributeAlarmPublished`, `AlarmStateUpdate`) — value path only.
- **No PollGroupEngine cadence change** (fixed-rate scheduling is a separate item).
---
## Follow-ups (recorded, not implemented)
Raised during Tasks 1–12 review and deliberately left out of this plan's scope. None is a defect in the
delivered work; each is a place where the next change in the area could go wrong quietly.
1. **`PollGroupEngine` collects batch items unconditionally — the batch event is only as trustworthy as
the driver's `onChange`.** The engine has no knowledge of a driver's publish filter, so its items list
is pre-filter by construction. That is safe *today* only because S7, AbCip and FOCAS forward every
`onChange` unconditionally, and Modbus — the one driver with a deadband — ignores the engine's list and
raises from its own filtered buffer. The trap: a future per-tag filter added to S7/AbCip/FOCAS's
`onChange` would silently reintroduce the exact deadband-bypass class of bug fixed for Modbus in
`cd4ce305`, with a green test suite (the two events would simply disagree, and only a consumer
preferring the batch would notice). Consider changing the engine's `onChange` contract to
`Func<..., bool>` so the engine collects an item only when the driver reports it published — making the
filter structurally impossible to skip rather than a convention each driver must remember.
2. **Two-level `driverId → (rawPath → refs)` routing map in `DriverHostActor`.** The fan-out map is keyed
by a composite of driver id + raw path, so resolving a batch re-hashes the driver id once per item even
though a batch is by definition single-driver. Nesting the map would hash the driver id once per batch
and the raw path once per item. Measure first — batches are small enough that this may be noise
relative to the message-count win already banked.
3. **Null/empty guard consistency on the internal batch messages.** The guards on
`AttributeValueBatchPublished` / `AttributeValueBatchUpdate` grew per task and are not uniform — some
seams drop an empty batch early, others tolerate it downstream. These are internal messages with
trusted senders so nothing is unsafe today; worth one pass to make the contract identical at every hop
and stated once, rather than re-derived per reader.
4. **Possible merge of `DriverHostActorLiveValueTests` into the batch suite.** After Task 8 both files
drive the same fan-out through the same message; the split is now historical rather than meaningful.
Merging would remove a genuine ambiguity about where a new fan-out test belongs — but check first that
the older suite's per-item comms-loss cases really are covered, since those enter via a different seam.
5. **Aborted-cycle tail-loss when a per-tag handler throws, for the three unbuffered drivers.** If a
consumer's `onChange` throws mid-cycle in S7/AbCip/FOCAS, the cycle aborts and the items already
collected for that cycle never reach a batch raise. **Pre-existing and narrower than the Modbus case**
(Modbus's own buffer is per-handle and is dropped on unsubscribe/dispose, so it cannot leak), and it
predates this plan — the per-tag path lost the same tail before batching existed. Recorded so it is not
mistaken for a regression introduced here.
@@ -0,0 +1,19 @@
{
"planPath": "docs/plans/2026-08-15-value-pipeline-batching.md",
"tasks": [
{"id": 1, "subject": "Task 1: Batch contract types in Core.Abstractions", "status": "completed"},
{"id": 2, "subject": "Task 2: PollGroupEngine batch callback", "status": "completed", "blockedBy": [1]},
{"id": 3, "subject": "Task 3a: Modbus + S7 raise the batch event", "status": "completed", "blockedBy": [2]},
{"id": 4, "subject": "Task 3b: AbCip + FOCAS raise the batch event", "status": "completed", "blockedBy": [2]},
{"id": 5, "subject": "Task 4: DriverInstanceActor batch forwarding", "status": "completed", "blockedBy": [1]},
{"id": 6, "subject": "Task 5: ValueWrite + sink WriteValues default method", "status": "completed"},
{"id": 7, "subject": "Task 6: OtOpcUaNodeManager batched write under one lock", "status": "completed", "blockedBy": [6]},
{"id": 8, "subject": "Task 7: OpcUaPublishActor batch message", "status": "completed", "blockedBy": [6]},
{"id": 9, "subject": "Task 8: DriverHostActor batched fan-out", "status": "completed", "blockedBy": [5, 8]},
{"id": 10, "subject": "Task 9: DependencyMuxActor batch receive", "status": "completed", "blockedBy": [9]},
{"id": 11, "subject": "Task 10: Raise pinned-dispatcher throughput", "status": "completed"},
{"id": 12, "subject": "Task 11: Re-key node-manager maps", "status": "completed", "blockedBy": [7]},
{"id": 13, "subject": "Task 12: Full verification + docs", "status": "completed", "blockedBy": [3, 4, 9, 10, 11, 12]}
],
"lastUpdated": "2026-08-15T17:08:35Z"
}
+53
View File
@@ -181,3 +181,56 @@ Safety: reuse is serialised under `_evalGate`, so two threads can never observe
- `Reevaluation_reuses_the_same_read_cache_dictionary` — asserts dictionary instance identity across two evaluations.
- `Reevaluation_reuses_the_same_predicate_context` — same, for the context.
- `LoadAsync_drops_the_prior_generations_scratch` — asserts a publish resets the scratch.
## Value-path batching (2026-08-15)
The hot value path is batched end to end: one message per driver production burst (a poll cycle, an
inbound message, a stream chunk) instead of one per changed tag at every hop, one SDK-lock acquisition
per batch instead of one per value, and no per-write key allocation in the node manager. Nothing about
*what* is published changed — only how many messages and lock takes it costs. Plan +
task-by-task rationale: [`docs/plans/2026-08-15-value-pipeline-batching.md`](../plans/2026-08-15-value-pipeline-batching.md).
**The seams, in order:**
- **`IBatchSubscribable` / `PollGroupEngine`'s `onBatch`** — an optional driver capability raising
`OnDataChangeBatch` once per burst. The four engine-based poll drivers (Modbus, S7, AbCip, FOCAS) get
it from the engine's batch callback. The per-tag `ISubscribable.OnDataChange` is unchanged and still
raised — Galaxy cold reads and the driver CLIs subscribe it. A consumer subscribes to **exactly one**
of the two; subscribing both double-delivers.
- **`DriverInstanceActor` → `AttributeValueBatchPublished`** — one per burst, preferring the batch event
when the driver implements `IBatchSubscribable` and wrapping a non-batch driver's per-tag events as
single-item batches, so downstream sees one shape. The detach path tears the batch handler down too
(load-bearing: the `IDriver` outlives the actor across host respawns).
- **`DriverHostActor` → `AttributeValueBatchUpdate`** — resolves the whole batch's `NodeRealmRef`s
(raw NodeId + every referencing UNS NodeId) and sends **one** message to `OpcUaPublishActor`, plus one
batch Tell to the dependency mux. The mux keeps its per-item receive — the comms-loss fan-out
fabricates per-item messages.
- **`OtOpcUaNodeManager.WriteValues`** — the sink's batch override takes the manager-wide `Lock` once per
chunk of **512** items rather than once per value, with each item isolated so one bad node cannot lose
the rest of the batch.
**Invariants the batching preserves — do not break them:**
- **#519 status codes ride verbatim.** The full `uint` status code, sub-code included, reaches the SDK
unchanged at every hop; batching moved the values, it did not re-derive them. Timestamps likewise come
from the driver — nothing on this path stamps `DateTime.UtcNow`.
- **Metering stays per ITEM, not per batch** (`OpcUaPublishActor`'s write tally + the
`OtOpcUaTelemetry.OpcUaSinkWrite` meter). Counting per message would have silently dropped the
dashboards by up to 100×.
- **Deadband is honored on the batch event.** ⚠️ `PollGroupEngine` collects its batch list
**unconditionally** — it has no knowledge of a driver's publish filter — so `ModbusDriver` ignores the
engine's list and raises from its own buffer of what actually passed `ShouldPublish`, using `onBatch`
purely as the end-of-cycle signal. Forwarding the engine's list instead would carry changes the per-tag
event suppressed, and a consumer preferring the batch event would see deadband silently stop working.
- **Partial failure is typed, not swallowed.** A sink that applies some of a batch and fails the rest
reports `ValueWriteBatchException` (first failed NodeId + failed/succeeded counts); the publish actor's
typed catch advances the tally by the **succeeded** count — not zero (the general catch's behaviour) and
not the whole batch.
**Two supporting changes:** the node manager's variable / folder / alarm / historized-tagname maps are
re-keyed by a `RealmKey(Realm, Id)` struct, so a write no longer allocates `new NodeId(id, ns).ToString()`
per value (the realm still travels explicitly — it is never parsed back out of an id string). And the
pinned `opcua-synchronized-dispatcher`'s `throughput` rose **1 → 100**: the actor keeps its own dedicated
thread (that is what the `PinnedDispatcher` guarantees, not `throughput`), so this only removes a mailbox
re-schedule round-trip per message on the hottest actor in the process, with zero change to concurrency.
Keep it in sync with the inline HOCON copy in `ServiceCollectionExtensions.WithOtOpcUaRuntimeActors`.
@@ -109,8 +109,15 @@ akka {
# Pinned dispatcher used by OpcUaPublishActor (Task 44) so the OPC UA SDK sees
# only one thread per actor instance — its session/subscription locks expect
# strict single-threaded access.
#
# throughput is NOT what provides that guarantee: the PinnedDispatcher does, by
# giving the actor its own dedicated thread. throughput only sets how many queued
# messages drain per scheduling run, so 100 removes a mailbox re-schedule
# round-trip per message on the hottest actor in the process with zero change to
# concurrency. Must stay in sync with the inline HOCON copy in
# ServiceCollectionExtensions.WithOtOpcUaRuntimeActors.
opcua-synchronized-dispatcher {
type = "PinnedDispatcher"
executor = "thread-pool-executor"
throughput = 1
throughput = 100
}
@@ -26,6 +26,12 @@ public sealed class DeferredAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgical
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _inner.WriteValue(nodeId, value, statusCode, sourceTimestampUtc, realm);
/// <inheritdoc />
// Forward the batch to the inner sink. Without this the interface's default per-item loop runs here and
// the one-lock-per-batch win is silently degraded back to one lock per value on every driver-role host
// (actors inject THIS wrapper, not the inner SdkAddressSpaceSink) — same trap as WireAlarmNotifiers above.
public void WriteValues(IReadOnlyList<ValueWrite> values) => _inner.WriteValues(values);
/// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _inner.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
@@ -22,6 +22,26 @@ public interface IOpcUaAddressSpaceSink
/// calls this once per NodeId (raw + every referencing UNS node) with the matching realm.</param>
void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm);
/// <summary>Write a batch of Variable values. Semantically identical to calling
/// <see cref="WriteValue"/> per item; an SDK-backed sink overrides this to take its internal
/// lock ONCE per batch instead of once per value (the hot-path win). The default implementation
/// loops <see cref="WriteValue"/> so existing sinks and fakes behave unchanged.
/// <para>A batching implementation MUST isolate per item — one bad item may not drop the tail of the
/// batch — and report the aggregate as a <see cref="ValueWriteBatchException"/>; see that type for why.
/// The default loop above predates the contract and is only used by fakes / the no-op sink.</para></summary>
/// <param name="values">The value writes to apply, in order.</param>
/// <exception cref="ValueWriteBatchException">A batching implementation throws this when some — but not
/// necessarily all — items failed; every other item has been applied.</exception>
void WriteValues(IReadOnlyList<ValueWrite> values)
{
ArgumentNullException.ThrowIfNull(values);
for (var i = 0; i < values.Count; i++)
{
var v = values[i];
WriteValue(v.NodeId, v.Value, v.StatusCode, v.SourceTimestampUtc, v.Realm);
}
}
/// <summary>Write an alarm-condition's full Part 9 state. When a real condition node has been
/// materialised for <paramref name="alarmNodeId"/> via <see cref="MaterialiseAlarmCondition"/>,
/// this projects the whole <see cref="AlarmConditionSnapshot"/>
@@ -165,6 +185,35 @@ public interface IOpcUaAddressSpaceSink
string referenceType = "Organizes");
}
/// <summary>One value write within a <see cref="IOpcUaAddressSpaceSink.WriteValues"/> batch.
/// Same semantics per field as <see cref="IOpcUaAddressSpaceSink.WriteValue"/> (#519: status verbatim).</summary>
public readonly record struct ValueWrite(
string NodeId, object? Value, uint StatusCode, DateTime SourceTimestampUtc, AddressSpaceRealm Realm);
/// <summary>Aggregate outcome of a partially-failed <see cref="IOpcUaAddressSpaceSink.WriteValues"/> batch:
/// every non-failing item HAS been applied; this carries what the caller needs to log and meter honestly.</summary>
/// <remarks>A batched sink must NOT abandon the tail of a batch when one item throws: the poll engine
/// updates its last-value cache BEFORE raising, so a dropped item is never re-raised until that tag's value
/// changes again — the node would serve a stale value indefinitely. Hence the sink isolates per item and
/// reports the aggregate here rather than letting the first failure escape.</remarks>
/// <param name="firstFailedNodeId">The NodeId of the first item that failed (empty when that item's own
/// NodeId was null/empty — the validation failure this type most often reports).</param>
/// <param name="failedCount">How many items of the batch failed.</param>
/// <param name="succeededCount">How many items of the batch were applied.</param>
/// <param name="firstFailure">The exception the first failing item produced; the inner exception.</param>
public sealed class ValueWriteBatchException(string firstFailedNodeId, int failedCount, int succeededCount, Exception firstFailure)
: Exception($"WriteValues batch: {failedCount} of {failedCount + succeededCount} items failed (first: '{firstFailedNodeId}')", firstFailure)
{
/// <summary>Gets the NodeId of the first item that failed.</summary>
public string FirstFailedNodeId { get; } = firstFailedNodeId;
/// <summary>Gets the number of items of the batch that failed.</summary>
public int FailedCount { get; } = failedCount;
/// <summary>Gets the number of items of the batch that were successfully applied.</summary>
public int SucceededCount { get; } = succeededCount;
}
/// <summary>No-op sink. Bound by default so the actors are safe to run in dev / Mac /
/// integration tests without a real SDK behind them.</summary>
public sealed class NullOpcUaAddressSpaceSink : IOpcUaAddressSpaceSink
@@ -51,3 +51,31 @@ public sealed record DataChangeEventArgs(
ISubscriptionHandle SubscriptionHandle,
string FullReference,
DataValueSnapshot Snapshot);
/// <summary>One changed attribute within a <see cref="DataChangeBatchEventArgs"/>.</summary>
/// <param name="FullReference">Driver-side full reference of the changed attribute.</param>
/// <param name="Snapshot">New value + quality + timestamps.</param>
public sealed record DataChangeItem(string FullReference, DataValueSnapshot Snapshot);
/// <summary>Event payload for <see cref="IBatchSubscribable.OnDataChangeBatch"/> — every attribute
/// that changed within ONE production burst (a poll cycle, an inbound message, a stream chunk).</summary>
/// <param name="SubscriptionHandle">The handle returned by the original <see cref="ISubscribable.SubscribeAsync"/> call.</param>
/// <param name="Items">The changed attributes, one entry per changed tag. Never empty. Ownership transfers
/// to the consumer, which may retain the list past the raise (it is marshalled onto another thread) — so the
/// driver MUST NOT mutate or reuse the list instance after raising; allocate a fresh one per burst.</param>
public sealed record DataChangeBatchEventArgs(
ISubscriptionHandle SubscriptionHandle,
IReadOnlyList<DataChangeItem> Items);
/// <summary>
/// Optional driver capability: data changes delivered one event per production burst instead of
/// one event per tag. A driver implementing this raises <see cref="OnDataChangeBatch"/> once per
/// burst AND continues raising the per-tag <see cref="ISubscribable.OnDataChange"/> for each item
/// (legacy consumers: Galaxy cold reads, driver CLIs). A consumer subscribes to EXACTLY ONE of the
/// two events — subscribing both double-delivers. DriverInstanceActor prefers the batch event.
/// </summary>
public interface IBatchSubscribable : ISubscribable
{
/// <summary>Batched data-change notification — one raise per production burst.</summary>
event EventHandler<DataChangeBatchEventArgs>? OnDataChangeBatch;
}
@@ -24,6 +24,15 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
/// can record the failure. Without an <c>onError</c> callback the exception is silently
/// swallowed (preserves the original behaviour for drivers that have not opted in yet).</para>
///
/// <para>An optional <c>onBatch</c> callback delivers the SAME changes collected per poll
/// cycle: it is raised at most once per cycle, only when that cycle produced at least one
/// change, and always AFTER every per-tag <c>onChange</c> callback of that same cycle. Drivers
/// forward it to <see cref="IBatchSubscribable.OnDataChangeBatch"/> so downstream consumers can
/// take one message per cycle instead of one per tag. Both callbacks fire by design — a
/// consumer subscribes to exactly one of the two. Without an <c>onBatch</c> callback the engine
/// behaves byte-identically to before: no batch is collected and no per-cycle allocation is
/// made.</para>
///
/// <para>Programmer errors and obviously-fatal exceptions (<see cref="OutOfMemoryException"/>,
/// <see cref="ThreadAbortException"/>, <see cref="StackOverflowException"/>,
/// <see cref="AccessViolationException"/>) are NOT caught — they propagate and tear the poll
@@ -34,6 +43,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
private readonly Func<IReadOnlyList<string>, CancellationToken, Task<IReadOnlyList<DataValueSnapshot>>> _reader;
private readonly Action<ISubscriptionHandle, string, DataValueSnapshot> _onChange;
private readonly Action<Exception>? _onError;
private readonly Action<ISubscriptionHandle, IReadOnlyList<DataChangeItem>>? _onBatch;
private readonly TimeSpan _minInterval;
private readonly TimeSpan? _backoffCap;
private readonly ConcurrentDictionary<long, SubscriptionState> _subscriptions = new();
@@ -58,12 +68,18 @@ public sealed class PollGroupEngine : IAsyncDisposable
/// (interval, 2×, 4×, … saturating at this cap) instead of hammering a dead device every
/// tick; a successful poll resets to the base interval. <c>null</c> (default) keeps the
/// byte-identical fixed-interval cadence for consumers that have not opted in.</param>
/// <param name="onBatch">Optional per-cycle batch callback — invoked once per poll cycle that
/// produced at least one change, carrying exactly that cycle's changed items in reader
/// order, after all of the cycle's per-tag <paramref name="onChange"/> callbacks. The
/// driver forwards it to its own <see cref="IBatchSubscribable.OnDataChangeBatch"/> event.
/// <c>null</c> (default) collects nothing and allocates nothing per cycle.</param>
public PollGroupEngine(
Func<IReadOnlyList<string>, CancellationToken, Task<IReadOnlyList<DataValueSnapshot>>> reader,
Action<ISubscriptionHandle, string, DataValueSnapshot> onChange,
TimeSpan? minInterval = null,
Action<Exception>? onError = null,
TimeSpan? backoffCap = null)
TimeSpan? backoffCap = null,
Action<ISubscriptionHandle, IReadOnlyList<DataChangeItem>>? onBatch = null)
{
ArgumentNullException.ThrowIfNull(reader);
ArgumentNullException.ThrowIfNull(onChange);
@@ -72,6 +88,7 @@ public sealed class PollGroupEngine : IAsyncDisposable
_onError = onError;
_minInterval = minInterval ?? DefaultMinInterval;
_backoffCap = backoffCap;
_onBatch = onBatch;
}
/// <summary>Register a new polled subscription and start its background loop.</summary>
@@ -211,6 +228,10 @@ public sealed class PollGroupEngine : IAsyncDisposable
$"Reader contract violation: expected {state.TagReferences.Count} snapshots but received {snapshots.Count}. " +
"The reader delegate must return one snapshot per input reference in input order.");
// Collected lazily: nothing is allocated for a cycle with no changes, and nothing at all
// when no batch callback is wired.
List<DataChangeItem>? changed = null;
for (var i = 0; i < state.TagReferences.Count; i++)
{
var tagRef = state.TagReferences[i];
@@ -221,8 +242,15 @@ public sealed class PollGroupEngine : IAsyncDisposable
{
state.LastValues[tagRef] = current;
_onChange(state.Handle, tagRef, current);
// Capacity hint: a cycle can produce at most one item per subscribed reference, so
// sizing the list once avoids the doubling re-allocations a large group would incur.
if (_onBatch is not null)
(changed ??= new List<DataChangeItem>(state.TagReferences.Count)).Add(new DataChangeItem(tagRef, current));
}
}
// One raise per cycle, after every per-tag callback of that cycle, never empty.
if (changed is { Count: > 0 }) _onBatch!(state.Handle, changed);
}
/// <summary>
@@ -24,7 +24,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// (called in <see cref="DeviceState.DisposeHandles"/>); the library's own finalizer
/// handles GC-collected tags.</para>
/// </remarks>
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, IBatchSubscribable,
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, ISkippedTagReporter, IDisposable, IAsyncDisposable
{
/// <summary>Authored tags this driver could not build into its poll table (Gitea #541).</summary>
@@ -114,6 +114,14 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <summary>Occurs when a subscribed tag's value changes.</summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <summary>
/// Occurs once per poll cycle that produced at least one change, carrying that cycle's
/// changed tags. Both data-change events fire by design — this one once per cycle,
/// <see cref="OnDataChange"/> once per changed tag — so a consumer subscribes to EXACTLY
/// ONE of the two; subscribing both double-delivers.
/// </summary>
public event EventHandler<DataChangeBatchEventArgs>? OnDataChangeBatch;
/// <summary>Occurs when a device's host connectivity status changes.</summary>
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
@@ -151,7 +159,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
onChange: (handle, tagRef, snapshot) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
onError: HandlePollError,
backoffCap: PollBackoffCap);
backoffCap: PollBackoffCap,
onBatch: (handle, items) =>
OnDataChangeBatch?.Invoke(this, new DataChangeBatchEventArgs(handle, items)));
_alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval, _logger);
}
@@ -18,7 +18,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
/// + the default <see cref="UnimplementedFocasClientFactory"/> makes misconfigured servers
/// fail fast.
/// </remarks>
public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, IBatchSubscribable,
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, ISkippedTagReporter, IDisposable, IAsyncDisposable
{
/// <summary>Authored tags this driver could not build into its poll table (Gitea #541). FOCAS has three
@@ -80,6 +80,13 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <summary>Occurs when data changes on a subscribed tag.</summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <summary>
/// Occurs once per poll cycle that produced at least one change, carrying that cycle's
/// changed tags. Both data-change events fire by design — this one once per cycle,
/// <see cref="OnDataChange"/> once per changed tag — so a consumer subscribes to EXACTLY
/// ONE of the two; subscribing both double-delivers.
/// </summary>
public event EventHandler<DataChangeBatchEventArgs>? OnDataChangeBatch;
/// <summary>Occurs when a device host connection status changes.</summary>
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
/// <summary>Occurs when an alarm event is raised.</summary>
@@ -112,7 +119,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
onChange: (handle, tagRef, snapshot) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
onError: HandlePollError,
backoffCap: PollBackoffCap);
backoffCap: PollBackoffCap,
onBatch: (handle, items) =>
OnDataChangeBatch?.Invoke(this, new DataChangeBatchEventArgs(handle, items)));
}
/// <summary>True when the supplied DriverConfig JSON carries a real body. The bootstrapper always passes
@@ -19,7 +19,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus;
/// <see cref="PollGroupEngine"/> since Modbus has no native push model.
/// </remarks>
public sealed class ModbusDriver
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, ISkippedTagReporter, IDisposable, IAsyncDisposable
: IDriver, ITagDiscovery, IReadable, IWritable, IBatchSubscribable, IHostConnectivityProbe, IPerCallHostResolver, ISkippedTagReporter, IDisposable, IAsyncDisposable
{
/// <summary>Authored tags this driver could not build into its poll table (Gitea #541).</summary>
private readonly SkippedTagLedger _skippedTags = new();
@@ -53,6 +53,17 @@ public sealed class ModbusDriver
// does a typed cast inside.
private readonly ConcurrentDictionary<string, object> _lastPublishedByRef = new(StringComparer.OrdinalIgnoreCase);
// Per-cycle buffer of the items this driver actually PUBLISHED (i.e. that passed ShouldPublish),
// keyed by subscription handle. The poll engine collects its own batch list unconditionally — it
// has no knowledge of the deadband filter — so the batch event is raised from this buffer instead,
// using the engine's onBatch purely as the end-of-cycle signal.
//
// No lock is needed on the per-handle List: the engine runs one loop task per subscription, so for
// ONE handle all of a cycle's onChange calls and that cycle's single onBatch run sequentially on
// that subscription's own task. Different subscriptions run concurrently — hence the per-handle
// ConcurrentDictionary.
private readonly ConcurrentDictionary<ISubscriptionHandle, List<DataChangeItem>> _cycleBatchItems = new();
// Last-written value per tag for the WriteOnChangeOnly suppression. Invalidated by reads
// that return a different value (so an HMI-side change doesn't get masked).
private readonly Dictionary<string, object?> _lastWrittenByRef = new(StringComparer.OrdinalIgnoreCase);
@@ -80,6 +91,13 @@ public sealed class ModbusDriver
/// <summary>Occurs when a subscribed tag value changes.</summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <summary>
/// Occurs once per poll cycle that produced at least one change, carrying that cycle's
/// changed tags. Both data-change events fire by design — this one once per cycle,
/// <see cref="OnDataChange"/> once per changed tag — so a consumer subscribes to EXACTLY
/// ONE of the two; subscribing both double-delivers.
/// </summary>
public event EventHandler<DataChangeBatchEventArgs>? OnDataChangeBatch;
/// <summary>Occurs when host connectivity status changes.</summary>
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
@@ -122,10 +140,35 @@ public sealed class ModbusDriver
onChange: (handle, tagRef, snapshot) =>
{
if (!ShouldPublish(tagRef, snapshot)) return;
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot));
// Buffer what we publish so the cycle-end batch carries exactly the same items.
// The append stays OUTSIDE the catch below on purpose: as far as the batch contract
// is concerned the item WAS published — only the per-tag subscriber faulted — so it
// must still appear in this cycle's batch, keeping the two events content-identical.
_cycleBatchItems.GetOrAdd(handle, _ => new List<DataChangeItem>())
.Add(new DataChangeItem(tagRef, snapshot));
// Defensive, mirroring PollGroupEngine.ReportError: the engine invokes this bridge
// UNGUARDED, so a throwing external subscriber would unwind PollOnceAsync mid-loop
// and the cycle would never reach onBatch. Because the buffer above persists across
// cycles (it is drained only by onBatch), those items would then silently MERGE into
// a later cycle's batch — cross-cycle contamination unique to this driver. Swallowing
// is what keeps the cycle running so onBatch drains the buffer on schedule.
try { OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)); }
catch { /* a buggy subscriber must not corrupt the poll cycle */ }
},
onError: HandlePollError,
backoffCap: PollBackoffCap);
backoffCap: PollBackoffCap,
// The engine's own items list is deliberately IGNORED: it is collected pre-deadband
// (PollGroupEngine has no knowledge of ShouldPublish), so forwarding it would carry
// changes the per-tag event suppressed — and a consumer that prefers the batch event
// would see deadband silently stop working. The buffer holds exactly what the per-tag
// event published, keeping the two events content-identical per the IBatchSubscribable
// contract. onBatch is used only as the end-of-cycle signal. A cycle whose every change
// was suppressed leaves the buffer empty and raises nothing.
onBatch: (handle, _) =>
{
if (_cycleBatchItems.TryRemove(handle, out var published) && published.Count > 0)
OnDataChangeBatch?.Invoke(this, new DataChangeBatchEventArgs(handle, published));
});
}
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
@@ -1207,6 +1250,9 @@ public sealed class ModbusDriver
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
{
_poll.Unsubscribe(handle);
// Drop any partially-collected cycle buffer so a torn-down subscription leaks nothing.
// Unsubscribe awaits the loop task, so no onChange can re-add after this point.
_cycleBatchItems.TryRemove(handle, out _);
return Task.CompletedTask;
}
@@ -1648,6 +1694,8 @@ public sealed class ModbusDriver
lock (_autoProhibitedLock) _autoProhibited.Clear();
await _poll.DisposeAsync().ConfigureAwait(false);
// After the engine's loops are drained, no onChange can re-populate the per-cycle buffers.
_cycleBatchItems.Clear();
if (_transport is not null) await _transport.DisposeAsync().ConfigureAwait(false);
_transport = null;
@@ -30,7 +30,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7;
/// </para>
/// </remarks>
public sealed class S7Driver
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, ISkippedTagReporter, IDisposable, IAsyncDisposable
: IDriver, ITagDiscovery, IReadable, IWritable, IBatchSubscribable, IHostConnectivityProbe, ISkippedTagReporter, IDisposable, IAsyncDisposable
{
/// <summary>Authored tags this driver could not build into its poll table (Gitea #541).</summary>
private readonly SkippedTagLedger _skippedTags = new();
@@ -68,7 +68,8 @@ public sealed class S7Driver
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
minInterval: TimeSpan.FromMilliseconds(100),
onError: HandlePollError,
backoffCap: TimeSpan.FromSeconds(30));
backoffCap: TimeSpan.FromSeconds(30),
onBatch: (handle, items) => OnDataChangeBatch?.Invoke(this, new DataChangeBatchEventArgs(handle, items)));
}
// ---- ISubscribable + IHostConnectivityProbe state ----
@@ -93,6 +94,13 @@ public sealed class S7Driver
/// <summary>Occurs when a subscribed tag value or status code changes.</summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <summary>
/// Occurs once per poll cycle that produced at least one change, carrying that cycle's
/// changed tags. Both data-change events fire by design — this one once per cycle,
/// <see cref="OnDataChange"/> once per changed tag — so a consumer subscribes to EXACTLY
/// ONE of the two; subscribing both double-delivers.
/// </summary>
public event EventHandler<DataChangeBatchEventArgs>? OnDataChangeBatch;
/// <summary>Occurs when host connectivity status changes.</summary>
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
@@ -52,21 +52,21 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// v3 URIs are the real namespaces; this alias is removed once those tests target Raw/UNS explicitly.</summary>
public const string DefaultNamespaceUri = V3NodeIds.UnsNamespaceUri;
private readonly ConcurrentDictionary<string, BaseDataVariableState> _variables = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, FolderState> _folders = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, AlarmConditionState> _alarmConditions = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<RealmKey, BaseDataVariableState> _variables = new();
private readonly ConcurrentDictionary<RealmKey, FolderState> _folders = new();
private readonly ConcurrentDictionary<RealmKey, AlarmConditionState> _alarmConditions = new();
/// <summary>H6a: the subset of <see cref="_alarmConditions"/> node ids materialised as NATIVE
/// (driver-fed, e.g. Galaxy equipment-tag alarms) rather than scripted. A later task routes a native
/// condition's inbound Acknowledge to the driver instead of the scripted engine, so the node manager
/// must know which conditions are native. Maintained in lock-step with <see cref="_alarmConditions"/>:
/// a native re-materialise adds, and <see cref="RebuildAddressSpace"/> clears it alongside
/// <see cref="_alarmConditions"/> so a re-materialise as the other kind is correct.</summary>
private readonly HashSet<string> _nativeAlarmNodeIds = new(StringComparer.Ordinal);
private readonly HashSet<RealmKey> _nativeAlarmNodeIds = new();
/// <summary>Phase C: NodeId → resolved historian tagname for every variable materialised
/// Historizing. Populated by <see cref="EnsureVariable"/> when a historian tagname is supplied; the
/// (later) HistoryRead override resolves a HistoryRead request's NodeId against this map. Cleared on
/// <see cref="RebuildAddressSpace"/>.</summary>
private readonly ConcurrentDictionary<string, string> _historizedTagnames = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<RealmKey, string> _historizedTagnames = new();
/// <summary>Folders we have already promoted to event-notifiers + registered as root notifiers,
/// so repeated <see cref="MaterialiseAlarmCondition"/> calls don't double-add (idempotent guard).
/// Keyed by NodeId → the actual <see cref="FolderState"/> so <see cref="RebuildAddressSpace"/> can
@@ -81,7 +81,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// each wired pair is torn down bidirectionally (<c>RemoveNotifier(bidirectional:true)</c>) so an
/// inverse-notifier entry never leaks across redeploys. Guarded by the same <c>Lock</c> as
/// <see cref="_alarmConditions"/> / <see cref="_notifierFolders"/>.</summary>
private readonly Dictionary<string, AlarmNotifierWiring> _alarmNotifierWiring = new(StringComparer.Ordinal);
private readonly Dictionary<RealmKey, AlarmNotifierWiring> _alarmNotifierWiring = new();
/// <summary>Phase C: event-notifier folder NodeId-identifier → the event-history source
/// name passed to <see cref="IHistorianDataSource.ReadEventsAsync"/>. The equipment-folder NodeId
/// identifier IS the equipment id, which IS the sourceName, so key and value are the same string;
@@ -90,7 +90,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// and the <see cref="HistoryReadEvents"/> override resolves an inbound request's notifier NodeId
/// against it (a miss ⇒ <c>BadHistoryOperationUnsupported</c>). Cleared on
/// <see cref="RebuildAddressSpace"/>.</summary>
private readonly ConcurrentDictionary<string, string> _eventNotifierSources = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<RealmKey, string> _eventNotifierSources = new();
private FolderState? _root;
/// <summary>Initializes a new instance of the <see cref="OtOpcUaNodeManager"/> class with the OPC UA server and configuration.</summary>
@@ -123,20 +123,43 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
private AddressSpaceRealm RealmOf(NodeId nodeId) =>
nodeId.NamespaceIndex == NamespaceIndexes[0] ? AddressSpaceRealm.Raw : AddressSpaceRealm.Uns;
/// <summary>Build the internal map key for a realm-qualified string id. The key is the node's full
/// namespace-qualified NodeId string (e.g. <c>ns=2;s=&lt;id&gt;</c>) so a Raw node and a UNS node that
/// share the same <c>s=</c> identifier are distinct keys — a bare id is no longer globally unique across
/// the two namespaces. Matches <see cref="MapKey(NodeId)"/> for the same node.</summary>
/// <summary>Zero-allocation realm-qualified map key. Replaces the formatted "ns=N;s=id" string key —
/// the (realm, id) pair is the same identity without the per-write NodeId + string allocations
/// (the id string is already in hand at every call site).</summary>
/// <param name="Realm">The realm the id lives in.</param>
/// <param name="Id">The bare <c>s=</c> identifier.</param>
private readonly record struct RealmKey(AddressSpaceRealm Realm, string Id);
/// <summary>Build the internal map key for a realm-qualified string id. The key pairs the realm with the
/// bare <c>s=</c> identifier so a Raw node and a UNS node that share the same identifier are distinct
/// keys — a bare id is not globally unique across the two namespaces. Matches
/// <see cref="MapKey(NodeId)"/> for the same node.</summary>
/// <param name="realm">The realm the id lives in.</param>
/// <param name="id">The bare <c>s=</c> identifier.</param>
/// <returns>The namespace-qualified map key.</returns>
private string MapKey(AddressSpaceRealm realm, string id) => new NodeId(id, NamespaceIndexForRealm(realm)).ToString();
/// <returns>The realm-qualified map key.</returns>
private static RealmKey MapKey(AddressSpaceRealm realm, string id) => new(realm, id);
/// <summary>Build the internal map key for an already-resolved SDK <see cref="NodeId"/> (the SDK-facing
/// seams). Equal to <see cref="MapKey(AddressSpaceRealm, string)"/> for the same node.</summary>
/// seams). Equal to <see cref="MapKey(AddressSpaceRealm, string)"/> for the same node. The SDK-facing
/// seams only ever pass nodes this manager minted (string <c>s=</c> identifiers); a non-string
/// identifier falls back to its string form rather than throwing — and says so loudly, because such a
/// key cannot match anything the manager stored, so the lookup would silently miss.</summary>
/// <param name="nodeId">The resolved SDK node id.</param>
/// <returns>The namespace-qualified map key.</returns>
private static string MapKey(NodeId nodeId) => nodeId.ToString();
/// <returns>The realm-qualified map key.</returns>
private RealmKey MapKey(NodeId nodeId)
{
if (nodeId.Identifier is string s) return new RealmKey(RealmOf(nodeId), s);
// Unreachable by construction — every call site passes a node this manager minted with a string
// `s=` identifier. If it ever fires, the derived key resolves to nothing and the caller sees an
// unexplained miss, so name the seam rather than collapsing quietly.
#pragma warning disable CS0618 // Utils.LogWarning is [Obsolete] in favour of an ITelemetryContext this manager doesn't carry.
Utils.LogWarning(
"OtOpcUaNodeManager.MapKey: non-string NodeId identifier reached the map-key seam (node {0}, identifier type {1}) — the derived key will not resolve",
nodeId, nodeId.Identifier?.GetType().Name ?? "null");
#pragma warning restore CS0618
return new RealmKey(RealmOf(nodeId), nodeId.Identifier?.ToString() ?? string.Empty);
}
/// <summary>Gets the count of variable nodes currently managed.</summary>
public int VariableCount => _variables.Count;
@@ -503,8 +526,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// </summary>
/// <param name="nodeId">The node identifier of the variable.</param>
/// <param name="value">The new value to write.</param>
/// <param name="quality">The OPC UA quality status code.</param>
/// <param name="statusCode">The full OPC UA status code, sub-code included (#519 — passed to the SDK verbatim).</param>
/// <param name="sourceTimestampUtc">The timestamp of the value in UTC.</param>
/// <param name="realm">Which of the two v3 namespaces the <paramref name="nodeId"/> lives in; the
/// namespace index is resolved from this rather than parsed out of the id string.</param>
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
{
ArgumentException.ThrowIfNullOrEmpty(nodeId);
@@ -513,23 +538,135 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
var ns = NamespaceIndexForRealm(realm);
var key = MapKey(realm, nodeId);
lock (Lock)
WriteValueLocked(nodeId, ns, key, value, statusCode, sourceTimestampUtc);
}
/// <summary>The most items <see cref="WriteValues"/> applies under a SINGLE <c>Lock</c> acquisition before
/// releasing and re-taking it.
/// <para>WHY a cap at all: the lock take is what batching amortises, and the amortisation curve is
/// brutally front-loaded — going from one take per value to one per 512 already removes &gt;99.8% of the
/// takes, so an unbounded hold buys essentially nothing more. What it DOES buy is a stall: the manager-wide
/// <c>Lock</c> is the same lock every SDK service thread (Read / Browse / subscription publish /
/// ConditionRefresh) contends for, and the first poll cycle force-raises EVERY tag (the initial-data push),
/// so batch #1 is a whole device × its UNS fan-out — thousands of <c>CreateVariable</c> calls, each mutating
/// the SDK address space, under one acquisition. 512 is therefore "effectively unbounded for the win,
/// bounded for the stall": chunking hands the SDK a scheduling point between chunks.</para></summary>
private const int MaxWritesPerLockAcquisition = 512;
/// <summary>Batched <see cref="WriteValue"/>: one <c>Lock</c> acquisition per chunk of
/// <see cref="MaxWritesPerLockAcquisition"/> items instead of one per value — the publish path's per-value
/// lock take was the steady-state throughput ceiling. Per-item semantics are identical to
/// <see cref="WriteValue"/> (create-on-first-write, #519 verbatim status codes, ClearChangeMasks per node).
/// <para><b>Items are isolated.</b> A bad item (empty NodeId, or a throw out of the SDK) does not abandon
/// the rest of the batch: it is recorded and the loop continues, and the aggregate surfaces afterwards as
/// a <see cref="ValueWriteBatchException"/>. Dropping the tail would be silent and durable — the poll
/// engine updates its last-value cache BEFORE raising, so a dropped item is not re-raised until that tag's
/// value changes again, leaving the node stale indefinitely.</para></summary>
/// <param name="values">The value writes to apply, in order.</param>
/// <exception cref="ValueWriteBatchException">When one or more items failed. Every other item HAS been
/// applied; the exception carries the first failing NodeId plus the failed/succeeded split so the caller
/// can log and meter honestly.</exception>
public void WriteValues(IReadOnlyList<ValueWrite> values)
{
ArgumentNullException.ThrowIfNull(values);
// Guard BEFORE the empty-batch early-return, so a pre-start call fails the same legible way every
// other mutator does regardless of what it happens to carry.
EnsureAddressSpaceCreated();
if (values.Count == 0) return;
// Pre-pass, OUTSIDE the lock: validate each item and resolve its namespace index + map key into a
// scratch array. Both jobs belong here — validating outside keeps a malformed item from ever reaching
// the SDK, and resolving outside moves the MapKey string allocation (a NodeId + ToString per item)
// off the critical section entirely.
var resolved = new ResolvedWrite[values.Count];
string? firstFailedNodeId = null;
Exception? firstFailure = null;
var failed = 0;
for (var i = 0; i < values.Count; i++)
{
// CreateVariable mutates the SDK address space (_root.AddChild + AddPredefinedNode),
// so it MUST run under Lock — the SDK's subscription/ConditionRefresh threads take it too.
if (!_variables.TryGetValue(key, out var variable))
var nodeId = values[i].NodeId;
if (string.IsNullOrEmpty(nodeId))
{
variable = CreateVariable(nodeId, ns);
_variables[key] = variable;
failed++;
firstFailedNodeId ??= nodeId ?? string.Empty;
firstFailure ??= new ArgumentException(
$"WriteValues item {i} has a null or empty NodeId.", nameof(values));
continue;
}
variable.Value = value;
// #519 — the caller's status code reaches the SDK verbatim, sub-code included. This used to
// re-expand a 3-state enum, so every Bad became a bare StatusCodes.Bad (0x80000000) and a
// driver's BadNoCommunication was indistinguishable from BadTypeMismatch at the client.
variable.StatusCode = new StatusCode(statusCode);
variable.Timestamp = sourceTimestampUtc;
variable.ClearChangeMasks(SystemContext, includeChildren: false);
resolved[i] = new ResolvedWrite(
nodeId, NamespaceIndexForRealm(values[i].Realm), MapKey(values[i].Realm, nodeId), IsValid: true);
}
var succeeded = 0;
for (var start = 0; start < values.Count; start += MaxWritesPerLockAcquisition)
{
var end = Math.Min(start + MaxWritesPerLockAcquisition, values.Count);
lock (Lock)
{
for (var i = start; i < end; i++)
{
if (!resolved[i].IsValid) continue;
var v = values[i];
try
{
WriteValueLocked(resolved[i].NodeId, resolved[i].NamespaceIndex, resolved[i].Key,
v.Value, v.StatusCode, v.SourceTimestampUtc);
succeeded++;
}
catch (Exception ex)
{
failed++;
firstFailedNodeId ??= v.NodeId;
firstFailure ??= ex;
}
}
}
}
if (failed > 0)
{
throw new ValueWriteBatchException(
firstFailedNodeId ?? string.Empty, failed, succeeded, firstFailure!);
}
}
/// <summary>One <see cref="WriteValues"/> item after the outside-the-lock pre-pass: its namespace index and
/// map key already resolved, or <paramref name="IsValid"/> false when the item failed validation.</summary>
/// <param name="NodeId">The item's bare <c>s=</c> identifier.</param>
/// <param name="NamespaceIndex">The namespace index resolved from the item's realm.</param>
/// <param name="Key">The realm-qualified map key.</param>
/// <param name="IsValid">False when the item failed the pre-pass and must be skipped.</param>
private readonly record struct ResolvedWrite(string NodeId, ushort NamespaceIndex, RealmKey Key, bool IsValid);
/// <summary>The body of a single value write. <b>Caller MUST hold <c>Lock</c></b> — this is factored out
/// solely so <see cref="WriteValues"/> can apply a whole batch under one acquisition; it is byte-identical
/// to what <see cref="WriteValue"/> has always done inside its own <c>lock</c>.</summary>
/// <param name="nodeId">The bare <c>s=</c> identifier of the variable.</param>
/// <param name="ns">The namespace index resolved from the write's realm.</param>
/// <param name="key">The realm-qualified map key for <paramref name="nodeId"/>.</param>
/// <param name="value">The new value to write.</param>
/// <param name="statusCode">The full OPC UA status code, sub-code included.</param>
/// <param name="sourceTimestampUtc">The timestamp of the value in UTC.</param>
private void WriteValueLocked(string nodeId, ushort ns, RealmKey key, object? value, uint statusCode, DateTime sourceTimestampUtc)
{
// CreateVariable mutates the SDK address space (_root.AddChild + AddPredefinedNode),
// so it MUST run under Lock — the SDK's subscription/ConditionRefresh threads take it too.
if (!_variables.TryGetValue(key, out var variable))
{
variable = CreateVariable(nodeId, ns);
_variables[key] = variable;
}
variable.Value = value;
// #519 — the caller's status code reaches the SDK verbatim, sub-code included. This used to
// re-expand a 3-state enum, so every Bad became a bare StatusCodes.Bad (0x80000000) and a
// driver's BadNoCommunication was indistinguishable from BadTypeMismatch at the client.
variable.StatusCode = new StatusCode(statusCode);
variable.Timestamp = sourceTimestampUtc;
// Stays INSIDE the lock, per node: this is how the SDK notifies monitored items of the change.
variable.ClearChangeMasks(SystemContext, includeChildren: false);
}
/// <summary>
@@ -1138,7 +1275,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// ReportEvent catch below for the same pattern). Utils.LogInfo is [Obsolete] in 1.5.378 in
// favour of an ITelemetryContext this manager doesn't wire — suppress the deprecation.
#pragma warning disable CS0618 // Type or member is obsolete
Utils.LogInfo("OtOpcUaNodeManager.WireAlarmNotifiers: condition {0} not materialised — skipping notifier wiring", alarmKey);
// Interpolate the key's PARTS, not the RealmKey struct — a struct dump would render a shape
// no log-grep pattern in this file matches.
Utils.LogInfo("OtOpcUaNodeManager.WireAlarmNotifiers: condition {0} (realm {1}) not materialised — skipping notifier wiring",
alarmKey.Id, alarmKey.Realm);
#pragma warning restore CS0618
return;
}
@@ -1155,7 +1295,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// supplied, regardless of whether each is currently materialised. Reconcile against it below.
var desiredKeys = notifierFolderNodeIds
.Select(id => MapKey(notifierFolderRealm, id))
.ToHashSet(StringComparer.Ordinal);
.ToHashSet();
// Reconcile (M3): unwire any previously-wired folder whose id is NO LONGER in the supplied set — a
// genuine de-reference (the equipment dropped its reference to the alarm's raw tag). Matched by the
@@ -1173,8 +1313,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
// Referencing-equipment folder not (yet) materialised — skip this one (logged, never thrown).
#pragma warning disable CS0618 // Type or member is obsolete
Utils.LogInfo("OtOpcUaNodeManager.WireAlarmNotifiers: notifier folder {0} for condition {1} not present — skipped",
folderNodeId, alarmKey);
Utils.LogInfo("OtOpcUaNodeManager.WireAlarmNotifiers: notifier folder {0} for condition {1} (realm {2}) not present — skipped",
folderNodeId, alarmKey.Id, alarmKey.Realm);
#pragma warning restore CS0618
continue;
}
@@ -1200,7 +1340,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <summary>Tear down (bidirectionally) every notifier wired for the condition at <paramref name="alarmKey"/>
/// and drop its tracking entry. MUST be called under <c>Lock</c>. Used on condition-removal + full rebuild
/// so no inverse-notifier entry leaks. A no-op when the condition has no wired notifiers.</summary>
private void UnwireAlarmNotifiers(string alarmKey)
private void UnwireAlarmNotifiers(RealmKey alarmKey)
{
if (!_alarmNotifierWiring.TryGetValue(alarmKey, out var wiring)) return;
foreach (var folder in wiring.Folders)
@@ -1817,8 +1957,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// A historian is wired: advertise event history on this notifier and register it as a source.
// The equipment-folder NodeId identifier IS the equipment id IS the ReadEventsAsync sourceName.
folder.EventNotifier = (byte)(folder.EventNotifier | EventNotifiers.HistoryRead);
// Key by the full (namespace-qualified) NodeId string so a Raw and a UNS folder that share the
// same bare id are distinct sources; the VALUE stays the bare id — that is the historian
// Key by the realm-qualified RealmKey built from the NodeId so a Raw and a UNS folder that share
// the same bare id are distinct sources; the VALUE stays the bare id — that is the historian
// ReadEventsAsync sourceName (== the equipment id).
var sourceName = folder.NodeId.Identifier?.ToString() ?? string.Empty;
_eventNotifierSources[MapKey(folder.NodeId)] = sourceName;
@@ -2239,7 +2379,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <see cref="AddReference"/> to link two already-materialised nodes regardless of their kind.</summary>
/// <param name="key">The namespace-qualified map key (see <see cref="MapKey(AddressSpaceRealm, string)"/>).</param>
/// <returns>The node state, or null when unknown.</returns>
private NodeState? ResolveNodeState(string key)
private NodeState? ResolveNodeState(RealmKey key)
{
if (_variables.TryGetValue(key, out var variable)) return variable;
if (_folders.TryGetValue(key, out var folder)) return folder;
@@ -2527,13 +2667,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// Unknown equipment id ⇒ map drift ⇒ false (caller rebuilds).
if (!_folders.ContainsKey(equipKey)) return false;
// Maps key on the full namespace-qualified NodeId string (e.g. "ns=3;s=<equipmentId>"). Every
// descendant in the SAME realm shares that key exactly or begins with "<equipKey>/" (sub-folders,
// variables, condition nodes); the "ns=N;s=" prefix makes the match realm-scoped for free, so a
// node in the OTHER namespace that happens to share a bare id is never in scope. Snapshot each
// map's in-scope keys first (ToList) so we don't mutate while enumerating.
var prefix = equipKey + "/";
bool InScope(string key) => key == equipKey || key.StartsWith(prefix, StringComparison.Ordinal);
// Maps key on the (realm, bare id) pair. Every descendant in the SAME realm shares that id exactly
// or begins with "<equipmentNodeId>/" (sub-folders, variables, condition nodes); the explicit realm
// compare is what makes the match realm-scoped — it replaces the old string key's leading "ns=N;s="
// — so a node in the OTHER namespace that happens to share a bare id is never in scope. Snapshot
// each map's in-scope keys first (ToList) so we don't mutate while enumerating.
var prefix = equipmentNodeId + "/";
bool InScope(RealmKey key) => key.Realm == realm
&& (key.Id == equipmentNodeId || key.Id.StartsWith(prefix, StringComparison.Ordinal));
// Value variables (+ their historized-tagname registrations).
foreach (var id in _variables.Keys.Where(InScope).ToList())
@@ -2575,8 +2716,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
// Notifier demotion BEFORE dropping the folders: sever the Server↔folder HasNotifier ref for
// every promoted folder in the subtree (an equipment folder, or a sub-folder that hosted an
// alarm), else the removal leaks an orphaned root-notifier reference on the Server object.
// _notifierFolders is keyed by the folder's NodeId; match InScope on its full (ns-qualified) string.
foreach (var kvp in _notifierFolders.Where(kv => InScope(kv.Key.ToString())).ToList())
// _notifierFolders is keyed by the folder's NodeId; match InScope on its realm-qualified map key.
foreach (var kvp in _notifierFolders.Where(kv => InScope(MapKey(kv.Key))).ToList())
{
RemoveRootNotifier(kvp.Value);
_notifierFolders.Remove(kvp.Key);
@@ -2612,7 +2753,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// <summary>Guard the address-space mutators against a too-early call. <c>_root</c>
/// is only assigned in <see cref="CreateAddressSpace"/>, which the SDK invokes during
/// <c>StandardServer</c> start; every public mutator (<see cref="WriteValue"/>,
/// <c>StandardServer</c> start; every public mutator (<see cref="WriteValue"/>, <see cref="WriteValues"/>,
/// <see cref="WriteAlarmCondition"/>, <see cref="EnsureFolder"/>, <see cref="EnsureVariable"/>,
/// <see cref="MaterialiseAlarmCondition"/>) and <see cref="ResolveParentFolder"/> assume it has run.
/// If one is invoked before the server has started (a sink wired or a publish replayed before
@@ -24,6 +24,13 @@ public sealed class SdkAddressSpaceSink : IOpcUaAddressSpaceSink, ISurgicalAddre
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _nodeManager.WriteValue(nodeId, value, statusCode, sourceTimestampUtc, realm);
/// <inheritdoc />
/// <remarks>Overrides the interface's default implementation on purpose: that default loops
/// <see cref="WriteValue"/>, which would re-take the node manager's SDK-wide <c>Lock</c> once per value.
/// Forwarding to the manager's own batched entry point takes it ONCE for the whole batch.</remarks>
public void WriteValues(IReadOnlyList<ValueWrite> values)
=> _nodeManager.WriteValues(values);
/// <inheritdoc />
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> _nodeManager.WriteAlarmCondition(alarmNodeId, state, sourceTimestampUtc, realm);
@@ -48,7 +48,11 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
/// Children (DriverInstance/VirtualTag/etc.) are fully wired: <c>DispatchDeployment</c> drives
/// <see cref="PushDesiredSubscriptions"/> which runs the spawn-plan via <see cref="IDriverFactory"/>,
/// maintains the <c>FullName→NodeId</c> live-value routing map, and forwards results to the
/// OPC UA publish actor via <c>ForwardToMux</c>.
/// OPC UA publish actor via <c>ForwardBatchToMux</c> — one whole driver production burst per message,
/// and the ONLY value ingress from driver children (<see cref="DriverInstanceActor"/> wraps even a
/// single data change in an <c>AttributeValueBatchPublished</c>). The per-value
/// <c>AttributeValuePublished</c> survives only as the message <see cref="FanCommsLossToDependencyMux"/>
/// fabricates straight to the dependency mux; the host itself no longer receives it.
/// </summary>
public sealed class DriverHostActor : ReceiveActor, IWithTimers
{
@@ -185,7 +189,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
/// <summary>
/// Driver live-value routing map: <c>(DriverInstanceId, RawPath) → materialised NodeId(s) (realm-tagged)</c>.
/// Rebuilt every apply by <see cref="PushDesiredSubscriptions"/> from the composition's <c>RawTags</c>
/// ∪ <c>UnsReferenceVariables</c>, and resolved in <see cref="ForwardToMux"/> so a driver value
/// ∪ <c>UnsReferenceVariables</c>, and resolved in <see cref="ForwardBatchToMux"/> so a driver value
/// published by wire-ref RawPath fans to its raw node AND every referencing UNS node with identical
/// value/quality/timestamp — the single-source-fan-out (no independent buffer, so no drift). A set
/// because one driver ref can back the raw node plus N UNS references; the per-apply rebuild dedups.
@@ -923,7 +927,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
{
Receive<DispatchDeployment>(HandleDispatchFromSteady);
Receive<GetDiagnostics>(HandleGetDiagnostics);
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
Receive<DriverInstanceActor.AttributeValueBatchPublished>(ForwardBatchToMux);
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
@@ -957,7 +961,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
// then Become(Applying)); completing the apply transitions back to Steady.
Receive<FetchedForApply>(HandleFetchedForApply);
Receive<GetDiagnostics>(HandleGetDiagnostics);
Receive<DriverInstanceActor.AttributeValuePublished>(ForwardToMux);
Receive<DriverInstanceActor.AttributeValueBatchPublished>(ForwardBatchToMux);
Receive<DriverInstanceActor.AttributeAlarmPublished>(ForwardNativeAlarm);
Receive<DriverInstanceActor.ConnectivityChanged>(OnDriverConnectivityChanged);
Receive<DriverInstanceActor.DeltaApplied>(HandleDeltaApplied);
@@ -973,42 +977,67 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers
Receive<SubscribeAck>(_ => { /* PubSub ack */ });
}
private void ForwardToMux(DriverInstanceActor.AttributeValuePublished msg)
/// <summary>
/// Routes one batched driver production burst — the ONLY value ingress from a driver child, since
/// <see cref="DriverInstanceActor"/> wraps even a single data change in an
/// <see cref="DriverInstanceActor.AttributeValueBatchPublished"/>.
/// <para>
/// Each item's <c>FullReference</c> (the tag's RawPath) resolves through
/// <see cref="_nodeIdByDriverRef"/> — rebuilt every apply from the composition's <c>RawTags</c>
/// ∪ <c>UnsReferenceVariables</c> — to the v3 Batch 4 <b>single-source fan-out</b>: the RAW
/// NodeId (<see cref="AddressSpaceRealm.Raw"/>) AND every referencing UNS NodeId
/// (<see cref="AddressSpaceRealm.Uns"/>), each carrying IDENTICAL value/status/timestamp, so
/// there is no independent buffer to drift. An unmapped ref is Debug-logged and dropped without
/// disturbing the rest of the burst.
/// </para>
/// <para>
/// The whole burst costs ONE hop to the dependency mux (which filters per item itself, feeding
/// VirtualTag inputs keyed by RawPath) and ONE
/// <see cref="ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AttributeValueBatchUpdate"/> to
/// the publish actor — never one message per value. Both sinks are optional: without a mux,
/// VirtualTagActor evaluation can't fire (the dev/Mac path, no virtual tags registered);
/// production binds both via the RuntimeActors extension.
/// </para>
/// </summary>
private void ForwardBatchToMux(DriverInstanceActor.AttributeValueBatchPublished msg)
{
// Pass driver-published values to the dependency mux when one is wired (VirtualTag inputs,
// keyed by FullReference). Without a mux, VirtualTagActor evaluation can't fire — that's the
// dev/Mac path (no virtual tags registered); production binds the mux via the RuntimeActors
// extension. KEEP this unchanged — VirtualTag inputs are still keyed by FullReference.
// One envelope per burst; the mux filters per item (it registered a batch receive in Task 9).
_dependencyMux?.Tell(msg);
if (_opcUaPublishActor is null) return;
// Route the value to the OPC UA sink at the variable's ACTUAL NodeId. Equipment-tag variables
// are materialised with folder-scoped NodeIds (EquipmentId/FolderPath/Name), while the driver
// publishes keyed by its wire-ref FullName (FullReference). The _nodeIdByDriverRef map — built
// each apply from the composition's EquipmentTags — resolves (DriverInstanceId, FullName) to
// the folder-scoped NodeId(s) the materialiser placed the variable(s) at, so the value lands
// instead of leaving the variable at BadWaitingForInitialData. One driver ref can back several
// equipment variables (identical machines sharing a register), hence the fan-out.
if (_nodeIdByDriverRef.TryGetValue((msg.DriverInstanceId, msg.FullReference), out var nodeIds))
// Indexed for-loop, not foreach: IReadOnlyList<AttributeValueItem> enumerates through an interface
// enumerator that would allocate once per burst on the hottest routing seam in the server.
var count = msg.Items.Count;
List<ValueWrite>? writes = null;
for (var idx = 0; idx < count; idx++)
{
// v3 Batch 4 single-source fan-out: one driver publish for a RawPath lands on the raw NodeId AND
// every referencing UNS NodeId, each with its realm, carrying IDENTICAL value/status/timestamp.
foreach (var n in nodeIds)
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AttributeValueUpdate(
n.NodeId, msg.Value, msg.StatusCode, msg.TimestampUtc, n.Realm));
}
else
{
_log.Debug("DriverHost {Node}: no bound NodeId for ({Driver},{Ref}) — value dropped",
_localNode, msg.DriverInstanceId, msg.FullReference);
var item = msg.Items[idx];
if (_nodeIdByDriverRef.TryGetValue((msg.DriverInstanceId, item.FullReference), out var nodeIds))
{
// Sized for the dominant case — every item resolving to its raw node alone, no UNS
// reference — where this is exact and the list never grows. A burst that does fan out
// pays at most a doubling or two, which beats over-allocating on every burst that doesn't.
writes ??= new List<ValueWrite>(count);
foreach (var n in nodeIds)
writes.Add(new ValueWrite(n.NodeId, item.Value, item.StatusCode, item.TimestampUtc, n.Realm));
}
else if (_log.IsDebugEnabled)
{
_log.Debug("DriverHost {Node}: no bound NodeId for ({Driver},{Ref}) — value dropped",
_localNode, msg.DriverInstanceId, item.FullReference);
}
}
// Nothing resolved ⇒ no message at all, rather than an empty batch for the sink to unpack.
if (writes is not null)
_opcUaPublishActor.Tell(new ZB.MOM.WW.OtOpcUa.Runtime.OpcUa.OpcUaPublishActor.AttributeValueBatchUpdate(writes));
}
/// <summary>
/// Routes a native alarm transition (published by a driver child as
/// <see cref="DriverInstanceActor.AttributeAlarmPublished"/>) to its materialised Part 9 condition
/// node(s). The alarm path analogue of <see cref="ForwardToMux"/>: the transition's <c>ConditionId</c>
/// node(s). The alarm path analogue of <see cref="ForwardBatchToMux"/>: the transition's <c>ConditionId</c>
/// (the dotted alarm full-reference, which equals the authored equipment-tag FullName — NOT the bare
/// <c>SourceNodeId</c> owning object) is resolved by the <see cref="_alarmNodeIdByDriverRef"/> map —
/// built each apply from the alarm-bearing EquipmentTags — to the folder-scoped condition NodeId(s)
@@ -101,13 +101,28 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// state — a no-op when already Reconnecting or Connecting.
/// </summary>
public sealed record ForceReconnect;
/// <summary>Published to the actor's parent whenever the subscribed IDriver fires
/// <see cref="ISubscribable.OnDataChange"/>. The parent forwards to OpcUaPublishActor.
/// <summary>One driver-published value. NOT sent to the parent by the subscription path any more — the
/// actor batches every data change into an <see cref="AttributeValueBatchPublished"/>. Still live as the
/// per-value message the parent fans onward (the dependency mux consumes it, and
/// <c>DriverHostActor.FanCommsLossToDependencyMux</c> fabricates it).
/// <para><paramref name="StatusCode"/> is the driver's full OPC UA status code including its sub-code
/// (#519) — not a severity bucket. Classify it with <see cref="OpcUaStatus.IsGood"/> and friends rather
/// than comparing it to <see cref="OpcUaStatus.Good"/>.</para></summary>
public sealed record AttributeValuePublished(string DriverInstanceId, string FullReference, object? Value, uint StatusCode, DateTime TimestampUtc);
/// <summary>One value within an <see cref="AttributeValueBatchPublished"/>.
/// <para>A struct deliberately: an <c>AttributeValueItem[]</c> then holds its items inline, so a burst of
/// N values costs one array allocation instead of one array plus N boxes. Indexed access through
/// <see cref="IReadOnlyList{T}"/> does not box.</para></summary>
public readonly record struct AttributeValueItem(string FullReference, object? Value, uint StatusCode, DateTime TimestampUtc);
/// <summary>Batched form of <see cref="AttributeValuePublished"/> — every changed attribute of ONE
/// driver production burst in one message. The parent fans the whole batch to the mux and the OPC UA
/// publish path in one hop each. Status codes ride verbatim (#519).</summary>
public sealed record AttributeValueBatchPublished(string DriverInstanceId, IReadOnlyList<AttributeValueItem> Items);
private sealed record DataChangeForward(string FullReference, DataValueSnapshot Snapshot);
/// <summary>Self-sent when a batch-capable driver (<see cref="IBatchSubscribable"/>) raises
/// <see cref="IBatchSubscribable.OnDataChangeBatch"/> — marshals the whole burst off the driver's
/// thread onto the actor thread in one hop. Parallels <see cref="DataChangeForward"/>.</summary>
private sealed record DataChangeBatchForward(IReadOnlyList<DataChangeItem> Items);
/// <summary>Published to the parent whenever the subscribed driver (an <see cref="IAlarmSource"/>) fires
/// <see cref="IAlarmSource.OnAlarmEvent"/>. The parent (<see cref="DriverHostActor"/>) projects + routes it
/// to the materialised Part 9 condition. Parallels <see cref="AttributeValuePublished"/>.</summary>
@@ -205,6 +220,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// do not need to re-send subscription requests after a reconnect.</summary>
private ISubscriptionHandle? _subscriptionHandle;
private EventHandler<DataChangeEventArgs>? _dataChangeHandler;
/// <summary>The batch data-change handler, attached INSTEAD of <see cref="_dataChangeHandler"/> when the
/// driver is an <see cref="IBatchSubscribable"/>. Exactly one of the two is ever non-null.</summary>
private EventHandler<DataChangeBatchEventArgs>? _batchChangeHandler;
private EventHandler<AlarmEventArgs>? _alarmEventHandler;
private EventHandler<RediscoveryEventArgs>? _rediscoveryHandler;
private EventHandler<HostStatusChangedEventArgs>? _hostStatusHandler;
@@ -478,8 +496,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
});
ReceiveAsync<SubscribeAlarms>(HandleSubscribeAlarmsAsync);
Receive<DataChangeForward>(OnDataChangeForward);
Receive<DataChangeBatchForward>(OnDataChangeBatchForward);
// Native alarm transition marshaled onto the actor thread from the driver's OnAlarmEvent;
// project it to the parent the same way DataChangeForward projects AttributeValuePublished.
// project it to the parent the same way DataChangeForward projects AttributeValueBatchPublished.
Receive<NativeAlarmRaised>(m => Context.Parent.Tell(new AttributeAlarmPublished(_driverInstanceId, m.Args)));
// ResubscribeDesired self-Tells Subscribe; HandleSubscribeAsync replies SubscriptionEstablished to the
// sender, which on the self-resubscribe path is Self. Swallow it (trace only) so it doesn't dead-letter.
@@ -877,8 +896,27 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
try
{
_dataChangeHandler = (_, args) => self.Tell(new DataChangeForward(args.FullReference, args.Snapshot));
subscribable.OnDataChange += _dataChangeHandler;
// EXACTLY ONE of the two data-change events is subscribed. An IBatchSubscribable driver raises
// OnDataChangeBatch once per production burst AND the per-tag OnDataChange for every item in
// that same burst (the per-tag event is kept for legacy consumers — Galaxy cold reads, driver
// CLIs). Attaching both here would forward every value twice. The batch event is preferred: one
// Tell, one parent hop, one mux pass per burst instead of per tag.
if (subscribable is IBatchSubscribable batchable)
{
_batchChangeHandler = (_, args) =>
{
// A null/empty raise from a buggy driver must never enter the mailbox: an unguarded null
// would throw in the handler, restart the actor, and tear down the driver transport.
if (args?.Items is not { Count: > 0 }) return;
self.Tell(new DataChangeBatchForward(args.Items));
};
batchable.OnDataChangeBatch += _batchChangeHandler;
}
else
{
_dataChangeHandler = (_, args) => self.Tell(new DataChangeForward(args.FullReference, args.Snapshot));
subscribable.OnDataChange += _dataChangeHandler;
}
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
// A bulk subscribe spans potentially many hosts, so key the pipeline on the driver
@@ -928,9 +966,12 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
}
}
/// <summary>Tear down the data-change + native-alarm event handlers + null the handle. Called from the
/// Unsubscribe path, on PostStop, and on Connected → Reconnecting transitions so a stale handler doesn't
/// push data-change / alarm events to an actor that has lost its driver connection.</summary>
/// <summary>Tear down the data-change (per-tag AND batch) + native-alarm event handlers + null the handle.
/// Called from the Unsubscribe path, on PostStop, and on Connected → Reconnecting transitions so a stale
/// handler doesn't push data-change / alarm events to an actor that has lost its driver connection.
/// <para>Both data-change fields are cleared unconditionally even though only one is ever attached — the
/// IDriver object outlives the actor when the host respawns a child around it, so a handler left on the
/// driver's event would keep Telling a dead actor.</para></summary>
private void DetachSubscription()
{
if (_driver is ISubscribable subscribable && _dataChangeHandler is not null)
@@ -938,6 +979,11 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
subscribable.OnDataChange -= _dataChangeHandler;
}
_dataChangeHandler = null;
if (_driver is IBatchSubscribable batchable && _batchChangeHandler is not null)
{
batchable.OnDataChangeBatch -= _batchChangeHandler;
}
_batchChangeHandler = null;
_subscriptionHandle = null;
DetachAlarmSource();
}
@@ -1130,7 +1176,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
}
}
/// <summary>Forward a driver's data change to the parent for OPC UA publication.
/// <summary>Forward a per-tag-only driver's data change to the parent for OPC UA publication, as a
/// single-item batch — so the parent sees ONE message shape whether or not the driver batches.
/// <para>The driver's status code rides through UNCHANGED (#519). This used to project it onto a
/// 3-state Good/Uncertain/Bad enum here, which the node manager then re-expanded to a bare
/// <c>0x80000000</c> — so no driver's sub-code ever reached a client, and the status constants each
@@ -1138,8 +1185,25 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
private void OnDataChangeForward(DataChangeForward msg)
{
var ts = msg.Snapshot.SourceTimestampUtc ?? msg.Snapshot.ServerTimestampUtc;
Context.Parent.Tell(new AttributeValuePublished(
_driverInstanceId, msg.FullReference, msg.Snapshot.Value, msg.Snapshot.StatusCode, ts));
Context.Parent.Tell(new AttributeValueBatchPublished(
_driverInstanceId,
new[] { new AttributeValueItem(msg.FullReference, msg.Snapshot.Value, msg.Snapshot.StatusCode, ts) }));
}
/// <summary>Forward one batch-capable driver production burst to the parent as a single message.
/// <para>Every item's status code and timestamp ride through exactly as
/// <see cref="OnDataChangeForward"/> carries them for a per-tag driver (#519; timestamps come from the
/// driver's snapshot, never from this actor's clock).</para></summary>
private void OnDataChangeBatchForward(DataChangeBatchForward msg)
{
var items = new AttributeValueItem[msg.Items.Count];
for (var i = 0; i < msg.Items.Count; i++)
{
var item = msg.Items[i];
var ts = item.Snapshot.SourceTimestampUtc ?? item.Snapshot.ServerTimestampUtc;
items[i] = new AttributeValueItem(item.FullReference, item.Snapshot.Value, item.Snapshot.StatusCode, ts);
}
Context.Parent.Tell(new AttributeValueBatchPublished(_driverInstanceId, items));
}
private static bool IsGoodStatus(uint statusCode) => OpcUaStatus.IsGood(statusCode);
@@ -51,6 +51,11 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
/// hands it to the SDK verbatim.</para></summary>
public sealed record AttributeValueUpdate(string NodeId, object? Value, uint StatusCode, DateTime TimestampUtc, AddressSpaceRealm Realm);
/// <summary>Batched form of <see cref="AttributeValueUpdate"/>: every fan-out write of one driver
/// production burst in one message — the sink takes its lock once for the whole batch. Items carry
/// their realm each (<see cref="ValueWrite"/>); status codes ride verbatim (#519).</summary>
public sealed record AttributeValueBatchUpdate(IReadOnlyList<ValueWrite> Items);
/// <summary>Carries the full Part 9 condition state for a scripted alarm to the sink. The
/// <paramref name="State"/> snapshot is the Commons projection the Runtime host maps from the engine's
/// Core <c>AlarmConditionState</c> + severity/message — the actor stays decoupled from
@@ -286,6 +291,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
_healthTickInterval = healthTickInterval ?? TimeSpan.FromSeconds(5);
Receive<AttributeValueUpdate>(HandleAttributeUpdate);
Receive<AttributeValueBatchUpdate>(HandleAttributeBatchUpdate);
Receive<AlarmStateUpdate>(HandleAlarmUpdate);
Receive<AlarmQualityUpdate>(HandleAlarmQualityUpdate);
Receive<RebuildAddressSpace>(HandleRebuild);
@@ -330,6 +336,38 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
}
}
/// <summary>Mirrors <see cref="HandleAttributeUpdate"/> for a whole burst: ONE
/// <see cref="IOpcUaAddressSpaceSink.WriteValues"/> call, but the write tally and the
/// <c>otopcua.opcua.sink.write</c> meter still advance PER ITEM — batching is a transport change, so
/// dashboards must not drop by the batch factor.
/// <para>A <see cref="ValueWriteBatchException"/> is a PARTIAL failure: the sink applied every item it
/// could and is telling us which one it couldn't. So it is caught ahead of the general catch to (a) name
/// the offending node in the log — a bare "the batch threw" cannot be acted on — and (b) still count the
/// items that DID land, keeping the per-item counting invariant honest instead of silently under-reporting
/// a whole batch because one item was bad.</para></summary>
private void HandleAttributeBatchUpdate(AttributeValueBatchUpdate msg)
{
try
{
_sink.WriteValues(msg.Items);
Interlocked.Add(ref _writes, msg.Items.Count);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(msg.Items.Count, new KeyValuePair<string, object?>("kind", "value"));
}
catch (ValueWriteBatchException ex)
{
_log.Warning(ex,
"OpcUaPublish: sink.WriteValues partially failed for a {Count}-item batch — {Failed} failed " +
"(first: {Node}), {Succeeded} applied",
msg.Items.Count, ex.FailedCount, ex.FirstFailedNodeId, ex.SucceededCount);
Interlocked.Add(ref _writes, ex.SucceededCount);
OtOpcUaTelemetry.OpcUaSinkWrite.Add(ex.SucceededCount, new KeyValuePair<string, object?>("kind", "value"));
}
catch (Exception ex)
{
_log.Warning(ex, "OpcUaPublish: sink.WriteValues threw for a {Count}-item batch", msg.Items.Count);
}
}
private void HandleAlarmUpdate(AlarmStateUpdate msg)
{
try
@@ -469,7 +507,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
failedNodes += _applier.MaterialiseScriptedAlarms(composition);
// Equipment-namespace tags get their own pass: ensures each signal's Variable (and any
// FolderPath sub-folder) exists under its already-materialised equipment folder so
// clients can browse them. Live values are pushed by DriverHostActor.ForwardToMux after
// clients can browse them. Live values are pushed by DriverHostActor.ForwardBatchToMux after
// each subscription cycle; variables show BadWaitingForInitialData only until the first
// publish interval fires.
failedNodes += _applier.MaterialiseEquipmentTags(composition);
@@ -302,12 +302,18 @@ public static class ServiceCollectionExtensions
// bootstrap their own HOCON (e.g. ServiceCollectionExtensionsTests) wouldn't pick it up
// — OpcUaPublishActor.Props pins itself to opcua-synchronized-dispatcher and Akka throws
// ConfigurationException if it doesn't exist. Prepend a fallback so the runtime extension
// is self-contained.
// is self-contained. Keep this block identical to the akka.conf one.
//
// The OPC UA SDK's single-threaded session/subscription requirement is guaranteed by the
// PinnedDispatcher alone (a dedicated thread per actor instance), not by throughput.
// throughput only sets how many queued messages drain per scheduling run, so 100 removes a
// mailbox re-schedule round-trip per message on the hottest actor in the process with zero
// change to concurrency.
builder.AddHocon(@"
opcua-synchronized-dispatcher {
type = ""PinnedDispatcher""
executor = ""thread-pool-executor""
throughput = 1
throughput = 100
}
", HoconAddMode.Prepend);
@@ -12,9 +12,15 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;
/// forwards a <see cref="VirtualTagActor.DependencyValueChanged"/> to each interested
/// subscriber.
///
/// DriverHostActor forwards every <c>AttributeValuePublished</c> it receives from its
/// DriverInstanceActor children to this mux (one mux per driver-role node). The mux is
/// deliberately not a DPS subscriber — virtual-tag evaluation is local to each node and
/// DriverHostActor forwards driver-child value publishes to this mux (one mux per driver-role
/// node). The ONLY driver ingress is the batched
/// <see cref="DriverInstanceActor.AttributeValueBatchPublished"/> — one envelope per driver
/// production burst, which the mux filters per item. The per-value
/// <see cref="DriverInstanceActor.AttributeValuePublished"/> receive stays for the comms-loss fan
/// ALONE: <c>DriverHostActor.FanCommsLossToDependencyMux</c> fabricates that message and Tells this
/// mux directly, never through the host's own receive. Both
/// produce the same <see cref="VirtualTagActor.DependencyValueChanged"/> per interested subscriber.
/// The mux is deliberately not a DPS subscriber — virtual-tag evaluation is local to each node and
/// would over-emit if it spanned the cluster.
/// </summary>
public sealed class DependencyMuxActor : ReceiveActor
@@ -43,6 +49,7 @@ public sealed class DependencyMuxActor : ReceiveActor
Receive<RegisterInterest>(OnRegister);
Receive<UnregisterInterest>(msg => RemoveSubscriber(msg.Subscriber));
Receive<DriverInstanceActor.AttributeValuePublished>(OnAttributeValuePublished);
Receive<DriverInstanceActor.AttributeValueBatchPublished>(OnAttributeValueBatchPublished);
Receive<Terminated>(msg => RemoveSubscriber(msg.ActorRef));
}
@@ -107,4 +114,27 @@ public sealed class DependencyMuxActor : ReceiveActor
}
}
/// <summary>Filters one batched driver production burst per item — the batched analogue of
/// <see cref="OnAttributeValuePublished"/>. Items nobody registered for are dropped (the common case:
/// only a fraction of an address space feeds virtual-tag expressions), so a burst of N values costs one
/// message plus one DependencyValueChanged per genuinely interested (item, subscriber) pair.
/// <para>The registered set is NOT just virtual-tag inputs: <c>ContinuousHistorizationRecorder</c>
/// registers interest in every historized ref, so on a historizing node <see cref="_byRef"/> can be
/// large and a sizeable fraction of each burst resolves.</para></summary>
private void OnAttributeValueBatchPublished(DriverInstanceActor.AttributeValueBatchPublished msg)
{
// Nobody has registered anything — skip the whole burst rather than probing an empty map per item.
if (_byRef.Count == 0) return;
// Indexed for-loop, not foreach: this is the hot value ingress and an IReadOnlyList<T> enumerator
// would allocate once per burst.
var count = msg.Items.Count;
for (var i = 0; i < count; i++)
{
var item = msg.Items[i];
if (!_byRef.TryGetValue(item.FullReference, out var subscribers) || subscribers.Count == 0) continue;
var dep = new VirtualTagActor.DependencyValueChanged(item.FullReference, item.Value, item.TimestampUtc, item.StatusCode);
foreach (var sub in subscribers) sub.Tell(dep);
}
}
}
@@ -0,0 +1,112 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;
/// <summary>
/// The batched <see cref="IOpcUaAddressSpaceSink.WriteValues"/> seam. It exists so an SDK-backed sink
/// can take its internal lock ONCE per batch instead of once per value, and it ships as a <b>default
/// interface method</b> so every existing sink and test fake keeps compiling unchanged — this suite
/// pins both halves of that bargain: the default loops <see cref="IOpcUaAddressSpaceSink.WriteValue"/>
/// verbatim (#519: the status code rides through untouched), and
/// <see cref="DeferredAddressSpaceSink"/> forwards the <i>batch</i> to an overriding inner rather than
/// silently degrading it to per-item calls on every driver-role host.
/// </summary>
public class AddressSpaceSinkBatchTests
{
[Fact]
public void Default_WriteValues_loops_WriteValue()
{
// A sink that implements ONLY the pre-existing interface members — exactly the shape of every
// sink and fake that predates the batch seam. The default implementation must fan the batch out
// to WriteValue, in order, with every field carried through unchanged.
var fake = new PerItemOnlySink();
var t0 = new DateTime(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc);
((IOpcUaAddressSpaceSink)fake).WriteValues(new[]
{
new ValueWrite("Plant/Modbus/dev1/Speed", 42, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw),
new ValueWrite("Area/Line/Eq/Speed", 43.5, OpcUaStatus.BadNoCommunication, t0.AddSeconds(1), AddressSpaceRealm.Uns),
new ValueWrite("Plant/Modbus/dev1/Temp", null, OpcUaStatus.Bad, t0.AddSeconds(2), AddressSpaceRealm.Raw),
});
fake.Calls.Count.ShouldBe(3);
fake.Calls[0].ShouldBe(("Plant/Modbus/dev1/Speed", (object?)42, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw));
fake.Calls[1].ShouldBe(("Area/Line/Eq/Speed", (object?)43.5, OpcUaStatus.BadNoCommunication, t0.AddSeconds(1), AddressSpaceRealm.Uns));
fake.Calls[2].ShouldBe(("Plant/Modbus/dev1/Temp", (object?)null, OpcUaStatus.Bad, t0.AddSeconds(2), AddressSpaceRealm.Raw));
}
[Fact]
public void Deferred_sink_forwards_WriteValues_to_inner_once()
{
// The whole point of the seam is the ONE-lock-per-batch win, and actors inject the deferred
// wrapper — not the inner SdkAddressSpaceSink. A missing forward here would fall back to the
// interface default and re-explode the batch into per-item WriteValue calls on every
// driver-role host: the batching would ship inert, exactly the F10b / PR#423 trap class.
var inner = new BatchingSpySink();
var sink = new DeferredAddressSpaceSink();
sink.SetSink(inner);
var t0 = new DateTime(2026, 8, 15, 11, 0, 0, DateTimeKind.Utc);
var batch = new[]
{
new ValueWrite("a", 1, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw),
new ValueWrite("b", 2, OpcUaStatus.Good, t0, AddressSpaceRealm.Uns),
new ValueWrite("c", 3, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw),
};
sink.WriteValues(batch);
inner.Batches.Count.ShouldBe(1, "the batch must reach the inner sink as ONE call, not be re-looped");
inner.Batches[0].Count.ShouldBe(3);
inner.Batches[0].Select(v => v.NodeId).ShouldBe(new[] { "a", "b", "c" });
inner.PerItemCalls.ShouldBe(0, "the inner sink's per-item WriteValue must not be reached at all");
}
// ---- test doubles ----
/// <summary>Implements only the members that existed before the batch seam, so the interface's
/// default <c>WriteValues</c> is what runs.</summary>
private sealed class PerItemOnlySink : IOpcUaAddressSpaceSink
{
public List<(string NodeId, object? Value, uint StatusCode, DateTime SourceTimestampUtc, AddressSpaceRealm Realm)> Calls { get; } = new();
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> Calls.Add((nodeId, value, statusCode, sourceTimestampUtc, realm));
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
/// <summary>Overrides <c>WriteValues</c> the way a real SDK-backed sink does — records the batch and
/// does NOT loop — so a wrapper that dropped the forward is distinguishable from one that kept it.</summary>
private sealed class BatchingSpySink : IOpcUaAddressSpaceSink
{
public List<IReadOnlyList<ValueWrite>> Batches { get; } = new();
public int PerItemCalls { get; private set; }
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm)
=> PerItemCalls++;
public void WriteValues(IReadOnlyList<ValueWrite> values) => Batches.Add(values);
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
}
@@ -86,12 +86,28 @@ public class DeferredSinkForwardingReflectionTests
{
if (method.Name == nameof(IOpcUaAddressSpaceSink.RebuildAddressSpace)) continue;
method.GetParameters().Any(p => p.ParameterType == typeof(AddressSpaceRealm)).ShouldBeTrue(
method.GetParameters().Any(p => CarriesRealm(p.ParameterType)).ShouldBeTrue(
$"{method.DeclaringType!.Name}.{method.Name} names a node but has no AddressSpaceRealm parameter — " +
"a bare node id is ambiguous across the Raw and UNS namespaces (B4-WP2). Add the realm discriminator.");
}
}
/// <summary>Whether a parameter type supplies the B4-WP2 realm discriminator — either directly, or
/// carried per element by a batch parameter (<c>WriteValues(IReadOnlyList&lt;ValueWrite&gt;)</c> is
/// realm-qualified per item, since one batch legitimately spans both namespaces). Looking THROUGH the
/// element type rather than exempting the method by name keeps the guard mechanical: a future batch
/// item struct that forgets its realm still trips it.</summary>
private static bool CarriesRealm(Type t)
{
if (t == typeof(AddressSpaceRealm)) return true;
if (t.IsGenericType && t.GetGenericArguments().Any(CarriesRealm)) return true;
return t.Namespace == typeof(IOpcUaAddressSpaceSink).Namespace
&& (t.GetProperties().Any(p => p.PropertyType == typeof(AddressSpaceRealm))
|| t.GetFields().Any(f => f.FieldType == typeof(AddressSpaceRealm)));
}
[Fact]
public void Every_IServiceLevelPublisher_method_reaches_the_inner_publisher()
{
@@ -0,0 +1,27 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
/// <summary>Contract tests for the batched data-change payload types.</summary>
[Trait("Category", "Unit")]
public sealed class DataChangeBatchContractTests
{
private sealed record FakeHandle(string DiagnosticId) : ISubscriptionHandle;
/// <summary>Verifies the batch payload carries the subscription handle and its per-tag items.</summary>
[Fact]
public void Batch_args_carry_handle_and_items()
{
var handle = new FakeHandle("h1");
var snap = new DataValueSnapshot(42, 0u, DateTime.UtcNow, DateTime.UtcNow);
var args = new DataChangeBatchEventArgs(handle, new[] { new DataChangeItem("plc/tag1", snap) });
args.SubscriptionHandle.ShouldBe(handle);
args.Items.Count.ShouldBe(1);
args.Items[0].FullReference.ShouldBe("plc/tag1");
args.Items[0].Snapshot.ShouldBe(snap);
}
}
@@ -0,0 +1,335 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests;
/// <summary>
/// Guards for <c>PollGroupEngine</c>'s optional per-cycle batch callback: one raise per poll
/// cycle carrying exactly the items that changed in that cycle, raised after the per-tag
/// callbacks of the same cycle and only when non-empty. Drivers forward it to
/// <see cref="IBatchSubscribable.OnDataChangeBatch"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class PollGroupEngineBatchTests
{
private static readonly TimeSpan Interval = TimeSpan.FromMilliseconds(30);
private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(5);
/// <summary>
/// A reader over a mutable value map. Snapshots are returned in the requested reference
/// order (the engine's reader contract); <see cref="PollCount"/> lets a test await poll
/// cycles instead of sleeping a fixed wall-clock span.
/// </summary>
private sealed class FakeReader
{
private readonly Lock _gate = new();
private readonly Dictionary<string, object?> _values = new(StringComparer.Ordinal);
private readonly Dictionary<string, uint> _statusCodes = new(StringComparer.Ordinal);
private int _pollCount;
public FakeReader(params (string Reference, object? Value)[] seed)
{
foreach (var (reference, value) in seed) _values[reference] = value;
}
/// <summary>Gets the number of completed reader invocations.</summary>
public int PollCount => Volatile.Read(ref _pollCount);
/// <summary>
/// Overwrites the values a subsequent poll will observe. Applied under the same lock the
/// reader holds, so a multi-reference change is observed by ONE poll cycle rather than
/// being torn across two.
/// </summary>
public void Set(params (string Reference, object? Value)[] changes)
{
lock (_gate)
{
foreach (var (reference, value) in changes) _values[reference] = value;
}
}
/// <summary>
/// Overwrites the status code a subsequent poll will report for a reference, leaving its
/// value untouched — the shape a device fault takes on the wire (#519).
/// </summary>
public void SetStatus(string reference, uint statusCode)
{
lock (_gate) _statusCodes[reference] = statusCode;
}
/// <summary>The reader delegate handed to the engine.</summary>
public Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(IReadOnlyList<string> references, CancellationToken ct)
{
var now = DateTime.UtcNow;
var snapshots = new List<DataValueSnapshot>(references.Count);
lock (_gate)
{
foreach (var reference in references)
snapshots.Add(new DataValueSnapshot(
_values.GetValueOrDefault(reference),
_statusCodes.GetValueOrDefault(reference),
now,
now));
}
Interlocked.Increment(ref _pollCount);
return Task.FromResult<IReadOnlyList<DataValueSnapshot>>(snapshots);
}
}
private static async Task WaitForAsync(Func<bool> condition, string because)
{
var deadline = DateTime.UtcNow + WaitTimeout;
while (DateTime.UtcNow < deadline)
{
if (condition()) return;
await Task.Delay(10, TestContext.Current.CancellationToken);
}
throw new TimeoutException($"Timed out after {WaitTimeout.TotalSeconds}s waiting for: {because}");
}
/// <summary>
/// The initial-data cycle batches every subscribed tag; a later cycle in which exactly one
/// value moved batches exactly that one item — and the per-tag callback fired for the same
/// items, so both callbacks see identical content.
/// </summary>
[Fact]
public async Task Batch_fires_once_per_cycle_with_only_changed_items()
{
var reader = new FakeReader(("a", 1), ("b", 2), ("c", 3));
var perTag = new ConcurrentQueue<(string Reference, DataValueSnapshot Snapshot)>();
var batches = new ConcurrentQueue<IReadOnlyList<DataChangeItem>>();
await using var engine = new PollGroupEngine(
reader.ReadAsync,
(_, reference, snapshot) => perTag.Enqueue((reference, snapshot)),
minInterval: Interval,
onBatch: (_, items) => batches.Enqueue(items));
engine.Subscribe(["a", "b", "c"], Interval);
await WaitForAsync(() => batches.Count >= 1, "the initial-data batch");
batches.TryDequeue(out var initial).ShouldBeTrue();
initial!.Count.ShouldBe(3);
initial.Select(i => i.FullReference).ShouldBe(["a", "b", "c"]);
perTag.Count.ShouldBe(3);
perTag.Clear();
batches.Clear();
reader.Set(("b", 99));
await WaitForAsync(() => batches.Count >= 1, "the batch for the single changed tag");
batches.TryDequeue(out var changed).ShouldBeTrue();
changed!.Count.ShouldBe(1);
changed[0].FullReference.ShouldBe("b");
changed[0].Snapshot.Value.ShouldBe(99);
// The per-tag callback saw the same single item, with the same snapshot instance.
perTag.Count.ShouldBe(1);
perTag.TryDequeue(out var perTagItem).ShouldBeTrue();
perTagItem.Reference.ShouldBe("b");
perTagItem.Snapshot.ShouldBeSameAs(changed[0].Snapshot);
}
/// <summary>A cycle in which no value moved raises neither the batch nor any per-tag callback.</summary>
[Fact]
public async Task No_batch_when_nothing_changed()
{
var reader = new FakeReader(("a", 1), ("b", 2));
var perTag = new ConcurrentQueue<string>();
var batches = new ConcurrentQueue<IReadOnlyList<DataChangeItem>>();
await using var engine = new PollGroupEngine(
reader.ReadAsync,
(_, reference, _) => perTag.Enqueue(reference),
minInterval: Interval,
onBatch: (_, items) => batches.Enqueue(items));
engine.Subscribe(["a", "b"], Interval);
await WaitForAsync(() => batches.Count >= 1, "the initial-data batch");
var pollsAfterInitial = reader.PollCount;
perTag.Clear();
batches.Clear();
// Several further cycles over identical snapshots, plus a grace window for the callbacks
// of the last counted poll to have run.
await WaitForAsync(() => reader.PollCount >= pollsAfterInitial + 5, "five further poll cycles");
await Task.Delay(50, TestContext.Current.CancellationToken);
batches.ShouldBeEmpty();
perTag.ShouldBeEmpty();
}
/// <summary>
/// Constructed without <c>onBatch</c>, the engine behaves exactly as before: per-tag
/// callbacks only, with no batch collection and no change to the raise pattern.
/// </summary>
[Fact]
public async Task Batch_is_not_invoked_when_callback_absent()
{
var reader = new FakeReader(("a", 1), ("b", 2), ("c", 3));
var perTag = new ConcurrentQueue<string>();
await using var engine = new PollGroupEngine(
reader.ReadAsync,
(_, reference, _) => perTag.Enqueue(reference),
minInterval: Interval);
engine.Subscribe(["a", "b", "c"], Interval);
await WaitForAsync(() => perTag.Count >= 3, "the initial-data per-tag callbacks");
perTag.Count.ShouldBe(3);
reader.Set(("c", 42));
await WaitForAsync(() => perTag.Count >= 4, "the per-tag callback for the changed tag");
// Let further cycles run: nothing else changed, so the count must stay at 4.
var polls = reader.PollCount;
await WaitForAsync(() => reader.PollCount >= polls + 3, "three further poll cycles");
await Task.Delay(50, TestContext.Current.CancellationToken);
perTag.Count.ShouldBe(4);
perTag.ToArray()[3].ShouldBe("c");
}
/// <summary>Batch items follow the reader's reference order, not any incidental sort order.</summary>
[Fact]
public async Task Batch_items_are_in_reader_order()
{
// Deliberately non-alphabetical subscription order: an alphabetical (or insertion-ordered
// dictionary) collection would produce z/a/m in a different sequence.
var reader = new FakeReader(("z", 1), ("a", 2), ("m", 3));
var batches = new ConcurrentQueue<IReadOnlyList<DataChangeItem>>();
await using var engine = new PollGroupEngine(
reader.ReadAsync,
(_, _, _) => { },
minInterval: Interval,
onBatch: (_, items) => batches.Enqueue(items));
engine.Subscribe(["z", "a", "m"], Interval);
await WaitForAsync(() => batches.Count >= 1, "the initial-data batch");
batches.TryDequeue(out var initial).ShouldBeTrue();
initial!.Select(i => i.FullReference).ShouldBe(["z", "a", "m"]);
// A cycle in which two of the three moved keeps the reader's relative order.
batches.Clear();
reader.Set(("m", 30), ("z", 10));
await WaitForAsync(() => batches.Count >= 1, "the batch for the two changed tags");
batches.TryDequeue(out var changed).ShouldBeTrue();
changed!.Select(i => i.FullReference).ShouldBe(["z", "m"]);
}
/// <summary>
/// Both callbacks write into ONE ordered log, so the batch's position relative to the
/// per-tag raises of the same cycle is directly observable: in a cycle where three tags
/// moved, the batch marker must land strictly after all three per-tag markers.
/// </summary>
/// <remarks>
/// <para>This asserts ORDER, not final state — a batch raised mid-loop (after the first
/// change, say) still produces exactly the same four log entries, and is caught here only
/// because their sequence differs. A test that merely counted the entries, or compared the
/// two collections' contents, would pass against that implementation.</para>
///
/// <para>The thread assertion covers the other way the guarantee can be lost: a batch handed
/// to a detached <c>Task.Run</c> would usually still land last in the log (nothing else is
/// appended after it), so position alone does not catch it. The engine raises both callbacks
/// in one synchronous stretch of <c>PollOnceAsync</c> with no <c>await</c> between them, so a
/// correct implementation is on the same thread by construction and this can never flake red;
/// a detached raise lands on a different pool thread.</para>
/// </remarks>
[Fact]
public async Task Batch_is_raised_after_every_per_tag_callback_of_the_same_cycle()
{
var reader = new FakeReader(("a", 1), ("b", 2), ("c", 3));
var log = new ConcurrentQueue<string>();
var perTagThreads = new ConcurrentQueue<int>();
var batchThreads = new ConcurrentQueue<int>();
await using var engine = new PollGroupEngine(
reader.ReadAsync,
// The thread id is recorded BEFORE the log marker in both callbacks: the test awaits the
// "batch" marker, so recording it second would let the assertions run against a
// not-yet-enqueued thread id.
(_, reference, _) =>
{
perTagThreads.Enqueue(Environment.CurrentManagedThreadId);
log.Enqueue($"per-tag:{reference}");
},
minInterval: Interval,
onBatch: (_, _) =>
{
batchThreads.Enqueue(Environment.CurrentManagedThreadId);
log.Enqueue("batch");
});
engine.Subscribe(["a", "b", "c"], Interval);
// Drain the initial-data cycle so the log holds exactly one cycle's markers.
await WaitForAsync(() => log.Contains("batch"), "the initial-data batch");
log.Clear();
perTagThreads.Clear();
batchThreads.Clear();
// All three move under the reader's lock, so ONE poll cycle observes all three changes.
reader.Set(("a", 10), ("b", 20), ("c", 30));
await WaitForAsync(() => log.Contains("batch"), "the batch for the three changed tags");
var ordered = log.ToArray();
var batchIndex = Array.IndexOf(ordered, "batch");
// Exactly one cycle's worth: three per-tag raises then the single batch, in that order.
ordered.Count(e => e == "batch").ShouldBe(1);
ordered.Take(batchIndex).ShouldBe(["per-tag:a", "per-tag:b", "per-tag:c"]);
batchIndex.ShouldBe(3);
// Raised inline on the polling thread, not posted to a detached task.
batchThreads.Count.ShouldBe(1);
batchThreads.Single().ShouldBe(perTagThreads.Last());
}
/// <summary>
/// A non-zero status code is a change in its own right (the value need not move), and the
/// snapshot rides into <see cref="DataChangeItem.Snapshot"/> untouched — the batch path
/// never re-derives or normalises quality (#519 at its origin).
/// </summary>
[Fact]
public async Task Batch_carries_a_bad_status_snapshot_unchanged()
{
const uint BadStatus = 0x80010000u; // Bad_UnexpectedError
var reader = new FakeReader(("a", 1), ("b", 2));
var perTag = new ConcurrentQueue<(string Reference, DataValueSnapshot Snapshot)>();
var batches = new ConcurrentQueue<IReadOnlyList<DataChangeItem>>();
await using var engine = new PollGroupEngine(
reader.ReadAsync,
(_, reference, snapshot) => perTag.Enqueue((reference, snapshot)),
minInterval: Interval,
onBatch: (_, items) => batches.Enqueue(items));
engine.Subscribe(["a", "b"], Interval);
await WaitForAsync(() => batches.Count >= 1, "the initial-data batch");
perTag.Clear();
batches.Clear();
// The device faults on "a": same value, bad quality.
reader.SetStatus("a", BadStatus);
await WaitForAsync(() => batches.Count >= 1, "the batch for the newly-bad tag");
batches.TryDequeue(out var bad).ShouldBeTrue();
bad!.Count.ShouldBe(1);
bad[0].FullReference.ShouldBe("a");
bad[0].Snapshot.StatusCode.ShouldBe(BadStatus);
bad[0].Snapshot.Value.ShouldBe(1); // value unchanged — the status alone drove the raise
// The per-tag callback saw the identical snapshot instance.
perTag.TryDequeue(out var perTagItem).ShouldBeTrue();
perTagItem.Reference.ShouldBe("a");
perTagItem.Snapshot.ShouldBeSameAs(bad[0].Snapshot);
}
}
@@ -20,6 +20,14 @@ public sealed class AbCipSubscriptionTests
return (drv, factory);
}
/// <summary>The driver advertises the batched-subscription capability (which implies ISubscribable).</summary>
[Fact]
public void Driver_is_batch_subscribable()
{
var (drv, _) = NewDriver(new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Speed", AbCipDataType.DInt));
(drv is IBatchSubscribable).ShouldBeTrue();
}
/// <summary>Verifies that the initial poll raises OnDataChange events for every subscribed tag.</summary>
[Fact]
public async Task Initial_poll_raises_OnDataChange_for_every_tag()
@@ -47,6 +47,20 @@ public sealed class FocasCapabilityTests
// ---- ISubscribable ----
/// <summary>The driver advertises the batched-subscription capability (which implies ISubscribable).</summary>
[Fact]
public void Driver_is_batch_subscribable()
{
var drv = new FocasDriver(new FocasDriverOptions
{
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("X", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = false },
}, "drv-1", new FakeFocasClientFactory());
(drv is IBatchSubscribable).ShouldBeTrue();
}
/// <summary>Verifies that the initial subscription poll raises an OnDataChange event.</summary>
[Fact]
public async Task Subscribe_initial_poll_raises_OnDataChange()
@@ -0,0 +1,274 @@
using System.Collections.Concurrent;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Modbus;
namespace ZB.MOM.WW.OtOpcUa.Driver.Modbus.Tests;
/// <summary>
/// Pins the driver-side half of the batched value pipeline: the Modbus driver is an
/// <see cref="IBatchSubscribable"/> and forwards the <c>PollGroupEngine</c>'s per-cycle batch
/// callback to <see cref="IBatchSubscribable.OnDataChangeBatch"/>. Both events fire by design —
/// the batch once per poll cycle, the per-tag one per changed tag — so a consumer subscribes to
/// exactly one of the two.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ModbusBatchSubscriptionTests
{
/// <summary>
/// Lightweight fake transport the subscription tests drive through — only the FC03
/// (Read Holding Registers) path is used. Mutating <see cref="HoldingRegisters"/>
/// between polls is how each test simulates a PLC value change.
/// </summary>
private sealed class FakeTransport : IModbusTransport
{
public readonly ushort[] HoldingRegisters = new ushort[256];
/// <summary>Simulates connecting to the Modbus device.</summary>
/// <param name="ct">The cancellation token.</param>
public Task ConnectAsync(CancellationToken ct) => Task.CompletedTask;
/// <summary>Simulates sending a Modbus PDU.</summary>
/// <param name="unitId">The Modbus unit ID.</param>
/// <param name="pdu">The protocol data unit.</param>
/// <param name="ct">The cancellation token.</param>
public Task<byte[]> SendAsync(byte unitId, byte[] pdu, CancellationToken ct)
{
if (pdu[0] != 0x03) return Task.FromException<byte[]>(new NotSupportedException("FC not supported"));
var addr = (ushort)((pdu[1] << 8) | pdu[2]);
var qty = (ushort)((pdu[3] << 8) | pdu[4]);
var resp = new byte[2 + qty * 2];
resp[0] = 0x03;
resp[1] = (byte)(qty * 2);
for (var i = 0; i < qty; i++)
{
resp[2 + i * 2] = (byte)(HoldingRegisters[addr + i] >> 8);
resp[3 + i * 2] = (byte)(HoldingRegisters[addr + i] & 0xFF);
}
return Task.FromResult(resp);
}
/// <summary>Disposes the transport asynchronously.</summary>
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
private static (ModbusDriver drv, FakeTransport fake) NewDriver(params ModbusTagDefinition[] tags)
{
var fake = new FakeTransport();
var opts = new ModbusDriverOptions { Host = "fake", RawTags = ModbusRawTags.Entries(tags)};
return (new ModbusDriver(opts, "modbus-1", _ => fake), fake);
}
/// <summary>The driver advertises the batched-subscription capability.</summary>
[Fact]
public void Driver_is_batch_subscribable()
{
var (drv, _) = NewDriver(new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16));
(drv is IBatchSubscribable).ShouldBeTrue();
}
/// <summary>
/// One poll cycle that changed N tags raises the batch event ONCE carrying N items, and
/// raises the per-tag event N times with identical content. The invariant asserted across
/// the whole run — the concatenation of every batch's items equals the per-tag event
/// sequence — is what makes the two events interchangeable for a downstream consumer.
/// </summary>
[Fact]
public async Task Batch_event_fires_once_per_cycle_with_per_tag_events()
{
var (drv, fake) = NewDriver(
new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16),
new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 1, ModbusDataType.Int16),
new ModbusTagDefinition("Flow", ModbusRegion.HoldingRegisters, 2, ModbusDataType.Int16));
await drv.InitializeAsync("{}", CancellationToken.None);
fake.HoldingRegisters[0] = 100;
fake.HoldingRegisters[1] = 200;
fake.HoldingRegisters[2] = 300;
var perTag = new ConcurrentQueue<DataChangeEventArgs>();
var batches = new ConcurrentQueue<DataChangeBatchEventArgs>();
drv.OnDataChange += (_, e) => perTag.Enqueue(e);
((IBatchSubscribable)drv).OnDataChangeBatch += (_, e) => batches.Enqueue(e);
var handle = await drv.SubscribeAsync(["Level", "Temp", "Flow"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
// Cycle 1 — the initial-data push: all three tags raise.
await WaitForCountAsync(batches, 1, TimeSpan.FromSeconds(2));
batches.Count.ShouldBe(1);
batches.Single().Items.Count.ShouldBe(3);
perTag.Count.ShouldBe(3);
// Cycle 2 — every tag changes, so the next cycle again batches all three.
fake.HoldingRegisters[0] = 101;
fake.HoldingRegisters[1] = 201;
fake.HoldingRegisters[2] = 301;
await WaitForCountAsync(batches, 2, TimeSpan.FromSeconds(3));
// Unsubscribe drains the in-flight poll loop, so the queues are stable from here.
await drv.UnsubscribeAsync(handle, CancellationToken.None);
var allBatches = batches.ToArray();
allBatches.Length.ShouldBeGreaterThanOrEqualTo(2);
allBatches[1].Items.Count.ShouldBe(3);
// Every batch belongs to the subscription it was raised for, and is never empty.
foreach (var b in allBatches)
{
b.SubscriptionHandle.ShouldBeSameAs(handle);
b.Items.ShouldNotBeEmpty();
}
// Identical content: flattening the batches reproduces the per-tag event stream exactly
// (same references, same snapshots, same order).
var flattened = allBatches.SelectMany(b => b.Items).ToArray();
var perTagArray = perTag.ToArray();
flattened.Length.ShouldBe(perTagArray.Length);
for (var i = 0; i < flattened.Length; i++)
{
flattened[i].FullReference.ShouldBe(perTagArray[i].FullReference);
flattened[i].Snapshot.ShouldBe(perTagArray[i].Snapshot);
}
}
/// <summary>
/// The batch event honours the driver's own per-tag deadband filter. The
/// <c>PollGroupEngine</c> collects batch items unconditionally — it knows nothing about
/// <c>ShouldPublish</c> — so a straight pass-through of the engine's list would carry
/// changes the per-tag event suppressed, and a downstream consumer that PREFERS the batch
/// event would see deadband silently stop working. A cycle whose only changes are all
/// suppressed raises no batch at all.
/// </summary>
[Fact]
public async Task Deadband_suppressed_changes_are_excluded_from_the_batch()
{
var (drv, fake) = NewDriver(
new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16, Deadband: 5.0));
await drv.InitializeAsync("{}", CancellationToken.None);
fake.HoldingRegisters[0] = 100;
var perTag = new ConcurrentQueue<DataChangeEventArgs>();
var batches = new ConcurrentQueue<DataChangeBatchEventArgs>();
drv.OnDataChange += (_, e) => perTag.Enqueue(e);
((IBatchSubscribable)drv).OnDataChangeBatch += (_, e) => batches.Enqueue(e);
var handle = await drv.SubscribeAsync(["Temp"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
try
{
// Cycle 1 — initial-data push. First sample seeds the deadband baseline and publishes
// through both events.
await WaitForCountAsync(batches, 1, TimeSpan.FromSeconds(2));
batches.Count.ShouldBe(1);
batches.Single().Items.Count.ShouldBe(1);
batches.Single().Items[0].Snapshot.Value.ShouldBe((short)100);
perTag.Count.ShouldBe(1);
// Under-threshold change (delta 2 < 5): the ENGINE sees a value change and would offer
// it as a batch item, but the driver's deadband suppresses the publish — so neither
// event fires and no batch is raised for that cycle at all.
fake.HoldingRegisters[0] = 102;
await Task.Delay(600);
perTag.Count.ShouldBe(1, "under-threshold change is suppressed on the per-tag event");
batches.Count.ShouldBe(1, "a cycle whose only change is deadband-suppressed raises no batch");
// Over-threshold change (delta 10 ≥ 5 against the 100 baseline): publishes on both.
fake.HoldingRegisters[0] = 110;
await WaitForCountAsync(batches, 2, TimeSpan.FromSeconds(3));
}
finally
{
// Unsubscribe drains the in-flight poll loop, so the queues are stable from here.
await drv.UnsubscribeAsync(handle, CancellationToken.None);
}
var allBatches = batches.ToArray();
allBatches.Length.ShouldBe(2);
allBatches[1].Items.Count.ShouldBe(1);
allBatches[1].Items[0].Snapshot.Value.ShouldBe((short)110);
// Content equality: flattening the batches reproduces the per-tag event stream exactly.
// The suppressed 102 appears in NEITHER.
var flattened = allBatches.SelectMany(b => b.Items).ToArray();
var perTagArray = perTag.ToArray();
flattened.Length.ShouldBe(perTagArray.Length);
for (var i = 0; i < flattened.Length; i++)
{
flattened[i].FullReference.ShouldBe(perTagArray[i].FullReference);
flattened[i].Snapshot.ShouldBe(perTagArray[i].Snapshot);
}
flattened.ShouldNotContain(item => Equals(item.Snapshot.Value, (short)102));
}
/// <summary>
/// A per-tag <see cref="ISubscribable.OnDataChange"/> subscriber that throws must not corrupt
/// the poll cycle. <c>PollGroupEngine.PollOnceAsync</c> invokes the driver's onChange bridge
/// UNGUARDED, so without the driver-side try/catch the throw unwinds the whole cycle before
/// <c>onBatch</c> runs — and because Modbus buffers its published items across the cycle
/// (the deadband-honouring buffer the tests above pin), the undrained items would silently
/// MERGE into a later cycle's batch. This failure mode is unique to Modbus: the other poll
/// drivers forward the engine's own per-cycle list and hold no state between cycles.
/// Asserted on both the shape (one batch per cycle, three items each) and the VALUES, so a
/// merged or dropped batch fails rather than coincidentally matching on count.
/// </summary>
[Fact]
public async Task A_throwing_per_tag_subscriber_does_not_break_or_contaminate_the_cycle_batch()
{
var (drv, fake) = NewDriver(
new ModbusTagDefinition("Level", ModbusRegion.HoldingRegisters, 0, ModbusDataType.Int16),
new ModbusTagDefinition("Temp", ModbusRegion.HoldingRegisters, 1, ModbusDataType.Int16),
new ModbusTagDefinition("Flow", ModbusRegion.HoldingRegisters, 2, ModbusDataType.Int16));
await drv.InitializeAsync("{}", CancellationToken.None);
fake.HoldingRegisters[0] = 100;
fake.HoldingRegisters[1] = 200;
fake.HoldingRegisters[2] = 300;
var thrown = 0;
var batches = new ConcurrentQueue<DataChangeBatchEventArgs>();
// The buggy external subscriber: every per-tag delivery faults.
drv.OnDataChange += (_, _) =>
{
Interlocked.Increment(ref thrown);
throw new InvalidOperationException("buggy per-tag subscriber");
};
((IBatchSubscribable)drv).OnDataChangeBatch += (_, e) => batches.Enqueue(e);
var handle = await drv.SubscribeAsync(["Level", "Temp", "Flow"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
try
{
// Cycle 1 — the initial-data push. All three tags publish despite every per-tag
// delivery faulting, and the cycle still reaches its batch.
await WaitForCountAsync(batches, 1, TimeSpan.FromSeconds(2));
batches.Count.ShouldBe(1, "the cycle must still reach onBatch after a subscriber throws");
// Cycle 2 — every tag changes again.
fake.HoldingRegisters[0] = 101;
fake.HoldingRegisters[1] = 201;
fake.HoldingRegisters[2] = 301;
await WaitForCountAsync(batches, 2, TimeSpan.FromSeconds(3));
}
finally
{
// Unsubscribe drains the in-flight poll loop, so the queues are stable from here.
await drv.UnsubscribeAsync(handle, CancellationToken.None);
}
var allBatches = batches.ToArray();
allBatches.Length.ShouldBeGreaterThanOrEqualTo(2);
allBatches[0].Items.Count.ShouldBe(3);
allBatches[0].Items.Select(i => i.Snapshot.Value).ShouldBe(new object[] { (short)100, (short)200, (short)300 });
// The load-bearing assertion: cycle 2's batch carries EXACTLY cycle 2's items. A cycle
// aborted mid-loop would have left its items in the buffer to merge in here.
allBatches[1].Items.Count.ShouldBe(3, "a cycle aborted before onBatch would merge its buffered items into the next batch");
allBatches[1].Items.Select(i => i.Snapshot.Value).ShouldBe(new object[] { (short)101, (short)201, (short)301 });
// Sanity: the throwing subscriber really was invoked for every published item.
thrown.ShouldBeGreaterThanOrEqualTo(6);
}
private static async Task WaitForCountAsync<T>(ConcurrentQueue<T> q, int target, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (q.Count < target && DateTime.UtcNow < deadline)
await Task.Delay(25);
}
}
@@ -0,0 +1,264 @@
using Microsoft.Extensions.Logging.Abstractions;
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Value-pipeline batching (Task 6) — <see cref="OtOpcUaNodeManager.WriteValues"/> applies a batch under
/// one acquisition of the manager-wide SDK <c>Lock</c> per 512-item chunk instead of one per value (the
/// per-value take was the steady-state throughput ceiling on the publish path; the chunk cap keeps the
/// first poll cycle's force-raise-everything batch from stalling every SDK service thread).
/// <para>These tests pin the <b>observable</b> contract rather than the locking: every item lands with
/// its own Value / StatusCode / Timestamp, an item for an undeclared NodeId is lazily created exactly
/// as <see cref="OtOpcUaNodeManager.WriteValue"/> does, and an empty batch does nothing at all.
/// A non-Good status is included so the #519 verbatim-sub-code passthrough is proven on the batched
/// path too — a batch that re-projected status through a 3-state enum would collapse it to a bare
/// <c>Bad</c>.</para>
/// <para>Boots a real <see cref="OtOpcUaSdkServer"/> through <see cref="OpcUaApplicationHost"/> — the
/// same harness <see cref="NodeManagerArrayTests"/> uses — so the assertions read the real SDK node
/// state an OPC UA client would receive.</para>
/// </summary>
public sealed class NodeManagerBatchWriteTests : IDisposable
{
private static CancellationToken Ct => TestContext.Current.CancellationToken;
private readonly string _pkiRoot = Path.Combine(
Path.GetTempPath(),
$"otopcua-batchwrite-{Guid.NewGuid():N}");
/// <summary>Every item of a multi-item batch lands on its own node with its own value, status code
/// (including a non-Good sub-code) and source timestamp — no item is dropped, and none overwrites
/// another's state.</summary>
[Fact]
public async Task WriteValues_lands_every_value()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
foreach (var name in new[] { "a", "b", "c" })
{
nm.EnsureVariable($"eq-1/{name}", parentFolderNodeId: null, displayName: name, dataType: "Double",
writable: false, realm: AddressSpaceRealm.Uns);
}
var t1 = new DateTime(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc);
var t2 = new DateTime(2026, 8, 15, 10, 0, 1, DateTimeKind.Utc);
var t3 = new DateTime(2026, 8, 15, 10, 0, 2, DateTimeKind.Utc);
nm.WriteValues(new List<ValueWrite>
{
new("eq-1/a", 1.5d, OpcUaStatus.Good, t1, AddressSpaceRealm.Uns),
new("eq-1/b", null, OpcUaStatus.BadNoCommunication, t2, AddressSpaceRealm.Uns),
new("eq-1/c", 3.5d, 0x80740000u /* BadTypeMismatch */, t3, AddressSpaceRealm.Uns),
});
var a = nm.TryGetVariable("eq-1/a");
a.ShouldNotBeNull();
a!.Value.ShouldBe(1.5d);
a.StatusCode.ShouldBe((StatusCode)StatusCodes.Good);
a.Timestamp.ShouldBe(t1);
var b = nm.TryGetVariable("eq-1/b");
b.ShouldNotBeNull();
b!.Value.ShouldBeNull();
b.StatusCode.ShouldBe((StatusCode)StatusCodes.BadNoCommunication);
b.StatusCode.ShouldNotBe((StatusCode)StatusCodes.Bad,
"the batched path must hand the status sub-code to the SDK verbatim (#519)");
b.Timestamp.ShouldBe(t2);
var c = nm.TryGetVariable("eq-1/c");
c.ShouldNotBeNull();
c!.Value.ShouldBe(3.5d);
c.StatusCode.ShouldBe((StatusCode)StatusCodes.BadTypeMismatch);
c.Timestamp.ShouldBe(t3);
}
finally
{
await host.DisposeAsync();
}
}
/// <summary>A batch item naming a NodeId that was never declared lazily creates the variable, exactly
/// as the single-value <see cref="OtOpcUaNodeManager.WriteValue"/> create-on-first-write path does —
/// and it does so PER REALM. Both items here carry the SAME <c>s=</c> id and differ only by realm, so a
/// key that ignored the realm would create one node and have the second item overwrite the first. The
/// assertion is therefore two nodes with DISTINCT namespace indices, not merely "both reads returned
/// something" (which a realm-blind map satisfies, since the shared key resolves for either lookup).</summary>
[Fact]
public async Task WriteValues_creates_missing_variables()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
const string SharedId = "Shared/lazy/id";
nm.TryGetVariable(SharedId, AddressSpaceRealm.Uns).ShouldBeNull();
nm.TryGetVariable(SharedId, AddressSpaceRealm.Raw).ShouldBeNull();
var ts = new DateTime(2026, 8, 15, 11, 0, 0, DateTimeKind.Utc);
nm.WriteValues(new List<ValueWrite>
{
new(SharedId, 42, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns),
new(SharedId, 7, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw),
});
var uns = nm.TryGetVariable(SharedId, AddressSpaceRealm.Uns);
uns.ShouldNotBeNull();
uns!.Value.ShouldBe(42);
uns.StatusCode.ShouldBe((StatusCode)StatusCodes.Good);
uns.Timestamp.ShouldBe(ts);
var raw = nm.TryGetVariable(SharedId, AddressSpaceRealm.Raw);
raw.ShouldNotBeNull();
raw!.Value.ShouldBe(7);
// Two DISTINCT nodes, one per namespace — the realm-blind failure mode collapses these to one.
raw.NodeId.NamespaceIndex.ShouldNotBe(uns.NodeId.NamespaceIndex);
raw.NodeId.NamespaceIndex.ShouldBe(nm.NamespaceIndexForRealm(AddressSpaceRealm.Raw));
uns.NodeId.NamespaceIndex.ShouldBe(nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns));
}
finally
{
await host.DisposeAsync();
}
}
/// <summary>A bad item does NOT drop the tail of the batch. Item 2's NodeId is empty; items 1 and 3 must
/// still land, and the aggregate surfaces as a <see cref="ValueWriteBatchException"/> naming the offender
/// and the failed/succeeded split.
/// <para>This is the durability-critical half: the poll engine updates its last-value cache BEFORE
/// raising, so an item dropped here is never re-raised until that tag's value changes again — the node
/// would serve a stale value indefinitely, with nothing in the log naming it.</para></summary>
[Fact]
public async Task A_failing_item_neither_drops_the_tail_nor_hides_which_node_failed()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var ts = new DateTime(2026, 8, 15, 12, 0, 0, DateTimeKind.Utc);
var ex = Should.Throw<ValueWriteBatchException>(() => nm.WriteValues(new List<ValueWrite>
{
new("eq-iso/first", 1, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns),
new(string.Empty, 2, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns),
new("eq-iso/third", 3, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns),
}));
ex.FailedCount.ShouldBe(1);
ex.SucceededCount.ShouldBe(2);
ex.FirstFailedNodeId.ShouldBe(string.Empty);
// The load-bearing half: BOTH good items applied — including the one AFTER the failure.
var first = nm.TryGetVariable("eq-iso/first");
first.ShouldNotBeNull();
first!.Value.ShouldBe(1);
var third = nm.TryGetVariable("eq-iso/third");
third.ShouldNotBeNull("the item after the failing one must not be dropped");
third!.Value.ShouldBe(3);
}
finally
{
await host.DisposeAsync();
}
}
/// <summary>A batch larger than <c>MaxWritesPerLockAcquisition</c> (512) spans several lock acquisitions;
/// chunking must be invisible in the outcome — every item still lands exactly once, with its own value.
/// Chunk boundaries are the natural off-by-one hazard, so the size is deliberately not a multiple of 512.
/// </summary>
[Fact]
public async Task A_batch_spanning_several_chunks_lands_every_item()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var ts = new DateTime(2026, 8, 15, 13, 0, 0, DateTimeKind.Utc);
const int Count = 1300; // > 2 chunks of 512, and not a multiple of it
var items = new List<ValueWrite>(Count);
for (var i = 0; i < Count; i++)
{
items.Add(new ValueWrite($"eq-chunk/{i}", i, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns));
}
nm.WriteValues(items);
nm.VariableCount.ShouldBe(Count);
foreach (var probe in new[] { 0, 511, 512, 1023, 1024, Count - 1 })
{
var v = nm.TryGetVariable($"eq-chunk/{probe}");
v.ShouldNotBeNull($"item {probe} (a chunk boundary) must have landed");
v!.Value.ShouldBe(probe);
}
}
finally
{
await host.DisposeAsync();
}
}
/// <summary>An empty batch neither throws nor touches the address space.</summary>
[Fact]
public async Task Empty_batch_is_a_no_op()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
var before = nm.VariableCount;
Should.NotThrow(() => nm.WriteValues([]));
nm.VariableCount.ShouldBe(before);
}
finally
{
await host.DisposeAsync();
}
}
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
{
var host = new OpcUaApplicationHost(
new OpcUaApplicationHostOptions
{
ApplicationName = "OtOpcUa.BatchWriteTest",
ApplicationUri = $"urn:OtOpcUa.BatchWriteTest:{Guid.NewGuid():N}",
OpcUaPort = AllocateFreePort(),
PublicHostname = "localhost",
PkiStoreRoot = _pkiRoot,
},
NullLogger<OpcUaApplicationHost>.Instance);
var server = new OtOpcUaSdkServer();
await host.StartAsync(server, Ct);
return (host, server);
}
private static int AllocateFreePort()
{
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
listener.Start();
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
/// <summary>Cleans up the PKI root directory.</summary>
public void Dispose()
{
if (Directory.Exists(_pkiRoot))
{
try { Directory.Delete(_pkiRoot, recursive: true); }
catch { /* best-effort cleanup */ }
}
}
}
@@ -9,7 +9,8 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// OpcUaServer-004 — a fresh <see cref="OtOpcUaNodeManager"/> whose <c>CreateAddressSpace</c> has NOT
/// yet run (i.e. the server has not started) has a null <c>_root</c>. Every public address-space mutator
/// (<see cref="OtOpcUaNodeManager.WriteValue"/>, <see cref="OtOpcUaNodeManager.WriteAlarmCondition"/>,
/// (<see cref="OtOpcUaNodeManager.WriteValue"/>, <see cref="OtOpcUaNodeManager.WriteValues"/>,
/// <see cref="OtOpcUaNodeManager.WriteAlarmCondition"/>,
/// <see cref="OtOpcUaNodeManager.EnsureFolder"/>, <see cref="OtOpcUaNodeManager.EnsureVariable"/>,
/// <see cref="OtOpcUaNodeManager.MaterialiseAlarmCondition"/>) must now fail with a legible
/// <see cref="InvalidOperationException"/> instead of a bare NRE out of <c>ResolveParentFolder</c> /
@@ -78,6 +79,28 @@ public sealed class NodeManagerPreStartGuardTests : IDisposable
}
}
/// <summary>The batched mutator guards the same way — and it does so BEFORE its empty-batch early-return,
/// so a pre-start caller gets the legible failure regardless of what the batch happens to carry (an empty
/// batch returning quietly would make the guard depend on payload rather than on server state).</summary>
[Fact]
public async Task WriteValues_before_CreateAddressSpace_throws_InvalidOperationException()
{
var (host, nm) = await BuildPreStartNodeManagerAsync();
try
{
Should.Throw<InvalidOperationException>(() => nm.WriteValues(new[]
{
new ValueWrite("eq-1/temp", 1.0, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns),
}));
Should.Throw<InvalidOperationException>(() => nm.WriteValues([]));
}
finally
{
await host.DisposeAsync();
}
}
[Fact]
public async Task MaterialiseAlarmCondition_before_CreateAddressSpace_throws_InvalidOperationException()
{
@@ -36,6 +36,83 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable
await host.DisposeAsync();
}
/// <summary>The sink's <see cref="SdkAddressSpaceSink.WriteValues"/> override forwards the batch to the
/// node manager's own batched entry point — every item lands, with its own value and realm.</summary>
[Fact]
public async Task WriteValues_forwards_the_batch_to_the_node_manager()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
// Through the INTERFACE on purpose: that is the seam production uses, and it is the only call
// shape under which deleting the sink's override silently degrades to the per-value default loop
// (a concrete-typed call would simply stop compiling, which is not the regression being pinned).
IOpcUaAddressSpaceSink sink = new SdkAddressSpaceSink(nm);
var ts = new DateTime(2026, 8, 15, 9, 0, 0, DateTimeKind.Utc);
sink.WriteValues(new List<ValueWrite>
{
new("Plant/dev1/Speed", 42, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw),
new("eq-1/Speed", 42, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns),
});
nm.VariableCount.ShouldBe(2);
var raw = nm.TryGetVariable("Plant/dev1/Speed", AddressSpaceRealm.Raw);
raw.ShouldNotBeNull();
raw!.Value.ShouldBe(42);
raw.Timestamp.ShouldBe(ts);
var uns = nm.TryGetVariable("eq-1/Speed", AddressSpaceRealm.Uns);
uns.ShouldNotBeNull();
uns!.Value.ShouldBe(42);
}
finally
{
await host.DisposeAsync();
}
}
/// <summary>Pins that the sink actually OVERRIDES <c>WriteValues</c> rather than inheriting the
/// interface's per-value default loop. The values-landed assertion above cannot tell the two apart — the
/// default loop lands them too — so this uses the one place their behaviour diverges: the manager's
/// batched path isolates per item and reports a <see cref="ValueWriteBatchException"/> aggregate, whereas
/// the default loop lets the first item's <c>ArgumentException</c> escape and never reaches the tail.
/// A test seam (a recording subclass) is not available here — <see cref="SdkAddressSpaceSink"/> is sealed
/// and its <c>WriteValues</c> is not virtual — so the forward is pinned by its observable contract
/// instead, which is the stronger assertion anyway.</summary>
[Fact]
public async Task WriteValues_forward_surfaces_the_node_managers_partial_failure_contract()
{
var (host, server) = await BootAsync();
try
{
var nm = server.NodeManager!;
IOpcUaAddressSpaceSink sink = new SdkAddressSpaceSink(nm);
var ts = new DateTime(2026, 8, 15, 9, 30, 0, DateTimeKind.Utc);
var ex = Should.Throw<ValueWriteBatchException>(() => sink.WriteValues(new List<ValueWrite>
{
new("eq-fwd/first", 1, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns),
new(string.Empty, 2, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns),
new("eq-fwd/third", 3, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns),
}));
ex.FailedCount.ShouldBe(1);
ex.SucceededCount.ShouldBe(2);
// The tail item landed — the default per-value loop would have thrown out on item 2 and skipped it.
var third = nm.TryGetVariable("eq-fwd/third", AddressSpaceRealm.Uns);
third.ShouldNotBeNull();
third!.Value.ShouldBe(3);
}
finally
{
await host.DisposeAsync();
}
}
/// <summary>Verifies that WriteAlarmCondition (un-materialised fallback) creates a dedicated node
/// distinct from value writes.</summary>
[Fact]
@@ -17,7 +17,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <see cref="CalculationDriver"/> path (not merely that <see cref="IDependencyConsumer"/> exists).
/// A source value published to the mux is fed into the calc driver, which computes and publishes a
/// value; and a calc tag reading ANOTHER calc tag's output (calc-of-calc) recomputes when the first
/// tag's output re-enters the mux — the exact re-entry the host's <c>ForwardToMux</c> performs, mirrored
/// tag's output re-enters the mux — the exact re-entry the host's <c>ForwardBatchToMux</c> performs, mirrored
/// here by looping the driver's <see cref="ISubscribable.OnDataChange"/> back into the mux.
/// </summary>
public sealed class CalculationDependencyFlowTests : RuntimeActorTestBase
@@ -58,7 +58,7 @@ public sealed class CalculationDependencyFlowTests : RuntimeActorTestBase
var published = new ConcurrentQueue<DataChangeEventArgs>();
var mux = Sys.ActorOf(DependencyMuxActor.Props(), "dep-mux");
// Loop the driver's outputs back into the mux exactly as DriverHostActor.ForwardToMux does — this
// Loop the driver's outputs back into the mux exactly as DriverHostActor.ForwardBatchToMux does — this
// is what makes calc-of-calc chains work.
driver.OnDataChange += (_, e) =>
{
@@ -0,0 +1,198 @@
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
using static ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness.V3ArtifactSeeder;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Batched value fan-out in <see cref="DriverHostActor"/>: a driver child's
/// <see cref="DriverInstanceActor.AttributeValueBatchPublished"/> burst costs exactly ONE mux hop and
/// ONE OPC UA publish hop, regardless of how many values it carries — the point of the batching work.
/// <para>
/// The routing itself is unchanged from the per-value path: each item's <c>FullReference</c>
/// (== the tag's RawPath) resolves through the host's <c>_nodeIdByDriverRef</c> map to the RAW NodeId
/// AND every referencing UNS NodeId, each carrying IDENTICAL value/status/timestamp (v3 Batch 4
/// single-source fan-out), and an unmapped ref is dropped rather than faulting the burst. These pin
/// the message COUNT as well as the contents, because a per-item unwrap satisfies the contents alone.
/// </para>
/// <para>
/// Arrangement mirrors <see cref="DriverHostActorLiveValueTests"/> — a real apply through the seeded
/// v3 artifact (<see cref="V3ArtifactSeeder"/>) builds the map; sink + mux are
/// <see cref="Akka.TestKit.TestProbe"/>s.
/// </para>
/// </summary>
public sealed class DriverHostActorBatchFanOutTests : RuntimeActorTestBase
{
private static readonly NodeId TestNode = NodeId.Parse("driver-batch-test");
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('b', 64));
private static readonly DateTime TsA = new(2026, 8, 15, 9, 0, 0, DateTimeKind.Utc);
private static readonly DateTime TsB = new(2026, 8, 15, 9, 0, 1, DateTimeKind.Utc);
/// <summary>A two-item burst whose first item is referenced by one equipment lands as a SINGLE
/// AttributeValueBatchUpdate carrying all three (raw, uns, raw) writes verbatim — including a non-Good
/// sub-code (#519), which rides through unbucketed.</summary>
[Fact]
public void Batch_fans_out_to_all_node_realm_refs_in_one_publish_message()
{
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: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) });
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA);
actor.Tell(new DriverInstanceActor.AttributeValueBatchPublished("drv-1", new[]
{
new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/speed", 42.0, OpcUaStatus.Good, TsA),
new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/temp", 7.5, OpcUaStatus.BadNoCommunication, TsB),
}));
var batch = publish.ExpectMsg<OpcUaPublishActor.AttributeValueBatchUpdate>(PresenceBudget);
batch.Items.Count.ShouldBe(3);
var byId = batch.Items.ToDictionary(w => w.NodeId);
byId.Keys.ShouldBe(new[]
{
"Plant/Modbus/dev1/speed",
"filling/line1/station1/speed",
"Plant/Modbus/dev1/temp",
}, ignoreOrder: true);
// Item A: raw + UNS, identical value/quality/timestamp (fan-out drift guard).
foreach (var id in new[] { "Plant/Modbus/dev1/speed", "filling/line1/station1/speed" })
{
byId[id].Value.ShouldBe(42.0);
byId[id].StatusCode.ShouldBe(OpcUaStatus.Good);
byId[id].SourceTimestampUtc.ShouldBe(TsA);
}
byId["Plant/Modbus/dev1/speed"].Realm.ShouldBe(AddressSpaceRealm.Raw);
byId["filling/line1/station1/speed"].Realm.ShouldBe(AddressSpaceRealm.Uns);
// Item B: raw only, and its own status sub-code + timestamp — not item A's.
byId["Plant/Modbus/dev1/temp"].Value.ShouldBe(7.5);
byId["Plant/Modbus/dev1/temp"].StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
byId["Plant/Modbus/dev1/temp"].SourceTimestampUtc.ShouldBe(TsB);
byId["Plant/Modbus/dev1/temp"].Realm.ShouldBe(AddressSpaceRealm.Raw);
// ONE message, not one per value.
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>An item whose ref is bound to no NodeId is dropped; the rest of the burst still lands in the
/// one message and nothing faults.</summary>
[Fact]
public void Unmapped_items_are_dropped_not_faulted()
{
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, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA);
actor.Tell(new DriverInstanceActor.AttributeValueBatchPublished("drv-1", new[]
{
new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/nope", 99.0, OpcUaStatus.Good, TsA),
new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/speed", 42.0, OpcUaStatus.Good, TsA),
}));
var batch = publish.ExpectMsg<OpcUaPublishActor.AttributeValueBatchUpdate>(PresenceBudget);
batch.Items.Count.ShouldBe(1);
batch.Items[0].NodeId.ShouldBe("Plant/Modbus/dev1/speed");
batch.Items[0].Value.ShouldBe(42.0);
// The host is still alive and routing after the unmapped item.
actor.Tell(new DriverInstanceActor.AttributeValueBatchPublished("drv-1", new[]
{
new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/speed", 43.0, OpcUaStatus.Good, TsB),
}));
publish.ExpectMsg<OpcUaPublishActor.AttributeValueBatchUpdate>(PresenceBudget)
.Items[0].Value.ShouldBe(43.0);
}
/// <summary>The dependency mux gets ONE envelope carrying the whole burst — not N per-item messages.
/// The mux filters per item itself.</summary>
[Fact]
public void Batch_reaches_mux_once()
{
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(this, db, TestNode, deploymentId, RevA);
actor.Tell(new DriverInstanceActor.AttributeValueBatchPublished("drv-1", new[]
{
new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/speed", 42.0, OpcUaStatus.Good, TsA),
new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/temp", 7.5, OpcUaStatus.Good, TsB),
}));
var fwd = mux.ExpectMsg<DriverInstanceActor.AttributeValueBatchPublished>(PresenceBudget);
fwd.DriverInstanceId.ShouldBe("drv-1");
fwd.Items.Select(i => i.FullReference).ShouldBe(
new[] { "Plant/Modbus/dev1/speed", "Plant/Modbus/dev1/temp" });
mux.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>A burst in which NOTHING resolves sends no publish message at all — an empty batch would be
/// wasted work at the sink — while the mux still gets its one envelope (virtual-tag inputs are keyed by
/// ref, independent of whether the ref is materialised as a node).</summary>
[Fact]
public void All_unmapped_batch_sends_nothing_to_publish_actor()
{
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, publish, mux) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA);
actor.Tell(new DriverInstanceActor.AttributeValueBatchPublished("drv-1", new[]
{
new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/nope", 1.0, OpcUaStatus.Good, TsA),
new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/alsonope", 2.0, OpcUaStatus.Good, TsB),
}));
mux.ExpectMsg<DriverInstanceActor.AttributeValueBatchPublished>(PresenceBudget)
.Items.Count.ShouldBe(2);
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
}
/// <summary>A host with NO OPC UA publish actor wired (the ctor default — the dev/Mac shape) still
/// makes the mux hop: the batch fan-out returns early on a null sink, and returning early BEFORE the
/// mux Tell would silently strand virtual-tag and continuous-historization inputs.</summary>
[Fact]
public void Mux_still_receives_batch_when_no_publish_actor_is_wired()
{
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) = SpawnHostAndApplyWithoutPublishActor(this, db, TestNode, deploymentId, RevA);
actor.Tell(new DriverInstanceActor.AttributeValueBatchPublished("drv-1", new[]
{
new DriverInstanceActor.AttributeValueItem("Plant/Modbus/dev1/speed", 42.0, OpcUaStatus.Good, TsA),
}));
var fwd = mux.ExpectMsg<DriverInstanceActor.AttributeValueBatchPublished>(PresenceBudget);
fwd.DriverInstanceId.ShouldBe("drv-1");
fwd.Items.Single().FullReference.ShouldBe("Plant/Modbus/dev1/speed");
}
}
@@ -1,19 +1,12 @@
using System.Text.Json;
using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Fleet;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
using static ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness.V3ArtifactSeeder;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
@@ -22,14 +15,22 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <see cref="DriverHostActor"/>. The driver publishes a value keyed by its wire-ref (== the tag's
/// <c>RawPath</c>); the host's <c>_nodeIdByDriverRef</c> map — rebuilt each apply from the composition's
/// <c>RawTags</c> ∪ <c>UnsReferenceVariables</c> — resolves <c>(DriverInstanceId, RawPath)</c> to the RAW
/// NodeId AND every referencing UNS NodeId, so <c>ForwardToMux</c> lands ONE publish on the raw node
/// NodeId AND every referencing UNS NodeId, so <c>ForwardBatchToMux</c> lands ONE write on the raw node
/// (<see cref="AddressSpaceRealm.Raw"/>) plus every UNS node (<see cref="AddressSpaceRealm.Uns"/>) with
/// IDENTICAL value/quality/timestamp (no independent buffer ⇒ no drift). The raw value is still forwarded
/// to the dependency mux (VirtualTag inputs), keyed by the RawPath.
/// <para>
/// Drives a real apply through the existing harness: the seeded artifact carries the v3 raw-tag chain
/// (RawFolder → DriverInstance(RawFolderId) → Device → Tag) + an optional UnsTagReference projecting
/// each raw tag into an equipment. <c>DispatchDeployment</c> triggers the
/// Driven over the BATCH ingress, because that is the only one a driver child has: since the
/// value-pipeline batching work <c>DriverInstanceActor.OnDataChangeForward</c> wraps even a single
/// data change in an <see cref="DriverInstanceActor.AttributeValueBatchPublished"/>, and the host
/// no longer receives the per-value message at all. These fan-out cases are therefore stated as
/// single-item bursts; what they pin is the ROUTING, while
/// <see cref="DriverHostActorBatchFanOutTests"/> pins the message counts of a multi-item burst.
/// </para>
/// <para>
/// Drives a real apply through <see cref="V3ArtifactSeeder"/>: the seeded artifact carries the v3
/// raw-tag chain (RawFolder → DriverInstance(RawFolderId) → Device → Tag) + an optional
/// UnsTagReference projecting each raw tag into an equipment. <c>DispatchDeployment</c> triggers the
/// <c>ApplyAndAck → PushDesiredSubscriptions</c> pass that builds the map; the sink and mux are
/// injected as <see cref="Akka.TestKit.TestProbe"/>s.
/// </para>
@@ -40,6 +41,12 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64));
private static readonly DateTime Ts = new(2026, 6, 13, 10, 0, 0, DateTimeKind.Utc);
/// <summary>The single-item burst a driver child emits for one data change — every value ingress into
/// the host is an AttributeValueBatchPublished, so the fan-out cases below state their input that
/// way.</summary>
private static DriverInstanceActor.AttributeValueBatchPublished Burst((string Ref, object Value) item) =>
new("drv-1", new[] { new DriverInstanceActor.AttributeValueItem(item.Ref, item.Value, OpcUaStatus.Good, Ts) });
/// <summary>A driver value published by RawPath fans to the RAW node AND the referencing UNS node, each
/// carrying identical value/quality/timestamp — the fan-out drift guard.</summary>
[Fact]
@@ -50,15 +57,14 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
rawTags: new[] { (Driver: "drv-1", DriverName: "Modbus", Device: "dev1", Tag: "speed", DataType: "Double", Writable: false) },
unsRefs: new[] { (Area: "filling", Line: "line1", Equip: "station1", BackingTag: "speed", Effective: (string?)null) });
var (actor, publish, mux) = SpawnHostAndApply(db, deploymentId);
var (actor, publish, mux) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA);
// The driver publishes by its wire-ref RawPath.
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
"drv-1", "Plant/Modbus/dev1/speed", 42.0, OpcUaStatus.Good, Ts));
// The driver publishes by its wire-ref RawPath, in the single-item burst DriverInstanceActor
// emits for one data change.
actor.Tell(Burst(("Plant/Modbus/dev1/speed", 42.0)));
var a = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
var b = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
var byId = new[] { a, b }.ToDictionary(u => u.NodeId);
var byId = publish.ExpectMsg<OpcUaPublishActor.AttributeValueBatchUpdate>(PresenceBudget)
.Items.ToDictionary(u => u.NodeId);
byId.Keys.ShouldBe(new[] { "Plant/Modbus/dev1/speed", "filling/line1/station1/speed" }, ignoreOrder: true);
// Realm travels with each update so the sink resolves the right namespace.
@@ -69,12 +75,12 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
{
u.Value.ShouldBe(42.0);
u.StatusCode.ShouldBe(OpcUaStatus.Good);
u.TimestampUtc.ShouldBe(Ts);
u.SourceTimestampUtc.ShouldBe(Ts);
}
// The raw publish still reaches the dependency mux (VirtualTag inputs), keyed by RawPath.
mux.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(5))
.FullReference.ShouldBe("Plant/Modbus/dev1/speed");
mux.ExpectMsg<DriverInstanceActor.AttributeValueBatchPublished>(PresenceBudget)
.Items.Single().FullReference.ShouldBe("Plant/Modbus/dev1/speed");
}
/// <summary>A raw tag with NO UNS reference fans to ONLY its raw NodeId (Raw realm).</summary>
@@ -86,12 +92,12 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
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, publish, _) = SpawnHostAndApply(db, deploymentId);
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA);
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
"drv-1", "Plant/Modbus/dev1/speed", 5.0, OpcUaStatus.Good, Ts));
actor.Tell(Burst(("Plant/Modbus/dev1/speed", 5.0)));
var only = publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5));
var only = publish.ExpectMsg<OpcUaPublishActor.AttributeValueBatchUpdate>(PresenceBudget)
.Items.ShouldHaveSingleItem();
only.NodeId.ShouldBe("Plant/Modbus/dev1/speed");
only.Realm.ShouldBe(AddressSpaceRealm.Raw);
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
@@ -111,17 +117,13 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
(Area: "filling", Line: "line1", Equip: "station2", BackingTag: "speed", Effective: (string?)null),
});
var (actor, publish, _) = SpawnHostAndApply(db, deploymentId);
var (actor, publish, _) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA);
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
"drv-1", "Plant/Modbus/dev1/speed", 7.0, OpcUaStatus.Good, Ts));
actor.Tell(Burst(("Plant/Modbus/dev1/speed", 7.0)));
var updates = new[]
{
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5)),
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5)),
publish.ExpectMsg<OpcUaPublishActor.AttributeValueUpdate>(TimeSpan.FromSeconds(5)),
};
// 3-wide fan-out inside ONE batch message — the batch path's own 1:N coverage.
var updates = publish.ExpectMsg<OpcUaPublishActor.AttributeValueBatchUpdate>(PresenceBudget).Items;
updates.Count.ShouldBe(3);
updates.Select(u => u.NodeId).ShouldBe(new[]
{
"Plant/Modbus/dev1/speed",
@@ -141,14 +143,13 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
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, publish, mux) = SpawnHostAndApply(db, deploymentId);
var (actor, publish, mux) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA);
actor.Tell(new DriverInstanceActor.AttributeValuePublished(
"drv-1", "Plant/Modbus/dev1/nope", 99.0, OpcUaStatus.Good, Ts));
actor.Tell(Burst(("Plant/Modbus/dev1/nope", 99.0)));
publish.ExpectNoMsg(TimeSpan.FromMilliseconds(500));
mux.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(5))
.FullReference.ShouldBe("Plant/Modbus/dev1/nope");
mux.ExpectMsg<DriverInstanceActor.AttributeValueBatchPublished>(PresenceBudget)
.Items.Single().FullReference.ShouldBe("Plant/Modbus/dev1/nope");
}
// ---- #481 (Layer 4): comms loss reaches the dependency mux ------------------------------------
@@ -172,14 +173,14 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
},
unsRefs: Array.Empty<(string, string, string, string, string?)>());
var (actor, _, mux) = SpawnHostAndApply(db, deploymentId);
var (actor, _, mux) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA);
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)),
mux.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(PresenceBudget),
mux.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(PresenceBudget),
};
pushes.Select(p => p.FullReference).ShouldBe(
@@ -215,7 +216,7 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
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);
var (actor, _, mux) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA);
actor.Tell(new DriverInstanceActor.ConnectivityChanged("drv-1", Connected: true));
@@ -232,110 +233,11 @@ public sealed class DriverHostActorLiveValueTests : RuntimeActorTestBase
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);
var (actor, _, mux) = SpawnHostAndApply(this, db, TestNode, deploymentId, RevA);
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>
private (IActorRef Actor, Akka.TestKit.TestProbe Publish, Akka.TestKit.TestProbe Mux) SpawnHostAndApply(
IDbContextFactory<OtOpcUaConfigDbContext> db, DeploymentId deploymentId)
{
var coordinator = CreateTestProbe();
var publish = CreateTestProbe();
var mux = CreateTestProbe();
var vtHost = CreateTestProbe();
var actor = Sys.ActorOf(DriverHostActor.Props(
db, TestNode, coordinator.Ref,
localRoles: new HashSet<string> { "driver" },
dependencyMux: mux.Ref,
opcUaPublishActor: publish.Ref,
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
virtualTagHostOverride: vtHost.Ref));
actor.Tell(new DispatchDeployment(deploymentId, RevA, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(TimeSpan.FromSeconds(5)).Outcome.ShouldBe(ApplyAckOutcome.Applied);
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(TimeSpan.FromSeconds(5));
return (actor, publish, mux);
}
/// <summary>Seeds a Sealed deployment whose artifact carries the v3 raw-tag chain (RawFolder "Plant" →
/// DriverInstance(RawFolderId) → Device → Tag) plus optional UnsTagReferences projecting a raw tag into an
/// Area/Line/Equipment. Each raw tag's RawPath is <c>Plant/{DriverName}/{Device}/{Tag}</c>; each UNS
/// reference's NodeId is <c>{Area}/{Line}/{Equip}/{Effective ?? backing tag Name}</c>. Enums serialize
/// numerically (AccessLevel: ReadWrite = 1).</summary>
private static DeploymentId SeedV3Deployment(
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
(string Driver, string DriverName, string Device, string Tag, string DataType, bool Writable)[] rawTags,
(string Area, string Line, string Equip, string BackingTag, string? Effective)[] unsRefs)
{
var drivers = rawTags
.Select(t => (t.Driver, t.DriverName))
.Distinct()
.Select(d => new { DriverInstanceId = d.Driver, RawFolderId = "rf-plant", Name = d.DriverName, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = false })
.ToArray();
var devices = rawTags
.Select(t => (t.Driver, t.Device))
.Distinct()
.Select(d => new { DeviceId = $"{d.Driver}:{d.Device}", DriverInstanceId = d.Driver, Name = d.Device, DeviceConfig = "{}" })
.ToArray();
var tags = rawTags.Select(t => new
{
TagId = t.Tag, // tag id == name for test simplicity (unique per driver here)
DeviceId = $"{t.Driver}:{t.Device}",
TagGroupId = (string?)null,
Name = t.Tag,
DataType = t.DataType,
AccessLevel = t.Writable ? 1 : 0, // TagAccessLevel.ReadWrite = 1
TagConfig = "{}",
}).ToArray();
// UNS topology: one area/line per distinct (Area, Line); one equipment per (Area, Line, Equip).
var areas = unsRefs.Select(r => r.Area).Distinct(StringComparer.Ordinal)
.Select(a => new { UnsAreaId = $"a-{a}", Name = a, ClusterId = "c1" }).ToArray();
var lines = unsRefs.Select(r => (r.Area, r.Line)).Distinct()
.Select(l => new { UnsLineId = $"l-{l.Area}-{l.Line}", UnsAreaId = $"a-{l.Area}", Name = l.Line }).ToArray();
var equipment = unsRefs.Select(r => (r.Area, r.Line, r.Equip)).Distinct()
.Select(e => new { EquipmentId = $"EQ-{e.Area}-{e.Line}-{e.Equip}", UnsLineId = $"l-{e.Area}-{e.Line}", Name = e.Equip, MachineCode = e.Equip }).ToArray();
var unsTagReferences = unsRefs.Select((r, i) => new
{
UnsTagReferenceId = $"ref-{i}",
EquipmentId = $"EQ-{r.Area}-{r.Line}-{r.Equip}",
TagId = r.BackingTag,
DisplayNameOverride = r.Effective,
}).ToArray();
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
DriverInstances = drivers,
Devices = devices,
TagGroups = Array.Empty<object>(),
Tags = tags,
UnsAreas = areas,
UnsLines = lines,
Equipment = equipment,
UnsTagReferences = unsTagReferences,
});
var id = DeploymentId.NewId();
using var ctx = db.CreateDbContext();
ctx.Deployments.Add(new Deployment
{
DeploymentId = id.Value,
RevisionHash = rev.Value,
Status = DeploymentStatus.Sealed,
CreatedBy = "test",
SealedAtUtc = DateTime.UtcNow,
ArtifactBlob = artifact,
});
ctx.SaveChanges();
return id;
}
}
@@ -0,0 +1,332 @@
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <summary>
/// Covers the ingress seam of the value-pipeline batching work: <see cref="DriverInstanceActor"/>
/// forwards data changes to its parent as <see cref="DriverInstanceActor.AttributeValueBatchPublished"/>
/// — ONE message per driver production burst.
/// <para>A driver implementing <see cref="IBatchSubscribable"/> gets the batch handler attached and the
/// per-tag <see cref="ISubscribable.OnDataChange"/> handler NOT attached: such a driver raises BOTH
/// events per the contract, so attaching both would double-deliver every value. A per-tag-only
/// <see cref="ISubscribable"/> driver keeps working unchanged — each raise is wrapped as a
/// single-item batch, so the parent sees exactly one message shape either way.</para>
/// <para>Status codes ride verbatim (#519); timestamps come from the driver's snapshot
/// (<c>SourceTimestampUtc ?? ServerTimestampUtc</c>), never from the actor's clock.</para>
/// </summary>
public sealed class DriverInstanceActorBatchForwardTests : RuntimeActorTestBase
{
/// <summary>
/// A batch-capable driver's burst of 3 changed tags reaches the parent as exactly ONE
/// <see cref="DriverInstanceActor.AttributeValueBatchPublished"/> carrying all 3 items, each mapped
/// verbatim (reference / value / status code) with the timestamp derived from the snapshot.
/// </summary>
[Fact]
public async Task Batch_driver_forwards_one_batch_message()
{
var driver = new BatchSubscribableStubDriver();
var parent = CreateTestProbe();
// #477: DriverInstanceActor Tells the parent ConnectivityChanged on each connect/disconnect;
// this suite asserts on the value forwards only, so ignore it.
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.InitializeCount > 0, PresenceBudget);
await actor.Ask<DriverInstanceActor.SubscriptionEstablished>(
new DriverInstanceActor.Subscribe(new[] { "tag-a", "tag-b", "tag-c" }, TimeSpan.FromMilliseconds(100)),
TimeSpan.FromSeconds(3));
var sourceTs = new DateTime(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc);
var serverTs = new DateTime(2026, 8, 15, 10, 0, 5, DateTimeKind.Utc);
driver.FireBatch(includePerTagRaises: false,
new DataChangeItem("tag-a", new DataValueSnapshot(3.14, OpcUaStatus.Good, sourceTs, serverTs)),
new DataChangeItem("tag-b", new DataValueSnapshot(7, 0x80740000u /* BadTypeMismatch */, sourceTs.AddSeconds(1), serverTs)),
// Null source timestamp — the server timestamp is the fallback, per OnDataChangeForward.
new DataChangeItem("tag-c", new DataValueSnapshot(null, OpcUaStatus.BadNoCommunication, null, serverTs)));
var batch = parent.ExpectMsg<DriverInstanceActor.AttributeValueBatchPublished>(TimeSpan.FromSeconds(2));
batch.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
batch.Items.Count.ShouldBe(3);
batch.Items[0].FullReference.ShouldBe("tag-a");
batch.Items[0].Value.ShouldBe(3.14);
batch.Items[0].StatusCode.ShouldBe(OpcUaStatus.Good);
batch.Items[0].TimestampUtc.ShouldBe(sourceTs);
batch.Items[1].FullReference.ShouldBe("tag-b");
batch.Items[1].Value.ShouldBe(7);
// #519: the sub-code rides through, not a collapsed OpcUaStatus.Bad.
batch.Items[1].StatusCode.ShouldBe(0x80740000u);
batch.Items[1].TimestampUtc.ShouldBe(sourceTs.AddSeconds(1));
batch.Items[2].FullReference.ShouldBe("tag-c");
batch.Items[2].Value.ShouldBeNull();
batch.Items[2].StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
batch.Items[2].TimestampUtc.ShouldBe(serverTs, "a null SourceTimestampUtc falls back to the server timestamp");
// Exactly ONE message for the burst — no per-item trailer.
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>
/// An <see cref="IBatchSubscribable"/> driver raises BOTH the batch event and the per-tag
/// <see cref="ISubscribable.OnDataChange"/> for every item (that is the documented driver contract,
/// kept for legacy consumers). The actor must attach ONE handler only, so the parent sees the batch
/// and nothing else — attaching both would publish every value twice.
/// </summary>
[Fact]
public async Task Batch_driver_per_tag_event_not_double_forwarded()
{
var driver = new BatchSubscribableStubDriver();
var parent = CreateTestProbe();
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.InitializeCount > 0, PresenceBudget);
await actor.Ask<DriverInstanceActor.SubscriptionEstablished>(
new DriverInstanceActor.Subscribe(new[] { "tag-a", "tag-b" }, TimeSpan.FromMilliseconds(100)),
TimeSpan.FromSeconds(3));
// Deterministic half of the assertion: the per-tag handler was never attached at all. ExpectNoMsg
// below only proves nothing arrived within its window; this proves it structurally cannot.
driver.OnDataChangeSubscriberCount.ShouldBe(0, "a batch-capable driver must NOT get the per-tag handler");
var ts = new DateTime(2026, 8, 15, 11, 0, 0, DateTimeKind.Utc);
driver.FireBatch(includePerTagRaises: true,
new DataChangeItem("tag-a", new DataValueSnapshot(1, OpcUaStatus.Good, ts, ts)),
new DataChangeItem("tag-b", new DataValueSnapshot(2, OpcUaStatus.Good, ts, ts)));
var batch = parent.ExpectMsg<DriverInstanceActor.AttributeValueBatchPublished>(TimeSpan.FromSeconds(2));
batch.Items.Count.ShouldBe(2);
// The per-tag raises for the SAME burst produced nothing extra — no second batch, no
// AttributeValuePublished. Anything here means both handlers were attached.
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
}
/// <summary>
/// A driver that implements only <see cref="ISubscribable"/> keeps its per-tag handler and each raise
/// arrives at the parent as a single-item batch — the whole fleet minus the batch-capable drivers
/// stays on one message shape.
/// </summary>
[Fact]
public async Task Per_tag_driver_forwards_single_item_batches()
{
var driver = new SubscribableStubDriver();
var parent = CreateTestProbe();
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.InitializeCount > 0, PresenceBudget);
await actor.Ask<DriverInstanceActor.SubscriptionEstablished>(
new DriverInstanceActor.Subscribe(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)),
TimeSpan.FromSeconds(3));
driver.FireDataChange("tag-a", value: 3.14, statusCode: OpcUaStatus.Good);
var batch = parent.ExpectMsg<DriverInstanceActor.AttributeValueBatchPublished>(TimeSpan.FromSeconds(2));
batch.DriverInstanceId.ShouldBe(driver.DriverInstanceId);
batch.Items.Count.ShouldBe(1);
batch.Items[0].FullReference.ShouldBe("tag-a");
batch.Items[0].Value.ShouldBe(3.14);
batch.Items[0].StatusCode.ShouldBe(OpcUaStatus.Good);
}
/// <summary>
/// The detach is load-bearing — the <see cref="IDriver"/> object outlives the actor across respawns,
/// so a batch handler left attached would push into a dead/stale actor. After Unsubscribe, a burst
/// produces nothing at the parent.
/// </summary>
[Fact]
public async Task Detach_removes_batch_handler()
{
var driver = new BatchSubscribableStubDriver();
var parent = CreateTestProbe();
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.InitializeCount > 0, PresenceBudget);
await actor.Ask<DriverInstanceActor.SubscriptionEstablished>(
new DriverInstanceActor.Subscribe(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)),
TimeSpan.FromSeconds(3));
driver.OnDataChangeBatchSubscriberCount.ShouldBe(1, "the batch handler is attached for an IBatchSubscribable driver");
actor.Tell(new DriverInstanceActor.Unsubscribe());
AwaitCondition(() => driver.OnDataChangeBatchSubscriberCount == 0, PresenceBudget);
var ts = new DateTime(2026, 8, 15, 12, 0, 0, DateTimeKind.Utc);
driver.FireBatch(includePerTagRaises: true,
new DataChangeItem("tag-a", new DataValueSnapshot(42, OpcUaStatus.Good, ts, ts)));
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(400));
}
/// <summary>
/// Comms loss (<see cref="DriverInstanceActor.DisconnectObserved"/>, Connected → Reconnecting) detaches
/// the batch handler too. The <see cref="IDriver"/> object outlives the actor across a host respawn, so a
/// handler surviving the disconnect would push a burst produced by the dead transport into a stale actor.
/// </summary>
[Fact]
public async Task DisconnectObserved_detaches_batch_handler_so_late_bursts_are_dropped()
{
var driver = new BatchSubscribableStubDriver();
var parent = CreateTestProbe();
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, reconnectInterval: TimeSpan.FromSeconds(30)));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.InitializeCount > 0, PresenceBudget);
await actor.Ask<DriverInstanceActor.SubscriptionEstablished>(
new DriverInstanceActor.Subscribe(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)),
TimeSpan.FromSeconds(3));
driver.OnDataChangeBatchSubscriberCount.ShouldBe(1);
actor.Tell(new DriverInstanceActor.DisconnectObserved("backend went away"));
// Race window — once the disconnect is processed the handler is gone and a late burst is inert.
AwaitCondition(() => driver.OnDataChangeBatchSubscriberCount == 0, PresenceBudget);
var ts = new DateTime(2026, 8, 15, 13, 0, 0, DateTimeKind.Utc);
driver.FireBatch(includePerTagRaises: true,
new DataChangeItem("tag-a", new DataValueSnapshot(42, OpcUaStatus.Good, ts, ts)));
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
/// <summary>
/// Stopping the actor detaches the batch handler in <c>PostStop</c> — the same leak guard as the
/// per-tag path, and the one that matters when the host respawns a child around the same
/// <see cref="IDriver"/> instance.
/// </summary>
[Fact]
public async Task PostStop_detaches_batch_handler()
{
var driver = new BatchSubscribableStubDriver();
var parent = CreateTestProbe();
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.InitializeCount > 0, PresenceBudget);
await actor.Ask<DriverInstanceActor.SubscriptionEstablished>(
new DriverInstanceActor.Subscribe(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)),
TimeSpan.FromSeconds(3));
driver.OnDataChangeBatchSubscriberCount.ShouldBe(1);
Sys.Stop(actor);
AwaitCondition(() => driver.OnDataChangeBatchSubscriberCount == 0, PresenceBudget);
}
/// <summary>
/// A malformed raise from a buggy driver — a null payload, or one carrying no items — is dropped at
/// the handler and never reaches the mailbox. Unguarded, the null would throw inside the event
/// handler, restart the actor, and take the driver transport down with it; the still-attached handler
/// (count unchanged) and the burst that lands afterwards prove neither happened.
/// </summary>
[Fact]
public async Task Malformed_batch_raise_is_dropped_without_restarting_the_actor()
{
var driver = new BatchSubscribableStubDriver();
var parent = CreateTestProbe();
parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
AwaitCondition(() => driver.InitializeCount > 0, PresenceBudget);
await actor.Ask<DriverInstanceActor.SubscriptionEstablished>(
new DriverInstanceActor.Subscribe(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)),
TimeSpan.FromSeconds(3));
driver.FireBatch(includePerTagRaises: false /* no items — an empty burst */);
driver.FireNullBatch();
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
driver.OnDataChangeBatchSubscriberCount.ShouldBe(1,
"a restart would have detached the handler in PostStop");
// …and the subscription is still live: a well-formed burst still lands.
var ts = new DateTime(2026, 8, 15, 14, 0, 0, DateTimeKind.Utc);
driver.FireBatch(includePerTagRaises: false,
new DataChangeItem("tag-a", new DataValueSnapshot(5, OpcUaStatus.Good, ts, ts)));
var batch = parent.ExpectMsg<DriverInstanceActor.AttributeValueBatchPublished>(TimeSpan.FromSeconds(2));
batch.Items.Count.ShouldBe(1);
batch.Items[0].FullReference.ShouldBe("tag-a");
batch.Items[0].Value.ShouldBe(5);
}
/// <summary>
/// A <see cref="StubDriver"/> that implements <see cref="IBatchSubscribable"/> — it raises
/// <see cref="OnDataChangeBatch"/> once per burst AND (optionally, per the documented driver
/// contract) the per-tag <see cref="OnDataChange"/> for each item of that same burst.
/// </summary>
private sealed class BatchSubscribableStubDriver : StubDriver, IBatchSubscribable
{
private readonly StubHandle _handle = new();
/// <summary>Occurs when a single attribute changes (legacy per-tag contract).</summary>
public event EventHandler<DataChangeEventArgs>? OnDataChange;
/// <summary>Occurs once per production burst with every attribute that changed in it.</summary>
public event EventHandler<DataChangeBatchEventArgs>? OnDataChangeBatch;
/// <summary>Gets the number of handlers attached to <see cref="OnDataChange"/>.</summary>
public int OnDataChangeSubscriberCount => OnDataChange?.GetInvocationList().Length ?? 0;
/// <summary>Gets the number of handlers attached to <see cref="OnDataChangeBatch"/>.</summary>
public int OnDataChangeBatchSubscriberCount => OnDataChangeBatch?.GetInvocationList().Length ?? 0;
/// <summary>Subscribes to the specified full references.</summary>
/// <param name="fullReferences">The full references to subscribe to.</param>
/// <param name="publishingInterval">The publishing interval.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
Task.FromResult<ISubscriptionHandle>(_handle);
/// <summary>Unsubscribes from the specified subscription handle.</summary>
/// <param name="handle">The subscription handle.</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) =>
Task.CompletedTask;
/// <summary>Raises one production burst.</summary>
/// <param name="includePerTagRaises">
/// When true, also raises the per-tag <see cref="OnDataChange"/> for every item — the documented
/// contract a real batch-capable driver honours for legacy consumers.
/// </param>
/// <param name="items">The changed attributes of this burst.</param>
public void FireBatch(bool includePerTagRaises, params DataChangeItem[] items)
{
OnDataChangeBatch?.Invoke(this, new DataChangeBatchEventArgs(_handle, items));
if (!includePerTagRaises) return;
foreach (var item in items)
{
OnDataChange?.Invoke(this, new DataChangeEventArgs(_handle, item.FullReference, item.Snapshot));
}
}
/// <summary>Raises the batch event with a null payload — what a buggy driver does, and what the
/// handler's guard exists to absorb.</summary>
public void FireNullBatch() => OnDataChangeBatch?.Invoke(this, null!);
}
}
@@ -15,7 +15,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
/// <see cref="IAlarmSource.OnAlarmEvent"/> (when the driver is an <see cref="IAlarmSource"/>)
/// and forwards every transition to its parent as
/// <see cref="DriverInstanceActor.AttributeAlarmPublished"/> — mirroring the OnDataChange →
/// <see cref="DriverInstanceActor.AttributeValuePublished"/> forward. The driver fires
/// <see cref="DriverInstanceActor.AttributeValueBatchPublished"/> forward. The driver fires
/// <c>OnAlarmEvent</c> on its OWN thread; the actor marshals it onto the actor thread via Self.
/// </summary>
public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
@@ -63,8 +63,8 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
// The same driver's OnDataChange still flows independently — alarm + value events coexist.
driver.FireDataChange("tag-z", value: 9.0, statusCode: 0u);
parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(2))
.FullReference.ShouldBe("tag-z");
parent.ExpectMsg<DriverInstanceActor.AttributeValueBatchPublished>(TimeSpan.FromSeconds(2))
.Items[0].FullReference.ShouldBe("tag-z");
}
/// <summary>
@@ -131,8 +131,8 @@ public sealed class DriverInstanceActorNativeAlarmTests : RuntimeActorTestBase
driver.FireDataChange("tag-a", value: 1.5, statusCode: 0u);
// Data-change forwarding still works (no crash on AttachAlarmSource for a non-IAlarmSource driver).
var dc = parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(2));
dc.FullReference.ShouldBe("tag-a");
var dc = parent.ExpectMsg<DriverInstanceActor.AttributeValueBatchPublished>(TimeSpan.FromSeconds(2));
dc.Items[0].FullReference.ShouldBe("tag-a");
// An AttributeAlarmPublished can never be produced for a non-IAlarmSource driver.
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300));
}
@@ -121,14 +121,16 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
new DriverInstanceActor.Subscribe(new[] { "tag-a", "tag-b" }, TimeSpan.FromMilliseconds(250)),
TimeSpan.FromSeconds(3));
// Driver fires an OnDataChange — actor should forward it to its parent as
// AttributeValuePublished carrying the driver's status code.
// Driver fires an OnDataChange — actor should forward it to its parent as an
// AttributeValueBatchPublished (single-item, since this driver is per-tag-only) carrying the
// driver's status code.
driver.FireDataChange("tag-a", value: 3.14, statusCode: 0u);
var published = parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(2));
published.FullReference.ShouldBe("tag-a");
published.Value.ShouldBe(3.14);
published.StatusCode.ShouldBe(OpcUaStatus.Good);
var published = parent.ExpectMsg<DriverInstanceActor.AttributeValueBatchPublished>(TimeSpan.FromSeconds(2));
published.Items.Count.ShouldBe(1);
published.Items[0].FullReference.ShouldBe("tag-a");
published.Items[0].Value.ShouldBe(3.14);
published.Items[0].StatusCode.ShouldBe(OpcUaStatus.Good);
}
/// <summary>
@@ -162,17 +164,19 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
// Uncertain — severity bits 01 (top 2 bits = 01).
driver.FireDataChange("tag-1", value: 1, statusCode: 0x40000000u);
parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>().StatusCode.ShouldBe(OpcUaStatus.Uncertain);
parent.ExpectMsg<DriverInstanceActor.AttributeValueBatchPublished>().Items[0].StatusCode
.ShouldBe(OpcUaStatus.Uncertain);
// Bad — severity bits 10.
driver.FireDataChange("tag-1", value: 2, statusCode: 0x80000000u);
parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>().StatusCode.ShouldBe(OpcUaStatus.Bad);
parent.ExpectMsg<DriverInstanceActor.AttributeValueBatchPublished>().Items[0].StatusCode
.ShouldBe(OpcUaStatus.Bad);
// The regression itself: a Bad SUB-code must arrive intact, not flattened to OpcUaStatus.Bad.
// This is the exact code MTConnect maps the Agent's UNAVAILABLE sentinel to, and the live gate
// that opened #519 observed it reaching a client as 0x80000000.
driver.FireDataChange("tag-1", value: null, statusCode: OpcUaStatus.BadNoCommunication);
var noComms = parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>();
var noComms = parent.ExpectMsg<DriverInstanceActor.AttributeValueBatchPublished>().Items[0];
noComms.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
noComms.StatusCode.ShouldNotBe(OpcUaStatus.Bad, "the sub-code was collapsed to a generic Bad");
OpcUaStatus.IsBad(noComms.StatusCode).ShouldBeTrue("it must still classify as Bad");
@@ -180,7 +184,8 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
// A second, DIFFERENT Bad sub-code — the two were indistinguishable before the fix, so asserting
// one alone would not prove the sub-code is carried rather than hard-coded.
driver.FireDataChange("tag-1", value: null, statusCode: 0x80740000u /* BadTypeMismatch */);
parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>().StatusCode.ShouldBe(0x80740000u);
parent.ExpectMsg<DriverInstanceActor.AttributeValueBatchPublished>().Items[0].StatusCode
.ShouldBe(0x80740000u);
}
/// <summary>
@@ -209,7 +214,8 @@ public sealed class DriverInstanceActorTests : RuntimeActorTestBase
// The auto-subscription is live — a data change reaches the parent.
driver.FireDataChange("tag-a", value: 7, statusCode: 0u);
parent.ExpectMsg<DriverInstanceActor.AttributeValuePublished>(TimeSpan.FromSeconds(2)).Value.ShouldBe(7);
parent.ExpectMsg<DriverInstanceActor.AttributeValueBatchPublished>(TimeSpan.FromSeconds(2))
.Items[0].Value.ShouldBe(7);
// Reconnect → the desired set is re-established without any new host message.
actor.Tell(new DriverInstanceActor.DisconnectObserved("backend blip"));
@@ -27,8 +27,11 @@ public abstract class RuntimeActorTestBase : TestKit
/// idle machine are comfortable in isolation and marginal under that contention: three consecutive
/// 30-run verification rounds each surfaced a <em>different</em> test failing on one, in three
/// different files. Fixing them one at a time was chasing a distribution rather than a defect.</para>
/// <para>Public rather than protected so Harness helpers that are not themselves
/// <see cref="TestKit"/> subclasses — <see cref="V3ArtifactSeeder"/> — budget their waits from the
/// same constant instead of re-deriving one.</para>
/// </remarks>
protected static readonly TimeSpan PresenceBudget = TimeSpan.FromSeconds(15);
public static readonly TimeSpan PresenceBudget = TimeSpan.FromSeconds(15);
/// <summary>Gets the Akka test HOCON configuration string for single-node cluster setup.</summary>
protected static string AkkaTestHocon => @"
@@ -0,0 +1,173 @@
using System.Text.Json;
using Akka.Actor;
using Akka.TestKit;
using Microsoft.EntityFrameworkCore;
using Shouldly;
using ZB.MOM.WW.OtOpcUa.Commons.Engines;
using ZB.MOM.WW.OtOpcUa.Commons.Messages.Deploy;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
/// <summary>
/// Shared v3 deployment fixture for the <see cref="DriverHostActor"/> value-routing suites: seeds a
/// Sealed artifact carrying the raw-tag chain (+ optional UNS references) and spawns a host that has
/// actually applied it, so <c>_nodeIdByDriverRef</c> is populated before the test publishes anything.
/// <para>
/// Kept here rather than in either test class because the per-value and batched fan-out suites
/// assert different deliveries over the SAME arrangement — duplicating the seeder let the two
/// copies drift. Deliberately narrow: the assembly's other artifact fixtures seed their own
/// shapes and are not folded in.
/// </para>
/// </summary>
public static class V3ArtifactSeeder
{
/// <summary>Seeds a Sealed deployment whose artifact carries the v3 raw-tag chain (RawFolder "Plant" →
/// DriverInstance(RawFolderId) → Device → Tag) plus optional UnsTagReferences projecting a raw tag into an
/// Area/Line/Equipment. Each raw tag's RawPath is <c>Plant/{DriverName}/{Device}/{Tag}</c>; each UNS
/// reference's NodeId is <c>{Area}/{Line}/{Equip}/{Effective ?? backing tag Name}</c>. Enums serialize
/// numerically (AccessLevel: ReadWrite = 1).</summary>
/// <param name="db">The in-memory config-db factory to seed.</param>
/// <param name="rev">The revision hash the deployment is sealed under.</param>
/// <param name="rawTags">The raw tags to materialise, one per (driver, device, tag).</param>
/// <param name="unsRefs">UNS references projecting a raw tag (by <c>BackingTag</c>) into an equipment.</param>
/// <returns>The seeded deployment's id.</returns>
public static DeploymentId SeedV3Deployment(
IDbContextFactory<OtOpcUaConfigDbContext> db, RevisionHash rev,
(string Driver, string DriverName, string Device, string Tag, string DataType, bool Writable)[] rawTags,
(string Area, string Line, string Equip, string BackingTag, string? Effective)[] unsRefs)
{
var drivers = rawTags
.Select(t => (t.Driver, t.DriverName))
.Distinct()
.Select(d => new { DriverInstanceId = d.Driver, RawFolderId = "rf-plant", Name = d.DriverName, DriverType = "Modbus", DriverConfig = "{}", ClusterId = "c1", Enabled = false })
.ToArray();
var devices = rawTags
.Select(t => (t.Driver, t.Device))
.Distinct()
.Select(d => new { DeviceId = $"{d.Driver}:{d.Device}", DriverInstanceId = d.Driver, Name = d.Device, DeviceConfig = "{}" })
.ToArray();
var tags = rawTags.Select(t => new
{
TagId = t.Tag, // tag id == name for test simplicity (unique per driver here)
DeviceId = $"{t.Driver}:{t.Device}",
TagGroupId = (string?)null,
Name = t.Tag,
DataType = t.DataType,
AccessLevel = t.Writable ? 1 : 0, // TagAccessLevel.ReadWrite = 1
TagConfig = "{}",
}).ToArray();
// UNS topology: one area/line per distinct (Area, Line); one equipment per (Area, Line, Equip).
var areas = unsRefs.Select(r => r.Area).Distinct(StringComparer.Ordinal)
.Select(a => new { UnsAreaId = $"a-{a}", Name = a, ClusterId = "c1" }).ToArray();
var lines = unsRefs.Select(r => (r.Area, r.Line)).Distinct()
.Select(l => new { UnsLineId = $"l-{l.Area}-{l.Line}", UnsAreaId = $"a-{l.Area}", Name = l.Line }).ToArray();
var equipment = unsRefs.Select(r => (r.Area, r.Line, r.Equip)).Distinct()
.Select(e => new { EquipmentId = $"EQ-{e.Area}-{e.Line}-{e.Equip}", UnsLineId = $"l-{e.Area}-{e.Line}", Name = e.Equip, MachineCode = e.Equip }).ToArray();
var unsTagReferences = unsRefs.Select((r, i) => new
{
UnsTagReferenceId = $"ref-{i}",
EquipmentId = $"EQ-{r.Area}-{r.Line}-{r.Equip}",
TagId = r.BackingTag,
DisplayNameOverride = r.Effective,
}).ToArray();
var artifact = JsonSerializer.SerializeToUtf8Bytes(new
{
RawFolders = new[] { new { RawFolderId = "rf-plant", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
DriverInstances = drivers,
Devices = devices,
TagGroups = Array.Empty<object>(),
Tags = tags,
UnsAreas = areas,
UnsLines = lines,
Equipment = equipment,
UnsTagReferences = unsTagReferences,
});
var id = DeploymentId.NewId();
using var ctx = db.CreateDbContext();
ctx.Deployments.Add(new Deployment
{
DeploymentId = id.Value,
RevisionHash = rev.Value,
Status = DeploymentStatus.Sealed,
CreatedBy = "test",
SealedAtUtc = DateTime.UtcNow,
ArtifactBlob = artifact,
});
ctx.SaveChanges();
return id;
}
/// <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>
/// <param name="kit">The calling TestKit, supplying the ActorSystem + probes.</param>
/// <param name="db">The factory whose deployment was seeded by <see cref="SeedV3Deployment"/>.</param>
/// <param name="localNode">The host's local node id.</param>
/// <param name="deploymentId">The deployment to dispatch.</param>
/// <param name="rev">The revision hash the deployment was sealed under.</param>
/// <returns>The host actor plus the publish and mux probes wired into it.</returns>
public static (IActorRef Actor, TestProbe Publish, TestProbe Mux) SpawnHostAndApply(
TestKitBase kit, IDbContextFactory<OtOpcUaConfigDbContext> db,
NodeId localNode, DeploymentId deploymentId, RevisionHash rev)
{
var publish = kit.CreateTestProbe();
var (actor, mux, coordinator) = Spawn(kit, db, localNode, publish.Ref);
Dispatch(actor, coordinator, deploymentId, rev);
publish.ExpectMsg<OpcUaPublishActor.RebuildAddressSpace>(RuntimeActorTestBase.PresenceBudget);
return (actor, publish, mux);
}
/// <summary>The same arrangement with NO OPC UA publish actor wired (the ctor default) — the dev/Mac
/// shape, where the mux hop must still happen. Returns only the mux probe, since there is no sink.</summary>
/// <param name="kit">The calling TestKit, supplying the ActorSystem + probes.</param>
/// <param name="db">The factory whose deployment was seeded by <see cref="SeedV3Deployment"/>.</param>
/// <param name="localNode">The host's local node id.</param>
/// <param name="deploymentId">The deployment to dispatch.</param>
/// <param name="rev">The revision hash the deployment was sealed under.</param>
/// <returns>The host actor plus the mux probe wired into it.</returns>
public static (IActorRef Actor, TestProbe Mux) SpawnHostAndApplyWithoutPublishActor(
TestKitBase kit, IDbContextFactory<OtOpcUaConfigDbContext> db,
NodeId localNode, DeploymentId deploymentId, RevisionHash rev)
{
var (actor, mux, coordinator) = Spawn(kit, db, localNode, opcUaPublishActor: null);
Dispatch(actor, coordinator, deploymentId, rev);
return (actor, mux);
}
private static (IActorRef Actor, TestProbe Mux, TestProbe Coordinator) Spawn(
TestKitBase kit, IDbContextFactory<OtOpcUaConfigDbContext> db, NodeId localNode,
IActorRef? opcUaPublishActor)
{
var coordinator = kit.CreateTestProbe();
var mux = kit.CreateTestProbe();
var vtHost = kit.CreateTestProbe();
var actor = kit.Sys.ActorOf(DriverHostActor.Props(
db, localNode, coordinator.Ref,
localRoles: new HashSet<string> { "driver" },
dependencyMux: mux.Ref,
opcUaPublishActor: opcUaPublishActor,
virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
virtualTagHostOverride: vtHost.Ref));
return (actor, mux, coordinator);
}
private static void Dispatch(IActorRef actor, TestProbe coordinator, DeploymentId deploymentId, RevisionHash rev)
{
actor.Tell(new DispatchDeployment(deploymentId, rev, CorrelationId.NewId()));
coordinator.ExpectMsg<ApplyAck>(RuntimeActorTestBase.PresenceBudget)
.Outcome.ShouldBe(ApplyAckOutcome.Applied);
}
}
@@ -0,0 +1,225 @@
using Akka.Actor;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa;
using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa;
/// <summary>
/// Task 7 of the value-pipeline batching work: <see cref="OpcUaPublishActor.AttributeValueBatchUpdate"/>
/// must reach the sink as exactly ONE <see cref="IOpcUaAddressSpaceSink.WriteValues"/> call — that single
/// call is the whole point (the SDK-backed sink takes its lock once per batch instead of once per value),
/// so a handler that looped <c>WriteValue</c> would pass a "the values arrived" assertion while delivering
/// none of the win. Hence the fake counts per-item <c>WriteValue</c> calls separately and the assertions
/// require that count to be zero.
/// <para>The per-item <see cref="OpcUaPublishActor.AttributeValueUpdate"/> receive stays — VirtualTagHostActor
/// and the alarm paths still send it — so it is pinned here too.</para>
/// </summary>
public sealed class OpcUaPublishActorBatchTests : RuntimeActorTestBase
{
/// <summary>A 3-item batch lands as one WriteValues call carrying all three items verbatim (field-exact,
/// realm included), and never decomposes into per-item WriteValue calls.</summary>
[Fact]
public void Batch_message_reaches_sink_as_one_WriteValues_call()
{
var sink = new RecordingSink();
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
var t0 = new DateTime(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc);
var items = new[]
{
new ValueWrite("Plant/Modbus/dev1/Speed", 42, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw),
new ValueWrite("AreaA/Line1/Eq1/Speed", 42, OpcUaStatus.Good, t0, AddressSpaceRealm.Uns),
new ValueWrite("Plant/Modbus/dev1/Temp", 7.5d, OpcUaStatus.BadNoCommunication,
t0.AddSeconds(1), AddressSpaceRealm.Raw),
};
actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate(items));
AwaitAssert(() => sink.Batches.Count.ShouldBe(1), PresenceBudget);
var batch = sink.Batches[0];
batch.Count.ShouldBe(3);
batch[0].ShouldBe(items[0]);
batch[1].ShouldBe(items[1]);
batch[2].ShouldBe(items[2]);
// The load-bearing half: one batched call, NOT three per-item calls.
sink.SingleWrites.Count.ShouldBe(0);
}
/// <summary>A sink that throws out of WriteValues is swallowed and logged — the actor survives and keeps
/// processing, rather than restarting and dropping traffic.
/// <para>The survival assertion alone does NOT prove the catch exists: Akka's default supervision
/// RESTARTS a throwing actor and the restart preserves the mailbox, so a follow-up message lands either
/// way. The load-bearing assertions are therefore the two event filters — the exception must never reach
/// supervision (no Error carrying it is ever logged), and the handler's own Warning must be logged so the
/// fault is swallowed but not silent. Deleting the catch turns BOTH red.</para></summary>
[Fact]
public void Sink_throw_is_swallowed_and_logged()
{
var sink = new ThrowOnWriteValuesSink();
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
var t0 = new DateTime(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc);
// Expect(0, …) counts matches at the END of the action, so everything that must have happened by
// then goes INSIDE it — otherwise the absence assertion races the actor and passes vacuously.
EventFilter.Exception<InvalidOperationException>().Expect(0, () =>
{
// The handler caught it AND said so: its own Warning, not supervision's Error.
EventFilter.Warning(contains: "sink.WriteValues threw").ExpectOne(() =>
actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate(new[]
{
new ValueWrite("Plant/Modbus/dev1/Speed", 1, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw),
})));
// Survival: a follow-up on the untouched per-item path lands only if the actor is still pumping.
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
"Plant/Modbus/dev1/Temp", 2, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw));
AwaitAssert(() => sink.SingleWrites.Count.ShouldBe(1), PresenceBudget);
});
sink.Threw.ShouldBeTrue();
}
/// <summary>A PARTIAL failure is metered honestly: a sink that applies 2 of 3 items and reports the
/// aggregate as a <see cref="ValueWriteBatchException"/> must advance the write tally by the SUCCEEDED
/// count, not by zero (the general catch's behaviour) and not by the whole batch. The invariant the
/// batching work must not break is that the tally counts per ITEM — so a batch that half-failed reports
/// exactly the half that landed.</summary>
[Fact]
public void Partial_failure_still_counts_the_items_that_landed()
{
var sink = new PartialFailureSink();
// TestActorRef so the actor's own WriteCount tally is readable — the counter the handler advances is
// the thing under test, and nothing else observes it.
var actor = ActorOfAsTestActorRef<OpcUaPublishActor>(OpcUaPublishActor.PropsForTests(sink: sink));
var t0 = new DateTime(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc);
actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate(new[]
{
new ValueWrite("Plant/Modbus/dev1/Speed", 1, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw),
new ValueWrite("", 2, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw),
new ValueWrite("Plant/Modbus/dev1/Temp", 3, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw),
}));
sink.Threw.ShouldBeTrue();
actor.UnderlyingActor.WriteCount.ShouldBe(2,
"the two items the sink DID apply must still be counted — not 0 (the general catch) and not 3");
// The actor survived the partial failure and keeps pumping.
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
"Plant/Modbus/dev1/Other", 4, OpcUaStatus.Good, t0, AddressSpaceRealm.Raw));
sink.SingleWrites.Count.ShouldBe(1);
actor.UnderlyingActor.WriteCount.ShouldBe(3);
}
/// <summary>The pre-existing single-item AttributeValueUpdate path is untouched by the batch addition.</summary>
[Fact]
public void Single_item_message_still_works()
{
var sink = new RecordingSink();
var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink));
var t0 = new DateTime(2026, 8, 15, 10, 0, 0, DateTimeKind.Utc);
actor.Tell(new OpcUaPublishActor.AttributeValueUpdate(
"AreaA/Line1/Eq1/Speed", 99, OpcUaStatus.Good, t0, AddressSpaceRealm.Uns));
AwaitAssert(() => sink.SingleWrites.Count.ShouldBe(1), PresenceBudget);
sink.SingleWrites[0].ShouldBe(new ValueWrite("AreaA/Line1/Eq1/Speed", 99, OpcUaStatus.Good, t0, AddressSpaceRealm.Uns));
sink.Batches.Count.ShouldBe(0);
}
private void AwaitAssert(Action assertion, TimeSpan duration)
{
var deadline = DateTime.UtcNow + duration;
Exception? last = null;
while (DateTime.UtcNow < deadline)
{
try { assertion(); return; }
catch (Exception ex) { last = ex; Thread.Sleep(25); }
}
if (last is not null) throw last;
}
/// <summary>Records batched and per-item value writes separately, so a handler that looped WriteValue
/// instead of calling WriteValues once is distinguishable from one that batched.</summary>
private class RecordingSink : IOpcUaAddressSpaceSink
{
private readonly object _gate = new();
private readonly List<IReadOnlyList<ValueWrite>> _batches = new();
private readonly List<ValueWrite> _singles = new();
public IReadOnlyList<IReadOnlyList<ValueWrite>> Batches { get { lock (_gate) return _batches.ToArray(); } }
public IReadOnlyList<ValueWrite> SingleWrites { get { lock (_gate) return _singles.ToArray(); } }
public void WriteValue(string nodeId, object? value, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns)
{
lock (_gate) _singles.Add(new ValueWrite(nodeId, value, statusCode, sourceTimestampUtc, realm));
}
// Overrides the interface's default WriteValues so the batch is observed as one call, not N.
public virtual void WriteValues(IReadOnlyList<ValueWrite> values)
{
lock (_gate) _batches.Add(values.ToArray());
}
public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime sourceTimestampUtc, AddressSpaceRealm realm) { }
public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime occurredUtc, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { }
public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null) { }
public void RebuildAddressSpace() { }
public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) { }
public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList<string> notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { }
public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { }
}
/// <summary>A sink whose batched write throws a PLAIN exception — drives the handler's GENERAL catch (an
/// unrecognised sink failure, where nothing is known about what landed, so nothing is counted).</summary>
private sealed class ThrowOnWriteValuesSink : RecordingSink
{
private int _threw;
public bool Threw => Volatile.Read(ref _threw) != 0;
public override void WriteValues(IReadOnlyList<ValueWrite> values)
{
Interlocked.Exchange(ref _threw, 1);
throw new InvalidOperationException("simulated batched-write fault");
}
}
/// <summary>A sink modelling the real SDK-backed one's PARTIAL failure: it applies every item with a
/// non-empty NodeId, then reports the aggregate as a <see cref="ValueWriteBatchException"/>. Drives the
/// handler's typed catch.</summary>
private sealed class PartialFailureSink : RecordingSink
{
private int _threw;
public bool Threw => Volatile.Read(ref _threw) != 0;
public override void WriteValues(IReadOnlyList<ValueWrite> values)
{
var applied = values.Where(v => !string.IsNullOrEmpty(v.NodeId)).ToArray();
var failedCount = values.Count - applied.Length;
if (failedCount == 0)
{
base.WriteValues(values);
return;
}
base.WriteValues(applied);
Interlocked.Exchange(ref _threw, 1);
throw new ValueWriteBatchException(
firstFailedNodeId: values.First(v => string.IsNullOrEmpty(v.NodeId)).NodeId,
failedCount: failedCount,
succeededCount: applied.Length,
firstFailure: new ArgumentException("empty NodeId"));
}
}
}
@@ -124,13 +124,14 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
/// <summary>Verifies that the DriverHostActor forwards AttributeValuePublished messages through the dependency mux.</summary>
/// <summary>Verifies that the DriverHostActor forwards a driver burst through the dependency mux to the
/// interested subscriber.</summary>
[Fact]
public void DriverHostActor_forwards_AttributeValuePublished_through_to_mux()
public void DriverHostActor_forwards_AttributeValueBatchPublished_through_to_mux()
{
// Spin a mux + a stand-in for DriverHostActor that wraps the real DriverHostActor's
// forward path. We feed it AttributeValuePublished directly to verify the routing —
// exercising the actual DriverHost spawn would require a deployment artifact + DI.
// Spin a mux + a real DriverHostActor and feed it the batch a driver child would send —
// exercising a full DriverHost apply would require a deployment artifact + DI, and the mux hop
// does not depend on one (it precedes the NodeId resolution).
var mux = Sys.ActorOf(DependencyMuxActor.Props());
var subscriber = CreateTestProbe();
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "ref-1" }, subscriber.Ref));
@@ -142,13 +143,44 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase
ackRouter: hostProbe.Ref,
dependencyMux: mux));
// Tell the host an AttributeValuePublished — it should fan out to the mux + subscriber.
hostActor.Tell(new DriverInstanceActor.AttributeValuePublished(
"driver-1", "ref-1", 42, OpcUaStatus.Good, DateTime.UtcNow));
hostActor.Tell(new DriverInstanceActor.AttributeValueBatchPublished("driver-1", new[]
{
new DriverInstanceActor.AttributeValueItem("ref-1", 42, OpcUaStatus.Good, DateTime.UtcNow),
}));
subscriber.ExpectMsg<VirtualTagActor.DependencyValueChanged>().TagId.ShouldBe("ref-1");
}
/// <summary>A batched driver burst (the primary ingress from DriverHostActor since the value-pipeline
/// batching work) is filtered per item: a subscriber hears exactly one DependencyValueChanged for the
/// ref it registered, carrying that item's own value/timestamp/quality, and nothing for the rest of the
/// burst.</summary>
[Fact]
public void AttributeValueBatchPublished_is_filtered_per_item_to_interested_subscribers()
{
var mux = Sys.ActorOf(DependencyMuxActor.Props());
var sub = CreateTestProbe();
mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "tag-x" }, sub.Ref));
var tsX = new DateTime(2026, 8, 15, 9, 0, 0, DateTimeKind.Utc);
var tsY = new DateTime(2026, 8, 15, 9, 0, 1, DateTimeKind.Utc);
mux.Tell(new DriverInstanceActor.AttributeValueBatchPublished("driver-1", new[]
{
new DriverInstanceActor.AttributeValueItem("tag-y", 99, OpcUaStatus.Good, tsY),
new DriverInstanceActor.AttributeValueItem("tag-x", 10, OpcUaStatus.BadNoCommunication, tsX),
}));
var dep = sub.ExpectMsg<VirtualTagActor.DependencyValueChanged>(PresenceBudget);
dep.TagId.ShouldBe("tag-x");
dep.Value.ShouldBe(10);
dep.TimestampUtc.ShouldBe(tsX);
// #519: the item's own status sub-code rides through, not a Good/Bad bucket.
dep.StatusCode.ShouldBe(OpcUaStatus.BadNoCommunication);
// Nothing for tag-y — no subscriber registered for it.
sub.ExpectNoMsg(TimeSpan.FromMilliseconds(200));
}
/// <summary>#478 — the mux forwards the published source quality onto DependencyValueChanged so the
/// scripted-alarm host can derive a condition's worst-of-input quality.</summary>
[Fact]