mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-25 20:00:31 +08:00
# Description Remove stock phrasing and correct misleading or inconsistent technical documentation. The initial two passes covered 367 Markdown files, followed by an interactive review of 169 editorial flags. The U5 interpretation originally deferred in that review has since received the upstream Mantis answer recorded by #4843; remaining server/spec follow-ups stay explicit in the CTT log. - Correct package responsibilities, migration steps, API names, ownership, protocol descriptions and links. Keep bundled migration guides synchronized. Preserve historical conclusions as historical records with separate re-check notes. - Provide at least two rendered TOC levels throughout `docs/`. Convert appropriate architecture figures to Mermaid without changing code examples, transcripts or historical quotations. - Address the October 19 feedback with concrete setup/migration instructions, corrected certificate paths and PubSub names, shorter repeated caveats, consistent XML comments, and a full review of the Concepts guide. Retain #4839's API corrections. - Integrate #4841's runtime/performance guidance and review incoming/recent prose. Preserve dated measurements rather than claiming new benchmark runs; clarify allocation ratios, startup-only processor settings, JIT warm-up and the benchmark commit's scope. Missing raw measurement artifacts and repeat ranges remain tracked by #4842. - Merge master through `e8c5d800a` in `32c6394b7`. Resolve the CTT log conflict using the updated Mantis states and OPC answers, retaining historical framing and re-checks for C28/C32/C43. - Address the latest naming/index feedback in `adb5bf693`: rename the guides to `docs/WotCon.md` and `docs/WotNodeSetConversion.md`; move the provider and DataType-declaration contracts into the conversion guide and remove `docs/WoT.md`. Rename `samples/DI` to `samples/Di` and `samples/Isa95` to `samples/ISA95`. Add content-index READMEs to Di, FluentApi, ISA95, Lds and PubSub; update solution entries, project references, Docker/CI paths, test fixture paths and documentation links with exact casing. **WoT guide scope.** This branch retains the detailed conversion guide that #4758 had replaced with an anchor stub. Conversion limits, verified document sets, preservation rules and provider ownership now live together in `WotNodeSetConversion.md`; binding/runtime guidance remains in `WotBindings.md` and `WotCon.md`. The design-rationale/test-ledger prose was shortened, the oversized argument-table cell moved into its section, and existing anchors preserved. **SDK policy is a separate, explicit user-directed change.** `global.json` sets a stable minimum of `10.0.400`, `rollForward: latestMajor`, and `allowPrerelease: false`. Newer major SDKs are permitted; CI's explicit SDK pins are unchanged. README, GettingStarted, DeveloperGuide and Recycling instructions reflect the possible analyzer differences. The image-contract regression fix accepts compatible stable SDK images instead of requiring exact equality with the floor. The latest four rename/index threads are resolved after publication. Three older threads remain open: universal obsolete-removal timing, unconditional nonce-strength wording, and a blanket Vision NativeAOT claim. Existing implementation/evidence constraints are explained in their inline replies. The two-level TOCs remain as requested. ## Validation - `git diff --check` and staged diff checks: passed. The untouched upstream AutoID NodeSet retains its provenance bytes, including two existing license-header trailing spaces. - `.\.azurepipelines\validate-migration-plugin.ps1`: passed; 15 bundled migration documents and manifests synchronized. - Documentation/path validation: **101** documents retain two rendered TOC levels (**2,228** entries), with idempotent formatting. **52** new relative links and their anchors passed exact-case checks. No tracked references to the old guide/sample paths remain. Both relocated provider-contract sections are preserved in the destination. - `dotnet sln UA.slnx list`: passed; shows the renamed Di/ISA95 projects and the existing FluentApi/Lds/PubSub projects. Updated solution and pump ProjectReference paths also passed exact-case existence checks. - Latest focused commands, run from the repository root: ```powershell # Passed: 61 channel send-order and reconnect tests. dotnet test tests\Opc.Ua.Core.Tests\Opc.Ua.Core.Tests.csproj -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~UaSCBinaryChannelSendOrderTests|FullyQualifiedName~TcpReconnectKeyAgreementTests' --verbosity minimal # Passed: 5 pump datasheet tests; builds the renamed pump sample. dotnet test tests\Opc.Ua.Di.Tests\Opc.Ua.Di.Tests.csproj -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~PumpDatasheetConformanceTests' --verbosity minimal # Passed: 4 repository NodeSet path/round-trip tests, including the moved pump models. dotnet test tests\Opc.Ua.Types.Tests\Opc.Ua.Types.Tests.csproj -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~RepositorySourceSurvivesSerializedLinkedPartitionsAsync' --verbosity minimal # Passed: build, zero warnings/errors. dotnet build samples\ISA95\MinimalIsa95Server\MinimalIsa95Server.csproj -p:CustomTestTarget=net10.0 -f net10.0 --verbosity minimal # Passed: dependent sample-test project build, zero warnings/errors. dotnet build tests\Opc.Ua.WotCon.Samples.Tests\Opc.Ua.WotCon.Samples.Tests.csproj -p:CustomTestTarget=net10.0 -f net10.0 --verbosity minimal ``` Earlier focused integration checks passed 2,620 tests across AutoID, event queues/scheduling, source generation, MCP/package profiles and WoT. Both reviewed Mermaid diagrams rendered locally without network access. These checks are not a full solution run. The full solution has **not** been rerun for this review. Earlier `dotnet test UA.slnx -p:CustomTestTarget=net10.0 --verbosity quiet` did not pass: three AOT projects rejected VSTest and 20 Vision tests failed during graphics initialization with `OutOfMemoryException`. Remaining long-running tests were stopped at the user's request. A focused Vision rerun passed 558 tests and reproduced those 20 failures. The earlier `dotnet test UA.slnx -p:CustomTestTarget=net48 --verbosity quiet` was blocked by the former SDK pin and was not retried after the policy change. The user approved proceeding with those limitations documented; current remote CI is not claimed green. ## Related Issues - Follow-up #4842 tracks the requested 2.0.2+ benchmark/tuning and diagnostic-tool documentation refresh. #4841 supplies documentation and earlier results, not all newly requested measurements. - #4758 explains the conversion guide's earlier consolidation; the current provider contracts are now consolidated into `WotNodeSetConversion.md`. - #4839 supplies the reviewed API corrections; #4843 supplies the updated CTT issue record. - The original prose cleanup and SDK policy were user-requested without a source issue. No new runtime architecture is introduced by these documentation edits. ## 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. - [ ] 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. - [ ] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] 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: Roman Ettlinger <romanett98@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
599 lines
24 KiB
Markdown
599 lines
24 KiB
Markdown
# Model Change Tracking
|
|
|
|
OPC UA Part 5 §6.4.32 defines `GeneralModelChangeEventType`. Servers use it
|
|
to notify clients about address-space changes, including added or deleted
|
|
nodes and references, and changed data types.
|
|
|
|
The `Opc.Ua.Client.ModelChange` namespace provides an opt-in client-side
|
|
tracker. It consumes these events, removes affected entries from the
|
|
client's `INodeCache`, and surfaces the changes through a strongly typed
|
|
event. Application code can then browse the affected subtrees again.
|
|
|
|
The server-side counterpart lives in `Opc.Ua.Server.Alarms`.
|
|
`ModelChangeAggregator` batches per-call changes. `CustomNodeManager` and
|
|
`AsyncCustomNodeManager` automatically emit `GeneralModelChangeEvent` from
|
|
`CreateNode` and `DeleteNode`. Set a flag to opt out when you need manual
|
|
control.
|
|
|
|
## Contents
|
|
|
|
- [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)
|
|
- [Marking a node as trackable](#marking-a-node-as-trackable)
|
|
- [Opting out (legacy compatibility)](#opting-out-legacy-compatibility)
|
|
- [Security considerations](#security-considerations)
|
|
- [Per-event `ReceiveEvents` is enforced](#per-event-receiveevents-is-enforced)
|
|
- [The `Affected` array is NOT filtered](#the-affected-array-is-not-filtered)
|
|
- [The initial address-space build does not emit](#the-initial-address-space-build-does-not-emit)
|
|
- [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. After a runtime add or remove, the
|
|
manager refreshes the parent's cached view. A subsequent Browse, Read, or
|
|
Call therefore sees the committed child set instead of stale data.
|
|
> When the node is already cached, re-registering it refreshes the cached
|
|
instance. Replacing a cached node with a typed proxy also refreshes it.
|
|
|
|
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.
|
|
|
|
Deletion is not an atomic subtree transaction. If removal fails after deleting
|
|
some nodes, monitored items for removed nodes report `Bad_NodeIdUnknown`, while
|
|
items for descendants that remain registered are restored to their node manager.
|
|
The original failure is still reported; restoration failures are also surfaced.
|
|
Each asynchronous restoration gets its own five-second cleanup budget, so one
|
|
timed-out item does not cancel attempts to restore the remaining items.
|
|
|
|
Deletion captures the subtree's model-change eligibility before mutation.
|
|
Completion reports only nodes actually removed and references actually removed
|
|
from surviving local nodes, including when later cleanup fails or the request
|
|
is cancelled. A versioned descendant is reported even if its root is unversioned.
|
|
The deletion error is still returned; notification failures are not suppressed.
|
|
For reference endpoints owned by another node manager, automatic reporting
|
|
requires requested and successful target-reference cleanup because their remaining
|
|
references cannot be inspected locally. A bad per-reference cleanup result from
|
|
an owning node manager is propagated as a deletion failure, not treated as
|
|
successful cleanup. Committed local changes are still reported, but unconfirmed
|
|
foreign reference removals are not. A successful `DeleteNodes` call with
|
|
`DeleteTargetReferences = false` does not imply that foreign references were removed.
|
|
This reporting covers both directions of hierarchical and non-hierarchical
|
|
references to other node managers in the same server. Absolute targets are
|
|
excluded, matching the deletion cleanup path.
|
|
|
|
The asynchronous runtime deletion overload accepts `emitModelChange: false` for
|
|
callers that own aggregation or roll back unpublished staging. This suppresses
|
|
automatic reporting for that call, including after a committed deletion's cleanup
|
|
fails, without changing the manager-wide policy or deletion lifecycle handling.
|
|
|
|
### 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 when given an `INamespaceTableRefresher`. It
|
|
refreshes the namespace table before invalidating the cache and raising
|
|
`ModelChanged`. Subscribers can then re-browse from the event handler using
|
|
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 3 §9.32.2 ties `ModelChangeEvent` emission to the
|
|
`NodeVersion` property:
|
|
|
|
Automatic deletion captures a node's `NodeVersion` eligibility before removing
|
|
it from the address space. A removed local node must not bypass this check as an
|
|
unresolved external node. Unversioned session diagnostics therefore do not emit
|
|
`NodeDeleted` events on session closure; versioned nodes and the explicit
|
|
`RequireNodeVersionForModelChange = false` compatibility mode retain their events.
|
|
|
|
> "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/) |