mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-19 07:02:16 +08:00
8a10139b8c
# Description
A live OPC UA compliance audit of `samples/PumpDeviceIntegrationServer`
against the **OPC 40223 Pumps** and **OPC 10000-100 (DI)** companion
specifications found **13 address-space violations** plus a
serialization bug in the MCP tool. Every fix lands at its **root cause
in the shared stack** rather than as a workaround in the sample, so all
servers benefit.
The sample now exposes **N fully simulated pumps** (`--pumps N`) and
advertises the DI conformance facets it actually satisfies.
> This branch has been merged with `master` including **#4116** ("Add
simulation to PumpDeviceIntegrationServer and Event modelling"), which
independently reworked the same pump simulation and the same fluent
alarm builders. See [Relationship to #4116](#relationship-to-4116) below
for how the two were reconciled.
## Findings and fixes
| # | Finding | Root cause | Fix |
|---|---------|-----------|-----|
| 1 | `OverTempAlarm` created by the fluent API did not exist in the
address space | `AttachAlarm` attached the alarm with `AddChild` but
never indexed it into the node manager | Register the node; the same gap
existed in `AddObject`, `CreateInstance` and the state-machine creators
|
| 2 | Alarms were unsubscribable — `EventNotifier = 0` everywhere, no
`HasNotifier` from `Server` | No event-source wiring at all | Set
`HasCondition`, initialise
`SourceNode`/`SourceName`/`ConditionName`/`InputNode`, promote
`EventNotifier` up the ancestor chain, publish `HasNotifier` from the
`Server` object |
| 3 | Every instance NodeId was minted in the **DI standard namespace**
(`ns=4;s=5001_Pump #1`) | `DiNodeManager.New` inherited the parent's
namespace index | New `DiNodeManager.InstanceNamespaceIndex`; instances
move to the server's application namespace |
| 4 | `Server/Namespaces` described only 2 of 6 namespaces | The
NodeSet2 importer drops nodes parented to namespace 0, so companion-spec
metadata objects never reached the address space — and no route covered
the server's own namespace | New `NamespaceMetadataPublisher` walks the
`NamespaceArray` and fills version/publication date from
`ModelDependencyAttribute` |
| 5 | Vendor BrowseNames (`Diagnostics`, `LastError`, …) sat in the DI
namespace | Builders defaulted to the parent's namespace | Default to
the server namespace; **spec-defined BrowseNames are unchanged** |
| 6 | `AccessLevel="5"` from the Pumps NodeSet was reported as `1` | The
ModelDesign `AccessLevel` enum is not `[Flags]` and cannot represent
`CurrentRead \| HistoryRead` | Carry the verbatim NodeSet2 bitmask and
emit named `AccessLevels` constants |
| 7 | `Historizing = true` while `HistoryRead` returned
`BadHistoryOperationUnsupported` | Nothing reconciled the declared
history surface with the wired historians | Startup clears the
advertisement (including masking the attribute read callbacks) when no
provider resolves; the sample wires a real historian |
| 8 | Mandatory `ProductInstanceUri` unset | Sample omission | Populated
on every pump |
| 9 | Mandatory `TrueState`/`FalseState` empty on every supervision
boolean | Absent in the official NodeSet; a server must still populate
them | Populated on every pump |
| 10 | Machinery `Machines` folder empty (OPC 40001-1 §9.2 says
**shall**) | `TryAddToMachinesFolder` used `AddChild`, which
*re-parents* the device away from `DeviceSet` | Add the `Organizes`
reference instead |
| 11 | BrowseName contained a space and `#` | Sample naming | `Pump_1`
with the readable label in `DisplayName` |
| 12 | `Maintenance` group materialised but empty | Sample omission |
Populated |
| 13 | Uninitialised values reported `Good` | No initial-value status |
`BadWaitingForInitialData` until the first simulation tick |
| — | `ServerProfileArray` advertised only `StandardUA2017` |
`DiNodeManager` did not implement the existing `IConformanceContributor`
| Contribute DI 1.05 server facets computed at runtime from what is
actually wired, merged with `StandardUA2017` |
| — | MCP tool serialised boolean `false` and numeric `0` as JSON `null`
| `Variant.Null` is `default`, so `Variant.Equals` adopts the non-null
type and compares against the default value | Dispatch on `BuiltInType`
and use typed `TryGetValue` accessors instead of the prohibited
`AsBoxedObject` |
## Relationship to #4116
#4116 landed on `master` while this branch was in flight and targeted
overlapping ground. The merge is a **union of both intents**, not a
fast-forward:
- **`AlarmBuilderExtensions`** — kept both sides. #4116 contributes the
parent-must-be-an-Object guard, `SetEnableState`, and the
`HasEventSource` references; this branch contributes the `HasCondition`
reference, condition source initialisation, **registration of the alarm
with the node manager so it is browsable**, notifier promotion up the
whole ancestor chain, and root-notifier registration.
- **`SupervisionBuilderExtensions`** — took #4116's `SetAlarmActive`. It
is more spec-correct than this branch's version: it honours
`EnabledState`, resets Acked/Confirmed on activation, and computes
`Retain` per OPC 10000-9 so an unacknowledged Condition stays retained
after going inactive. This branch's null guard was kept so a malformed
alarm cannot dereference a missing `EnabledState`.
- **`PumpNodeManager.Configure.cs`** — took #4116's **push-based**
simulation (`RegisterPumpSimulation`, `CreatePumpSimulation`,
`IValueUpdater<T>`, `Initialize`/`Advance`/`Publish`, deterministic
per-pump phase offsets) as the structure, then folded in this branch's
compliance work: configurable pump count, Maintenance group,
`TrueState`/`FalseState`, historian wiring, per-pump mandatory
Identification values, and `BadWaitingForInitialData` until the first
tick.
- **README** — union, keeping #4116's address-space validation workflow
section.
Two test adjustments were needed, both because the merge legitimately
changed behaviour the tests pinned:
- `PumpHostedReferenceTests` now resolves pump NodeIds from the browse
result instead of hard-coding the DI namespace, and waits for the first
published value. `PumpCreatedAfterStartupJoinsTheLiveSimulationAsync`
was **added** to restore #4116's coverage that a pump created after
startup through `ConfigureDevicesFor` joins the live simulation.
- `FluentAlarmRegistrationIntegrationTests` no longer asserts that
`Retain` clears when an alarm goes inactive — that pinned this branch's
weaker semantics, and #4116's Part 9 behaviour is correct.
## Breaking change
Runtime-created DI device instance NodeIds move from the companion-spec
namespace to the server's application namespace, e.g.
`ns=<DI>;s=5001_Pump #1` becomes `ns=<application>;s=5001_Pump #1`. This
invalidates NodeIds cached by clients from earlier 2.0 previews. It is
**not** a model change — standard DI/Machinery/Pumps type NodeIds and
spec-defined BrowseNames are untouched.
Device integration hosting is new in 2.0, so there is nothing to migrate
from 1.5.378 and this is deliberately *not* in `docs/MigrationGuide.md`.
Anyone tracking 2.0 previews should rediscover devices by browsing
`DeviceSet` rather than persisting NodeIds, and resolve namespace
indexes from `NamespaceArray` per connection.
## Verification
Compliance is pinned by regression tests that assert **through the
server's service surface** (`BrowseAsync` / `ReadAsync` /
`HistoryReadAsync`), not by inspecting `NodeState` objects in memory.
That distinction mattered: three defects survived in-memory assertions
and were only caught through the read path — most subtly, `Historizing`
was cleared while `AccessLevel` still advertised `HistoryRead`, because
attribute read callbacks re-applied the NodeSet bits.
Post-merge on net10.0:
| Suite | Result |
|---|---|
| `Opc.Ua.Di.Tests` | 326 passed |
| `Opc.Ua.Server.Tests` | 3846 passed, 5 skipped |
| `Opc.Ua.Tools.Tests` | 288 passed |
| `Opc.Ua.History.Tests` | 506 passed, 27 skipped |
| `Opc.Ua.SourceGeneration.Core.Tests` | 3722 passed, 8 skipped |
The full solution was also built and tested on **net48** before the
merge.
Review feedback from `copilot-pull-request-reviewer` has been addressed
in commit `badad31dc`: the shared `ServerObjectState` lock was removed
(the forward `HasNotifier` edge is published through the owning node
manager instead), MCP array serialization now preserves element types,
and the pump option guards report the specific rejected option. The only
failure is
`ConfigureApplicationBuildsSharedClientAndServerConfigurationAsync`, a
pre-existing certificate-store issue that **passes when run in
isolation** and is unrelated to this change.
## Documentation
Updated `docs/DeviceIntegration.md`, `docs/MigrationGuide.md`,
`docs/HistoricalAccess.md`, `docs/DeveloperGuide.md`,
`docs/McpServer.md`, `docs/README.md`,
`samples/PumpDeviceIntegrationServer/README.md` and
`tools/Opc.Ua.Mcp/README.md`, and consolidated the four node-manager
documents into a single [`docs/NodeManagers.md`](docs/NodeManagers.md) —
overview, built-in managers (including `MasterNodeManager`), core vs
custom, registration, server address-space metadata, and source
generation — with a TOC and all inbound links repointed.
## Related Issues
No tracking issue exists for this work yet — it originated from an
ad-hoc compliance audit of the pump sample. Given the size of the change
and the runtime-breaking NodeId move, please open (or link) a tracking
issue so the design is recorded as an ADR before merging.
- Fixes #
## Checklist
_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._
- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [x] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
---------
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 0d50c3fc-58b1-4c99-85a4-1e32c88666d9
556 lines
22 KiB
Markdown
556 lines
22 KiB
Markdown
# Model Change Tracking
|
|
|
|
OPC UA Part 5 §6.4.32 defines `GeneralModelChangeEventType` so a
|
|
server can notify clients that the address space has changed —
|
|
nodes added or deleted, references added or deleted, datatypes
|
|
changed. The `Opc.Ua.Client.ModelChange` namespace ships an
|
|
opt-in client-side tracker that consumes those events, drops the
|
|
affected entries from the client's `INodeCache`, and surfaces the
|
|
changes through a strongly-typed event so application code can
|
|
re-browse the impacted subtrees.
|
|
|
|
The server-side counterpart lives in `Opc.Ua.Server.Alarms`:
|
|
`ModelChangeAggregator` batches per-call changes and the
|
|
`CustomNodeManager` (and `AsyncCustomNodeManager`) emit
|
|
`GeneralModelChangeEvent` automatically from `CreateNode` /
|
|
`DeleteNode`. Opt out via a flag if you need manual control.
|
|
|
|
- [Quick reference](#quick-reference)
|
|
- [Server side: emitting model changes](#server-side-emitting-model-changes)
|
|
- [Auto-emit from `CreateNode` and `DeleteNode`](#auto-emit-from-createnode-and-deletenode)
|
|
- [Manual aggregation across a transaction](#manual-aggregation-across-a-transaction)
|
|
- [Semantic change events](#semantic-change-events)
|
|
- [Client side: tracking model changes](#client-side-tracking-model-changes)
|
|
- [Enabling tracking on a `ManagedSession`](#enabling-tracking-on-a-managedsession)
|
|
- [Manual construction](#manual-construction)
|
|
- [Reacting to changes](#reacting-to-changes)
|
|
- [Namespace table refresh](#namespace-table-refresh)
|
|
- [Per-node `NodeCache` invalidation](#per-node-nodecache-invalidation)
|
|
- [NodeVersion correlation](#nodeversion-correlation)
|
|
- [Security considerations](#security-considerations)
|
|
- [Reference](#reference)
|
|
|
|
## Quick reference
|
|
|
|
| Concern | Server entry point | Client entry point |
|
|
|---|---|---|
|
|
| Emit a model change | `CustomNodeManager.RaiseGeneralModelChangeEvent(...)` (auto from `CreateNode`/`DeleteNode`) | n/a |
|
|
| Aggregate before emit | `ModelChangeAggregator.RecordNodeAdded/Deleted/...` | n/a |
|
|
| Opt out of auto-emit | `node manager.ModelChangeEmissionEnabled = false` | n/a |
|
|
| Subscribe to changes | n/a | `ManagedSession.EnableModelChangeTrackingAsync()` |
|
|
| Inspect changes | n/a | `IModelChangeTracker.ModelChanged` event |
|
|
| Invalidate one node | n/a | `INodeCache.InvalidateNode(nodeId)` |
|
|
| Invalidate everything | n/a | `INodeCache.Clear()` |
|
|
| Refresh the namespace table | Live NodeManager registration updates `NamespaceArray` / `UrisVersion` | Automatic — see [Namespace table refresh](#namespace-table-refresh) |
|
|
|
|
## Server side: emitting model changes
|
|
|
|
### Auto-emit from `CreateNode` and `DeleteNode`
|
|
|
|
`AsyncCustomNodeManager.CreateNodeAsync(...)` and `DeleteNodeAsync(...)`
|
|
(and the sync-compat overloads on `CustomNodeManager`) automatically
|
|
record the change in a per-instance `ModelChangeAggregator` and emit a
|
|
`GeneralModelChangeEvent` at the end of the call.
|
|
|
|
```csharp
|
|
public class MyNodeManager : AsyncCustomNodeManager
|
|
{
|
|
public async ValueTask AddDeviceTwinAsync(NodeId parent, string name, CancellationToken ct)
|
|
{
|
|
var device = new BaseObjectState(null);
|
|
// CreateNodeAsync emits a GeneralModelChangeEvent with verbs
|
|
// NodeAdded(device.NodeId) + ReferenceAdded(parent)
|
|
NodeId id = await CreateNodeAsync(SystemContext, parent,
|
|
ReferenceTypeIds.HasComponent,
|
|
new QualifiedName(name, NamespaceIndex),
|
|
device,
|
|
ct).ConfigureAwait(false);
|
|
}
|
|
}
|
|
```
|
|
|
|
> **Browse consistency.** `CreateNodeAsync`, `AddNodeAsync` and `DeleteNodeAsync` also keep the node manager's internal component cache in sync with the change.
|
|
> A deleted node is evicted from the cache, and the parent's cached view is refreshed after a runtime add or remove, so a Browse, Read or Call issued afterwards reflects the committed child set instead of a stale, cached view.
|
|
> Re-registering or replacing a node id (for example swapping a passive child for a typed proxy) refreshes the cached instance only when that node is already cached.
|
|
|
|
If your node manager mutates the address space without going through
|
|
`CreateNodeAsync` / `DeleteNodeAsync` (for example by editing an
|
|
in-memory `NodeStateCollection`), you can either:
|
|
|
|
1. **Opt out of auto-emit and drive it yourself:**
|
|
|
|
```csharp
|
|
public class MyNodeManager : AsyncCustomNodeManager
|
|
{
|
|
public MyNodeManager(...)
|
|
{
|
|
ModelChangeEmissionEnabled = false; // disable auto-emit
|
|
}
|
|
|
|
public void RewireGraph()
|
|
{
|
|
// ...mutate nodes...
|
|
ModelChangeAggregator.RecordReferenceAdded(parent.NodeId);
|
|
ModelChangeAggregator.RecordNodeDeleted(stale.NodeId, stale.TypeDefinitionId);
|
|
EmitModelChange(SystemContext); // single event covers the batch
|
|
}
|
|
}
|
|
```
|
|
|
|
2. **Leave auto-emit on and call the aggregator + `EmitModelChange`
|
|
from custom mutation paths.** Every `CreateNode` / `DeleteNode`
|
|
continues to emit its own event for the change it made.
|
|
|
|
`EmitModelChange` is a no-op when the aggregator has no pending
|
|
changes, so it is safe to call at the end of an arbitrary service
|
|
implementation.
|
|
|
|
### Manual aggregation across a transaction
|
|
|
|
`ModelChangeAggregator` is thread-safe and supports arbitrary batching:
|
|
|
|
```csharp
|
|
var aggregator = new ModelChangeAggregator();
|
|
aggregator.RecordNodeAdded(motor.NodeId, ObjectTypeIds.BaseObjectType);
|
|
aggregator.RecordReferenceAdded(parent.NodeId);
|
|
aggregator.RecordDataTypeChanged(speedVar.NodeId);
|
|
aggregator.RecordReferenceDeleted(oldParent.NodeId);
|
|
|
|
if (aggregator.HasPending)
|
|
{
|
|
RaiseGeneralModelChangeEvent(context, aggregator.Drain());
|
|
}
|
|
```
|
|
|
|
`Drain` clears the pending list and returns the batch in insertion
|
|
order. The aggregator can be reused across many transactions.
|
|
|
|
The `Verb` field in `ModelChangeStructureDataType` is a bitmask (the
|
|
`ModelChangeVerbs` flags enum on the server side mirrors the same
|
|
spec values):
|
|
|
|
| Flag | Value | Meaning |
|
|
|---|---|---|
|
|
| `NodeAdded` | 1 | A new node was added. |
|
|
| `NodeDeleted` | 2 | An existing node was deleted. |
|
|
| `ReferenceAdded` | 4 | A reference was added. |
|
|
| `ReferenceDeleted` | 8 | A reference was deleted. |
|
|
| `DataTypeChanged` | 16 | The DataType attribute changed. |
|
|
|
|
### Semantic change events
|
|
|
|
`RaiseSemanticChangeEvent(...)` emits `SemanticChangeEventType` for
|
|
property semantics changes — for example when an EngineeringUnit,
|
|
EURange, or Description changes in a way that affects the meaning
|
|
of a value. This is independent of `GeneralModelChangeEvent`; both
|
|
are subtypes of `BaseModelChangeEventType` and both are observed by
|
|
the client-side tracker.
|
|
|
|
## Client side: tracking model changes
|
|
|
|
### Enabling tracking on a `ManagedSession`
|
|
|
|
The simplest path is to opt in via `ManagedSessionOptions` /
|
|
`ManagedSessionBuilder` — tracking auto-starts after connect:
|
|
|
|
```csharp
|
|
ManagedSession session = await new ManagedSessionBuilder(configuration, telemetry)
|
|
.UseEndpoint(endpoint)
|
|
.WithModelChangeTracking() // opt in
|
|
.ConnectAsync(ct);
|
|
|
|
session.ModelChange!.ModelChanged += (sender, args) =>
|
|
{
|
|
foreach (ModelChange change in args.Changes)
|
|
{
|
|
Console.WriteLine($"{change.Verb}: {change.AffectedNode}");
|
|
}
|
|
};
|
|
```
|
|
|
|
Tracking can also be enabled / disabled at runtime:
|
|
|
|
```csharp
|
|
await session.EnableModelChangeTrackingAsync(ct);
|
|
// ...
|
|
await session.DisableModelChangeTrackingAsync(ct);
|
|
```
|
|
|
|
Disposing the session disposes the tracker.
|
|
|
|
### Manual construction
|
|
|
|
If you need the tracker without a `ManagedSession` — for example with
|
|
a raw `Session` that has the V2 engine enabled — construct it
|
|
yourself:
|
|
|
|
```csharp
|
|
var streaming = new StreamingSubscription(session.SubscriptionManager);
|
|
var tracker = new ModelChangeTracker(
|
|
streaming,
|
|
nodeCache: session.NodeCache,
|
|
logger: logger,
|
|
namespaceTables: session);
|
|
|
|
await tracker.StartTrackingAsync(ct);
|
|
```
|
|
|
|
The tracker takes an `IStreamingSubscription`, an optional `INodeCache`
|
|
to invalidate, an optional `ILogger`, and an optional
|
|
`INamespaceTableRefresher` (see
|
|
[Namespace table refresh](#namespace-table-refresh)). It subscribes to
|
|
`BaseModelChangeEventType` on the Server object's notifier
|
|
(`ObjectIds.Server`), which covers both `GeneralModelChangeEventType`
|
|
and `SemanticChangeEventType` payloads.
|
|
|
|
### Reacting to changes
|
|
|
|
The `ModelChanged` event fires once per server event with a structured
|
|
payload:
|
|
|
|
```csharp
|
|
public sealed class ModelChangedEventArgs
|
|
{
|
|
public IReadOnlyList<ModelChange> Changes { get; }
|
|
public bool RequiresFullCacheInvalidation { get; }
|
|
public bool NamespaceTableRefreshed { get; }
|
|
}
|
|
|
|
public record struct ModelChange(
|
|
ModelChangeVerb Verb,
|
|
NodeId AffectedNode,
|
|
NodeId TypeDefinition);
|
|
```
|
|
|
|
`TypeDefinition` is `NodeId.Null` when the server did not report one —
|
|
test it with `change.TypeDefinition.IsNull`.
|
|
|
|
`RequiresFullCacheInvalidation` is `true` when the server reports a
|
|
change without per-node detail (a Part 5 semantic change without a
|
|
populated payload). In that case the tracker calls
|
|
`INodeCache.Clear()` for you, but applications may also need to
|
|
re-browse interesting subtrees:
|
|
|
|
```csharp
|
|
session.ModelChange!.ModelChanged += async (sender, args) =>
|
|
{
|
|
if (args.RequiresFullCacheInvalidation)
|
|
{
|
|
await RefreshUiTreeAsync();
|
|
return;
|
|
}
|
|
|
|
foreach (ModelChange c in args.Changes)
|
|
{
|
|
if (c.Verb.HasFlag(ModelChangeVerb.NodeDeleted))
|
|
{
|
|
ui.RemoveNode(c.AffectedNode);
|
|
}
|
|
else if (c.Verb.HasFlag(ModelChangeVerb.NodeAdded))
|
|
{
|
|
await ui.BrowseAndAddAsync(c.AffectedNode);
|
|
}
|
|
}
|
|
};
|
|
```
|
|
|
|
### Namespace table refresh
|
|
|
|
A Server may append namespace uris while it is running — most notably
|
|
when a NodeManager is
|
|
[registered live](NodeManagers.md#namespaces). The Server
|
|
updates `Server_NamespaceArray`, bumps `Server_UrisVersion`, and then
|
|
reports the address-space change. Without a refresh, a Client resolves
|
|
NodeIds from the new namespace against a stale `NamespaceUris` table
|
|
and silently gets the wrong index.
|
|
|
|
The tracker closes that gap. Given an `INamespaceTableRefresher` it
|
|
re-reads the namespace table before it invalidates the cache and before
|
|
it raises `ModelChanged`, so subscribers that re-browse from the handler
|
|
already see the new uris:
|
|
|
|
```csharp
|
|
public interface INamespaceTableRefresher
|
|
{
|
|
NamespaceTable NamespaceUris { get; }
|
|
Task FetchNamespaceTablesAsync(CancellationToken ct = default);
|
|
}
|
|
```
|
|
|
|
Both `Session` and `ManagedSession` implement it, and
|
|
`EnableModelChangeTrackingAsync` wires the session in automatically —
|
|
opting into tracking is all an application has to do:
|
|
|
|
```csharp
|
|
session.ModelChange!.ModelChanged += (sender, args) =>
|
|
{
|
|
if (args.NamespaceTableRefreshed)
|
|
{
|
|
// The namespace table grew. Re-resolve any ExpandedNodeIds
|
|
// the application cached as namespace-index based NodeIds.
|
|
RemapCachedNodeIds(session.NamespaceUris);
|
|
}
|
|
};
|
|
```
|
|
|
|
A refresh is triggered when any of the following holds:
|
|
|
|
| Trigger | Why |
|
|
|---|---|
|
|
| The event carries no per-node detail (`RequiresFullCacheInvalidation`) | This is the `BaseModelChangeEvent` shape a live NodeManager registration emits; the Client cannot tell whether namespaces changed, so it re-reads. |
|
|
| An affected node is `Server_NamespaceArray` | The Server explicitly reported the namespace array as changed. |
|
|
| An affected node (or its type definition) has a namespace index beyond the Client's table | The Client provably cannot resolve the NodeId. |
|
|
|
|
There is no throttling. An unqualified change already forces
|
|
`INodeCache.Clear()`, which is far more expensive for an application
|
|
than one two-node Read, so the namespace read rides along with it.
|
|
A failing refresh is logged and swallowed: the `ModelChanged` event is
|
|
still raised (with `NamespaceTableRefreshed == false`) and tracking
|
|
continues.
|
|
|
|
Pass `namespaceTables: null` when constructing the tracker manually to
|
|
opt out entirely, or supply a custom `INamespaceTableRefresher` to
|
|
control how and when the table is re-read.
|
|
|
|
> **Namespace indexes are append-only** for the lifetime of a running
|
|
> Server, so a refresh can only ever extend the Client table. Indexes
|
|
> the Client already resolved stay valid.
|
|
|
|
### Per-node `NodeCache` invalidation
|
|
|
|
`INodeCache` exposes `InvalidateNode(NodeId)` for targeted eviction.
|
|
The `NodeCache` shipped with the stack drops the cached node, value,
|
|
and reference list for just that NodeId; alternative implementations
|
|
can fall back to `Clear()`.
|
|
|
|
The tracker calls `InvalidateNode` for every non-NoneVerb change, so
|
|
the cache stays in sync without application help. If you want to
|
|
intercept (e.g. to drop dependents):
|
|
|
|
```csharp
|
|
class MyInvalidatingCache : INodeCache
|
|
{
|
|
private readonly NodeCache m_inner;
|
|
public void InvalidateNode(NodeId id)
|
|
{
|
|
m_inner.InvalidateNode(id);
|
|
// Also drop dependents tracked elsewhere
|
|
m_dependentIndex.RemoveAllReferencing(id);
|
|
}
|
|
// ... delegate everything else to m_inner ...
|
|
}
|
|
```
|
|
|
|
## NodeVersion correlation
|
|
|
|
OPC UA Part 5 §9.32.2 ties `ModelChangeEvent` emission to the
|
|
`NodeVersion` property:
|
|
|
|
> "Every time a `ModelChangeEvent` is issued for a Node, its
|
|
> `NodeVersion` shall be changed, and every time the `NodeVersion` is
|
|
> changed, a `ModelChangeEvent` shall be generated. A Server shall
|
|
> support both the `ModelChangeEvent` and the `NodeVersion` Property
|
|
> or neither, but never only one of the two mechanisms."
|
|
>
|
|
> "Only those Nodes of the AddressSpace having a `NodeVersion` shall
|
|
> trigger a `ModelChangeEvent`. Other Nodes shall not trigger a
|
|
> `ModelChangeEvent`."
|
|
|
|
The framework enforces both halves:
|
|
|
|
1. **Suppression.**
|
|
`AsyncCustomNodeManager.RaiseGeneralModelChangeEvent` filters the
|
|
`Changes[]` array, dropping every entry whose `Affected` node is
|
|
owned by the node manager and does **not** carry a `NodeVersion`
|
|
property. When the filtered array is empty no event is raised at
|
|
all. Entries whose `Affected` node belongs to a different node
|
|
manager pass through unchanged (the local manager cannot inspect
|
|
the foreign node's state — this is documented behaviour).
|
|
|
|
2. **Bumping.**
|
|
Every Affected node that survives the filter has its `NodeVersion`
|
|
incremented as a decimal `ulong` string (`"1"` → `"2"` → …) before
|
|
the `GeneralModelChangeEvent` is reported. The increment is wrapped
|
|
in an `AsyncLocal` guard so the OnWriteValue handler installed by
|
|
`EnableModelChangeTrackingFor` does not echo the framework bump as
|
|
a `BaseModelChangeEvent`.
|
|
|
|
3. **External writes.** When `EnableModelChangeTrackingFor` (or the
|
|
underlying `NodeState.EnableModelChangeTracking` extension)
|
|
attaches a `NodeVersion` it also wires `OnWriteValue` so a write
|
|
from an external client (or a `Write` service call) raises a
|
|
`BaseModelChangeEvent` (Part 5 §9.32.5) with the owning node as
|
|
the SourceNode.
|
|
|
|
### Marking a node as trackable
|
|
|
|
`AsyncCustomNodeManager` exposes the
|
|
`EnableModelChangeTrackingFor(node)` convenience method that wraps
|
|
the more general `NodeState.EnableModelChangeTracking(namespaceIndex,
|
|
callback)` extension:
|
|
|
|
```csharp
|
|
public class MyNodeManager : AsyncCustomNodeManager
|
|
{
|
|
public async ValueTask InstallDynamicAreaAsync(CancellationToken ct)
|
|
{
|
|
var folder = new FolderState(null)
|
|
{
|
|
SymbolicName = "Dynamic",
|
|
BrowseName = new QualifiedName("Dynamic", NamespaceIndex)
|
|
};
|
|
|
|
// Attaches a NodeVersion property and wires its OnWriteValue
|
|
// so any external write raises a BaseModelChangeEvent on this
|
|
// folder. Idempotent — calling it twice returns the same
|
|
// PropertyState and does not attach a second copy.
|
|
EnableModelChangeTrackingFor(folder);
|
|
|
|
await CreateNodeAsync(SystemContext, default,
|
|
ReferenceTypeIds.Organizes,
|
|
folder.BrowseName,
|
|
folder,
|
|
ct).ConfigureAwait(false);
|
|
|
|
// Now every CreateNodeAsync / DeleteNodeAsync touching the
|
|
// folder will emit a GeneralModelChangeEvent and bump
|
|
// folder.NodeVersion accordingly.
|
|
}
|
|
}
|
|
```
|
|
|
|
If you only need the helper without a node manager (for example in a
|
|
unit test or a fluent builder) call the extension directly:
|
|
|
|
```csharp
|
|
using Opc.Ua.Server.NodeManager;
|
|
|
|
PropertyState<string> version = node.EnableModelChangeTracking(
|
|
namespaceIndex: 1,
|
|
raiseBaseModelChangeEvent: (ctx, owner) =>
|
|
{
|
|
// emit a BaseModelChangeEvent via your own pipeline
|
|
});
|
|
```
|
|
|
|
### Opting out (legacy compatibility)
|
|
|
|
For servers that have not yet annotated their address space with
|
|
`NodeVersion` properties and still expect every `CreateNodeAsync` /
|
|
`DeleteNodeAsync` to fire an event, set
|
|
`RequireNodeVersionForModelChange = false` on the affected node
|
|
manager. The filter is then skipped and the legacy unconditional
|
|
behaviour is restored. Default: `true` (strict spec compliance).
|
|
|
|
```csharp
|
|
public sealed class LegacyNodeManager : AsyncCustomNodeManager
|
|
{
|
|
public LegacyNodeManager(...)
|
|
{
|
|
// Opt back into pre-Part5 §9.32.2 unconditional emission.
|
|
RequireNodeVersionForModelChange = false;
|
|
}
|
|
}
|
|
```
|
|
|
|
## Security considerations
|
|
|
|
Model-change events are governed by the same per-event permission rules
|
|
as every other UA event, with one practical caveat that operators
|
|
should understand when deploying on shared address spaces.
|
|
|
|
### Per-event `ReceiveEvents` is enforced
|
|
|
|
OPC UA Part 3 §8.55 (`PermissionType`, bit 11 `ReceiveEvents`):
|
|
|
|
> *A Client only receives an Event if this bit is set on the Node
|
|
> identified by the EventTypeId field and on the Node identified by
|
|
> the SourceNode field.*
|
|
|
|
The stack honors this on every live event delivery — see
|
|
`MonitoredNode2.ProcessEventSnapshotAsync` →
|
|
`IAsyncNodeManager.ValidateEventRolePermissionsAsync` (which delegates
|
|
to `ValidateEventReceivePermissionsAsync` for the two
|
|
`ValidateRolePermissionsAsync(..., PermissionType.ReceiveEvents)`
|
|
checks). For `GeneralModelChangeEvent` the two nodes are
|
|
`GeneralModelChangeEventType` and (typically) the `Server` object — a
|
|
session lacking `ReceiveEvents` on either node will not receive the
|
|
event even when the session has a valid event monitor on the notifier.
|
|
|
|
The verdict is cached per `(MonitoredItemId, EventTypeId, SourceNodeId)`
|
|
so a busy notifier does not pay the two role-permission lookups on
|
|
every event. The cache is invalidated when:
|
|
|
|
- the namespace `DefaultRolePermissions` or `DefaultUserRolePermissions`
|
|
change,
|
|
- the notifier's own `RolePermissions` change (signaled via
|
|
`NodeStateChangeMasks.RolePermissions` to subscribed nodes),
|
|
- the receiving session's user identity changes
|
|
(`InvalidatePermissionCacheForSession`),
|
|
- the monitored item is removed.
|
|
|
|
If you publish events via your own helper that pushes directly into a
|
|
list of `IEventMonitoredItem`, use
|
|
`EventManager.ReportEventAsync(event, nodeManager, items, ct)` — it
|
|
applies the same per-item permission gate before queueing. The legacy
|
|
`EventManager.ReportEvent(IFilterTarget, IList<IEventMonitoredItem>)`
|
|
overload is marked `[Obsolete]` because it bypasses the gate.
|
|
|
|
### The `Affected` array is NOT filtered
|
|
|
|
`GeneralModelChangeEvent.Changes[]` carries a
|
|
`ModelChangeStructureDataType` per address-space change with an
|
|
`Affected` and an `AffectedType` `NodeId`. These names are placed into
|
|
the event payload as-is — the stack does NOT redact entries the
|
|
receiving session cannot browse or read. The OPC UA specification does
|
|
not require such filtering, and applying it would force a per-item
|
|
metadata lookup for every affected node on every delivery.
|
|
|
|
When the affected subtree is sensitive (e.g. you do not want
|
|
unauthorized sessions to learn that a node with a given browse name
|
|
exists), choose one of the following:
|
|
|
|
1. **Emit on a private View.** Build a `ViewState` that contains only
|
|
the public subset of the address space, give it a distinct
|
|
`SourceNode`, and call `RaiseGeneralModelChangeEvent` with that
|
|
view as the source. Subscribers without `ReceiveEvents` on the
|
|
private view's source node will not receive the event at all.
|
|
2. **Split sensitive subtrees into a dedicated `AsyncCustomNodeManager`.**
|
|
Set `ModelChangeEmissionEnabled = false` on that manager and emit
|
|
model-change events on a notifier that is itself protected by
|
|
`ReceiveEvents`. Public node managers continue to auto-emit on
|
|
`Objects.Server` as normal.
|
|
3. **Drive emission manually with `ModelChangeAggregator`.** Inspect
|
|
the pending entries before calling `RaiseGeneralModelChangeEvent`
|
|
and drop entries that name sensitive nodes.
|
|
|
|
### The initial address-space build does not emit
|
|
|
|
`AsyncCustomNodeManager.CreateAddressSpaceAsync` walks the predefined
|
|
node set through `AddPredefinedNodeAsync` — that path does not feed
|
|
the `ModelChangeAggregator` and does not call `EmitModelChange`. Only
|
|
the dynamic `CreateNodeAsync` / `DeleteNodeAsync` (and any explicit
|
|
calls you add) raise `GeneralModelChangeEvent`. This is intentional:
|
|
no clients are subscribed during startup, and Part 5 §9.32.2 ties
|
|
`ModelChangeEvent` emission to nodes that carry a `NodeVersion`
|
|
property — predefined static nodes typically do not.
|
|
|
|
If your node manager mutates the address space late (e.g. installing
|
|
nodes after `StartAsync` returns) and uses `AddPredefinedNodeAsync`
|
|
rather than `CreateNodeAsync`, no event will fire. Either use
|
|
`CreateNodeAsync` or record the change with the aggregator and call
|
|
`EmitModelChange` explicitly. The
|
|
`AsyncCustomNodeManager.ModelChangeEmissionEnabled` flag opts the
|
|
entire manager out of auto-emit if you want full manual control.
|
|
|
|
## Reference
|
|
|
|
- Server source: `src/Opc.Ua.Server/NodeManager/ModelChangeAggregator.cs`
|
|
- Server events: `CustomNodeManager.RaiseGeneralModelChangeEvent`,
|
|
`RaiseSemanticChangeEvent` (and the async equivalents in
|
|
`AsyncCustomNodeManager`)
|
|
- Client source: `src/Opc.Ua.Client/ModelChange/`
|
|
- Cache: `src/Opc.Ua.Client/NodeCache/NodeCache.cs` —
|
|
`InvalidateNode`
|
|
- Sessions architecture: [Sessions.md](Sessions.md)
|
|
- Spec: [OPC UA Part 5 §6.4.32 — `BaseModelChangeEventType`](https://reference.opcfoundation.org/v105/Core/docs/Part5/6.4.32/) |