Repository navigation
fix(modules): query providers resolve node validators in a scope they own, not at the root (#6584) - #7055
Conversation
… own, not at the root (#6584) The #7027 diagnostic fired on production for two module write guards (InstanceActionRequestWriteGuard, ModelCeilingWriteGuard): built in the module container's root scope, which no caller owns. The route: the root-singleton query providers (StorageAdapterMeshQueryProvider, StaticNodeQueryProvider) took IEnumerable<Lazy<INodeValidator>> from the root container. Every INodeValidator is scoped, so that was a captive dependency; a module's scoped validator was forwarded for no caller and built in the module's root scope, only for the Read filter to read its SupportedOperations and discard it. SingletonNodeValidators, a root singleton, begins a scope of its own and resolves the set there: that scope is the caller, so a module's scoped validator is built in the module scope it owns and disposed with the root. Both query providers take the set from it. AModuleNodeValidatorIsNeverBuiltForNoCallerTest fails against the old registration (one build in a hub scope, one with no caller) and passes with the fix. Refs #6584 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟢 Approval recommended
The focused ownership change preserves lazy resolution and provider contracts, includes regression coverage, and has no unresolved blocking findings.
No open issues 🎉
What changed in this PR
This PR addresses the query-provider path behind #6584 by resolving scoped node validators in an owned scope rather than the root container, while preserving lazy construction and existing provider contracts.
Changes:
- Adds
SingletonNodeValidatorsto own the validator scope and its disposal. - Updates both query providers on partitioned and plain registration paths to use the holder.
- Adds regression coverage and documents the ownership fix and remaining backend follow-up.
| File | Description |
|---|---|
| test/MeshWeaver.Compiler.Pipeline.Test/AModuleNodeValidatorIsNeverBuiltForNoCallerTest.cs | Verifies query validators are constructed for an owning caller. |
| src/MeshWeaver.Hosting/Persistence/SingletonNodeValidators.cs | Owns the scope used for lazy validator resolution. |
| src/MeshWeaver.Hosting/Persistence/PersistenceExtensions.cs | Wires query providers to the shared validator holder. |
| src/MeshWeaver.Documentation/Data/Architecture/LiveModuleUpdate.md | Documents the diagnosed route, fix, and backend follow-up. |
Review effort: Balanced
Test Results 16 files - 8 16 suites - 8 56m 6s ⏱️ - 6m 28s Results for commit 18c5058. ± Comparison against base commit 084b167. This pull request removes 1662 and adds 62 tests. Note that renamed tests count towards both.♻️ This comment has been updated with latest results. |
…living the root no longer pins the mesh hub (#6584) CopyMoveDeadlineReleasesHubTest went red on #7055: the disposed mesh hub stayed alive through StaticNodeQueryProvider -> Lazy<INodeValidator>[] -> ServicePrincipalAdminGuard -> MessageHub (heap dump, gcroot). The static-node provider stays subscribed to the module registry past the root's disposal and now shared the array the storage provider materialises. The holder hands out a sequence read afresh per enumeration and empty once disposed. Refs #6584 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tor, no holder (#6584) The sequence a root-singleton consumer keeps was a C# iterator method. An iterator enumerated on the thread that created it is its own enumerator and keeps its last Current after the walk, so the disposed mesh hub stayed pinned whenever the first enumeration ran on the constructing thread — red in CI (CopyMoveDeadlineReleasesHubTest), green locally. It also reached the holder, whose disposed scope still references every service it resolved. LiveValidators now holds only the validator array, hands out a fresh array enumerator per call, and is emptied on dispose. SingletonNodeValidatorsReleaseOnDisposeTest constructs, enumerates and disposes on one thread and asserts every built validator is collectable (fails on the iterator, on a holder-reaching sequence, and on an array-only iterator). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…mutable accumulator Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>


Why
After #7027 rolled, its diagnostic fired on both public portals: 6 Warnings per portal, two per pod, all of the shape "built in the module container's root scope, which no caller owns". The services named are two module
INodeValidatorwrite guards: the Hosting module'sInstanceActionRequestWriteGuardand the AI module'sModelCeilingWriteGuard. The "served to another caller" shape wrote nothing. Reading on the issue: Refs #6584.Root cause
The mesh's query providers,
StorageAdapterMeshQueryProviderandStaticNodeQueryProvider, are ROOT singletons. Their Read filter took the validator set from the root container (IEnumerable<Lazy<INodeValidator>>). EveryINodeValidatoris registered SCOPED, because it runs on the hub that validates the write, so this was a captive dependency. A module's scoped validator reached the root through a forwarder with no caller (CallerOfRootScope(root) == null). It was therefore built in the module container's own root scope, once per generation, only so the filter could read itsSupportedOperationsand discard it as write-only.Registering the two guards as singletons in MeshWeaver.Plugins would have hidden the line. It would also have changed what they validate against: each reads the VALIDATING hub's configuration and serializer. So the consumer is the defect, not the registrations.
Fix
SingletonNodeValidators(new, public,MeshWeaver.Hosting.Persistence) is a root singleton. It begins a scope of its own and resolves the validator set there, still lazily. That scope is the caller, so a module's scoped validator is built in the module scope that caller owns (the #6586 route). Everything the scope built is disposed with the root. Both query providers take their set from it, on the partitioned and the plain registration paths.StorageAdapterMeshQueryProvideris now registered with an explicit factory so it gets the set from the holder; its constructor is unchanged.The platform's own scoped validators were built in the root scope before; they are now built in the holder's scope.
IMessageHubresolves the same in both (the mesh hub, a root singleton), so their behaviour is unchanged.Test
AModuleNodeValidatorIsNeverBuiltForNoCallerTestinstalls a module whose only service is a write-only, scopedINodeValidator. The validator's constructor records the caller that owns the module scope it is built in. The test then stores a node and reads it as the signed-in user through the query providers, and asserts that no build had no caller. The control asserts that the query really materialised every validator of the set the providers hold.module validator builds, by owning caller: [System.Object, <no caller>]. One build is the write in a hub scope; the other is the query, with no caller.Local:
MeshWeaver.Hosting,MeshWeaver.DocumentationandMeshWeaver.Compiler.Pipeline.Testbuild with-c Release -warnaserror(0 warnings, 0 errors).MeshWeaver.Graph.TestDeclaredPointReadTestandDenyAnonymousSwitchTest: 33 of 33 pass.MeshWeaver.Hosting.Testread-access filters (AutocompleteHonoursReadAccessTest,StaticNode*,Declared*): 22 of 22 pass.MeshWeaver.Compiler.Pipeline.Testproject passes with the fix: 1590 of 1590.CI red on b7d6f1b, fixed in this push
CopyMoveDeadlineReleasesHubTest(both cases) failed: the disposed mesh hub stayed alive. A heap dump andgcrootshowed the pathStaticNodeQueryProvider -> Lazy<INodeValidator>[] -> ServicePrincipalAdminGuard -> MessageHub.The static-node provider stays subscribed to the module registry past the root's disposal. That was already true before this PR; it is the anchor here, not the new defect. What this PR changed: the static-node provider now shared the one array the storage provider materialises, so it held built validators.
The holder now hands out a sequence that is read afresh on every enumeration and is empty once the holder is disposed. With that:
*DeadlineReleasesHubTest: 3 of 3 pass.MeshWeaver.Hosting.Test: 1584 of 1585 pass, 0 fail.Not established
Logswith|= "[ModuleScopeOwnership]", alongside the image check and the[ModuleLoad]positive control. That reading should show zero lines for these two guards. It does NOT answer the Radzen two-session reading, which stays owed.INodeValidatorwere searched for in core: the remaining ones (NodeUpdatePipeline,MeshDataSource,MeshExtensions,NodeTypeTransitionGate) resolve fromhub.ServiceProvider, a hub's child scope, which is a caller. A route I did not find would still write the line after the roll.IEnumerable<Lazy<INodeValidator>>from the root (SqliteExtensions.cs, CosmosPersistenceExtensions.cs), so the same shape remains on those backends. They can move toSingletonNodeValidatorsonce this is on their platform pin. The public portals run Postgres, whose provider consults no validator.Deploy
No recycle is needed. The query providers and the holder are root singletons, and a pod roll rebuilds them.
Pairs-with: none — additive only; no public type or member removed.
Implementers: none — no interface member added.
Mirror-sync: none — no i18n catalog key added or changed.
🤖 Generated with Claude Code