Fluent: publish Configure-created nodes' references on foreign-manager nodes (Objects folder placement) - #4331
Conversation
…ger nodes A node created inside the fluent Configure hook (source-generated [NodeManager] partial, or the hosting AddNodeManager build callback) had no way to appear below the Objects folder or any other node owned by another node manager: AddReverseReferencesAsync mirrors inverse references into the externalReferences dictionary before Configure runs, and the fluent surface never sees the dictionary. Implements the three coordinated pieces from #4329: - Make the mirroring pass idempotent: AddExternalReference (async and sync managers) gets if-missing semantics and TypeTable.AddEncoding no longer appends duplicate encodings, so the pass can safely run twice. - Re-run the pass after Configure: new protected FluentNodeManagerBase.CompleteConfigureAsync wraps AddReverseReferencesAsync; the generator-emitted CreateAddressSpaceAsync and the hosting FluentNodeManager invoke it between the Configure callbacks and Seal(). The hosting manager's hand-rolled externalReferences workaround for its root folder is replaced by the shared pass. - Discoverable fluent sugar: OrganizedBy(parentId) / UnderObjectsFolder() on INodeBuilder write the inverse Organizes reference, and a parentless CreateInstance<TState> on INodeManagerBuilder materializes a root-level instance from its type model, mints per-instance NodeIds through the manager's INodeIdFactory, and registers it with the manager. Companion gap, same theme: [NodeManager] grows AdditionalNamespaceUris so the generated constructor reports a second (instance) namespace at construction and the generated factory advertises it in NamespacesUris, removing the manual RegisterNamespaceManager workaround. Fixes #4329 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes a fluent NodeManager startup-time gap where nodes created during fluent Configure could not publish cross-manager references (e.g., appear under the ns=0 Objects folder), by re-running the reverse-reference mirroring pass after Configure and making that pass idempotent. It also extends [NodeManager] source generation to support additional namespace URIs that must be reported at construction time for correct namespace routing.
Changes:
- Make external-reference mirroring idempotent (
AddExternalReferenceandTypeTable.AddEncoding) so the reverse-reference pass can safely run multiple times. - Re-run reverse-reference collection after fluent
ConfigureviaFluentNodeManagerBase.CompleteConfigureAsync, wired into generated and hosting node managers. - Add fluent convenience APIs for Objects-folder placement (
OrganizedBy/UnderObjectsFolder) and a manager-scoped rootCreateInstanceoverload; extend[NodeManager]withAdditionalNamespaceUrisand wire through generator + tests + docs.
Reviewed changes
Copilot reviewed 24 out of 24 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tools/Opc.Ua.SourceGeneration/NodeManagerAttributeDiscovery.cs | Discovers AdditionalNamespaceUris from [NodeManager] attributes. |
| tools/Opc.Ua.SourceGeneration/Extensions.cs | Adds GetStringArray helper for attribute named-argument parsing. |
| tools/Opc.Ua.SourceGeneration.Core/Templating/Tokens.cs | Adds template token for AdditionalNamespaceUris. |
| tools/Opc.Ua.SourceGeneration.Core/NodeManagerAttributeBinding.cs | Carries discovered AdditionalNamespaceUris in binding record. |
| tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerTemplates.cs | Emits base ctor + factory NamespacesUris with additional namespaces; emits CompleteConfigureAsync call. |
| tools/Opc.Ua.SourceGeneration.Core/Generators/NodeManagerGenerator.cs | Formats and injects additional namespaces into generated templates. |
| tools/Opc.Ua.SourceGeneration.Core/Generators.cs | Plumbs additional namespaces from discovery/binding into generator options. |
| tools/Opc.Ua.SourceGeneration.Core/DesignFile.cs | Adds design option for node-manager additional namespace URIs. |
| src/Opc.Ua.Server/Fluent/NodeManagerAttribute.cs | Adds [NodeManager].AdditionalNamespaceUris public surface. |
| src/Opc.Ua.Server/Fluent/FluentNodeManagerBase.cs | Adds CompleteConfigureAsync wrapper to re-run reverse-reference pass post-Configure. |
| src/Opc.Ua.Server/Hosting/FluentNodeManagerFactory.cs | Removes bespoke external-references workaround and uses CompleteConfigureAsync instead. |
| src/Opc.Ua.Server/Fluent/ReferenceBuilderExtensions.cs | Adds OrganizedBy / UnderObjectsFolder fluent sugar (inverse Organizes). |
| src/Opc.Ua.Server/Fluent/InstanceCreationBuilderExtensions.cs | Adds manager-scoped root CreateInstance and related builder plumbing/docs. |
| src/Opc.Ua.Server/NodeManager/AsyncCustomNodeManager.cs | Makes AddExternalReference idempotent for multi-pass mirroring. |
| src/Opc.Ua.Server/NodeManager/CustomNodeManager.cs | Makes AddExternalReference idempotent for multi-pass mirroring. |
| src/Opc.Ua.Types/Nodes/TypeTable.cs | Makes AddEncoding idempotent when re-registering the same encoding. |
| tests/Opc.Ua.Server.Tests/AsyncCustomNodeManagerTests.cs | Adds tests for double-run idempotence and second-pass pickup of new nodes. |
| tests/Opc.Ua.Server.Tests/Hosting/FluentNodeManagerFactoryCoverageTests.cs | Adds hosting end-to-end coverage for Objects-folder mirroring. |
| tests/Opc.Ua.Server.Tests/Fluent/ReferenceBuilderExtensionsTests.cs | Adds tests for new reference sugar methods. |
| tests/Opc.Ua.Server.Tests/Fluent/InstanceCreationBuilderExtensionsTests.cs | Adds tests for manager-level root instance creation and rebasing behavior. |
| tests/Opc.Ua.Types.Tests/Nodes/TypeTableTests.cs | Adds test asserting AddEncoding idempotence. |
| tests/Opc.Ua.SourceGeneration.Tests/ModelGeneratorTests.cs | Adds generator test for AdditionalNamespaceUris emission in ctor + factory. |
| tests/Opc.Ua.SourceGeneration.Core.Tests/Generators/NodeManagerGeneratorTests.cs | Extends generator structural tests for CompleteConfigureAsync order and additional namespaces. |
| docs/NodeManagers.md | Documents cross-manager placement, fluent sugar, and [NodeManager] additional namespaces. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Code coverage✅ Coverage gate passed.
Uncovered changed lines
Coverage is above the recorded baseline - consider ratcheting Thresholds live in |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #4331 +/- ##
==========================================
+ Coverage 80.31% 80.33% +0.01%
==========================================
Files 1927 1927
Lines 263700 263779 +79
Branches 46129 46146 +17
==========================================
+ Hits 211799 211903 +104
+ Misses 35649 35624 -25
Partials 16252 16252
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
Address Copilot review on #4331: NodeId follows the stack's INullable pattern, so the placement test now uses a non-nullable NodeId with NodeId.Null as the sentinel and checks .IsNull instead of HasValue/.Value. Also cover the root InstanceBuilder null-argument guards flagged as uncovered by the coverage report. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Description
A node created inside the fluent
Configurehook (source-generated[NodeManager]partial, or the hostingAddNodeManager(uri, build)route) had no way to appear below the Objects folder — or below any node owned by another node manager.AsyncCustomNodeManager.AddReverseReferencesAsyncmirrors inverse references into theexternalReferencesdictionary before theConfigurepartials run, and the fluent surface never sees the dictionary, so a configure-created node with an inverseOrganizesreference toObjectsFolderexisted and simulated but never showed up under Objects.This implements the three coordinated pieces proposed in #4329, plus the companion namespace gap:
1. Idempotent mirroring pass (independently worthwhile hardening)
AddExternalReferenceinAsyncCustomNodeManagerandCustomNodeManager2now has if-missing semantics (linear scan of the per-source list).TypeTable.AddEncodingno longer appends a duplicate encoding id; re-registering the same encoding is a succeeding no-op. (AddRootNotifierAsyncand the node-sideAddReferenceIfMissingbranch were already idempotent.)2. Re-run the pass after
ConfigureFluentNodeManagerBase.CompleteConfigureAsync(externalReferences, ct)wrapsAddReverseReferencesAsyncso hand-written managers usingCreateFluentBuilderbenefit too.CreateAddressSpaceAsyncand the hostingFluentNodeManagerinvoke it once between theConfigurecallbacks andSeal(). Timing is safe: the master distributesexternalReferencesonly after every manager'sCreateAddressSpaceAsyncreturns. Side benefit: inverseHasNotifierreferences on configure-created event sources get root-notifier registration for free.FluentNodeManager's hand-rolledexternalReferencesworkaround for its root folder is deleted — the root's inverseOrganizesreference now flows through the shared pass (mirrored exactly once, covered by a test).3. Discoverable fluent sugar
OrganizedBy(parentId)/UnderObjectsFolder()onINodeBuilderwrite the inverseOrganizesreference; with piece 2, placement is just "write the inverse reference".CreateInstance<TState>onINodeManagerBuildertakes a constructor-style factory (p => new BoilerState(p), keeping the surface reflection-free and AOT-safe), materializes the subtree from the type model viaNodeState.Create, rebases all NodeIds through the manager'sINodeIdFactory(AssignInstanceNodeId/AssignInstanceChildNodeIds— the same pair the generated factories use, so declaration-id children cannot collide with the type model), and registers viaAddPredefinedNodeSynchronously. The Boiler Initial commit. #2 shape becomes fully fluent:Companion gap, same theme:
[NodeManager]growsAdditionalNamespaceUris, flowing through attribute discovery → binding → generator so the generated constructor reports a second (instance) namespace at construction and the generated factory advertises it inNamespacesUris— todaySetNamespacesafter construction is not enough becauseMasterNodeManagerbuilds its namespace routing from what the manager reported when it was built.Scope boundary (as discussed in the issue): startup-time configuration only; for nodes created after startup the correct primitive remains
IMasterNodeManager.AddReferencesAsync.Reviewer notes
Opc.Ua.Server.Tests(whose CoverageNodeSet managers are[NodeManager]-generated) emitsawait CompleteConfigureAsync(externalReferences, cancellationToken)betweenConfigureandSeal()and compiles against the real base class.AddReferencesAsync) already usedAddReferenceIfMissing, so the whole chain is defense-in-depth idempotent.AdditionalNamespaceUrisgenerator tests, and a full Roslyn-pipeline attribute test. Docs updated indocs/NodeManagers.md(new "Creating nodes under other managers' nodes" section, attribute docs, fluent-surface sections).Opc.Ua.Server.Tests(4,725 passed / 0 failed / 5 skipped, net9.0),TypeTableTests(113),Opc.Ua.SourceGeneration.Core.Tests(41),Opc.Ua.SourceGeneration.Tests(132), and the Quickstarts.Servers sample all pass.Related Issues
Checklist
🤖 Generated with Claude Code