Skip to content

fix(modules): query providers resolve node validators in a scope they own, not at the root (#6584) - #7055

Merged
rbuergi merged 7 commits into
mainfrom
fix/6584-root-scope-guards
Oct 22, 2026
Merged

rbuergi merged 7 commits into
mainfrom
fix/6584-root-scope-guards

Conversation

@rbuergi

@rbuergi rbuergi commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

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 INodeValidator write guards: the Hosting module's InstanceActionRequestWriteGuard and the AI module's ModelCeilingWriteGuard. The "served to another caller" shape wrote nothing. Reading on the issue: Refs #6584.

Root cause

The mesh's query providers, StorageAdapterMeshQueryProvider and StaticNodeQueryProvider, are ROOT singletons. Their Read filter took the validator set from the root container (IEnumerable<Lazy<INodeValidator>>). Every INodeValidator is 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 its SupportedOperations and 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. StorageAdapterMeshQueryProvider is 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. IMessageHub resolves the same in both (the mesh hub, a root singleton), so their behaviour is unchanged.

Test

AModuleNodeValidatorIsNeverBuiltForNoCallerTest installs a module whose only service is a write-only, scoped INodeValidator. 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.

  • Without the fix (old registration, same test): FAILED. 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.
  • With the fix: passed.

Local:

  • MeshWeaver.Hosting, MeshWeaver.Documentation and MeshWeaver.Compiler.Pipeline.Test build with -c Release -warnaserror (0 warnings, 0 errors).
  • MeshWeaver.Graph.Test DeclaredPointReadTest and DenyAnonymousSwitchTest: 33 of 33 pass.
  • MeshWeaver.Hosting.Test read-access filters (AutocompleteHonoursReadAccessTest, StaticNode*, Declared*): 22 of 22 pass.
  • The whole MeshWeaver.Compiler.Pipeline.Test project 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 and gcroot showed the path StaticNodeQueryProvider -> 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.
  • Old registration (control): 3 of 3 pass.
  • Previous head: 2 of 3 fail.
  • Full MeshWeaver.Hosting.Test: 1584 of 1585 pass, 0 fail.
  • New test plus the ownership suites: 14 of 14 pass.

Not established

  • Production. Nothing has rolled. The closing reading is the plan already on A module's scoped services are shared across every Blazor circuit and built against no circuit (Radzen ThemeService/DialogService) #6584: governed Logs with |= "[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.
  • That the query providers are the ONLY root route. The log names the service, not the resolve path. Other root-singleton consumers of INodeValidator were searched for in core: the remaining ones (NodeUpdatePipeline, MeshDataSource, MeshExtensions, NodeTypeTransitionGate) resolve from hub.ServiceProvider, a hub's child scope, which is a caller. A route I did not find would still write the line after the roll.
  • MeshWeaver.Plugins Sqlite and Cosmos. Both still take IEnumerable<Lazy<INodeValidator>> from the root (SqliteExtensions.cs, Cosmos PersistenceExtensions.cs), so the same shape remains on those backends. They can move to SingletonNodeValidators once 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

rbuergi and others added 2 commits October 22, 2026 08:28
… 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>
Copilot AI balanced review requested due to automatic review settings October 22, 2026 06:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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 SingletonNodeValidators to 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

@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

    16 files   -     8      16 suites   - 8   56m 6s ⏱️ - 6m 28s
12 556 tests  - 1 574  12 556 ✅  - 1 377  0 💤  - 197  0 ❌ ±0 
12 586 runs   - 1 574  12 586 ✅  - 1 377  0 💤  - 197  0 ❌ ±0 

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.

   --- End of inner exception stack trace ---
   --- End of inner exception stack trace ---)
   --- End of inner exception stack trace ---, expected: True)
   --- End of inner exception stack trace ---, isDenial: True)
 ---> (Inner Exception #1) MeshWeaver.Mesh.QueryProviderStalledException: Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.<---
 ---> (Inner Exception #1) MeshWeaver.Messaging.Hub.Test.InfrastructureFaultTest+ProviderException (0x80004005): Failed to connect to 10.42.18.4:5432<---
 ---> (Inner Exception #1) System.ArgumentException: Value does not fall within the expected range.<---
 ---> (Inner Exception #1) System.InvalidOperationException: boom<---
 ---> (Inner Exception #1) System.InvalidOperationException: source B is misconfigured<---
…
Memex.Portal.Shared.Test.InstanceIdRulesMatchTheRegistryTest ‑ TheSetupHostAgreesWithTheRegistry(candidate: "d1eb1c64-7ef4-4fd3-8add-e1152de4e1d0")
Memex.Portal.Shared.Test.PublishStorageFaultIsNotAConflictTest ‑ AStorageFault_IsA500_NamingTheError(fault: System.AggregateException: One or more errors occurred. (Access to the path '/data/modules/activation.d/MeshWeaver.Markdown.Export.json' is denied.)
 ---> System.UnauthorizedAccessException: Access to the path '/data/modules/activation.d/MeshWeaver.Markdown.Export.json' is denied.
   --- End of inner exception stack trace ---)
Memex.Portal.Shared.Test.PublishStorageFaultIsNotAConflictTest ‑ AStorageFault_IsA500_NamingTheError(fault: System.InvalidOperationException: landing faulted
 ---> System.UnauthorizedAccessException: Access to the path '/data/modules/activation.d/MeshWeaver.Markdown.Export.json' is denied.
   --- End of inner exception stack trace ---)
Memex.Portal.Shared.Test.SessionDenialIsAnAnswerTest ‑ OnlyAVerdictReadsAsADenial(shape: "the same verdict nested, as a late denial dispatch"···, failure: System.InvalidOperationException: write failed
 ---> System.UnauthorizedAccessException: Access denied
   --- End of inner exception stack trace ---, isDenial: True)
MeshWeaver.Compiler.Pipeline.Test.SourcesWatcherStallBackoffTest ‑ The_production_classifier_recognises_a_stall_through_every_wrapping(because: "a stall as an aggregate's FIRST member", fault: System.AggregateException: One or more errors occurred. (Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.)
 ---> MeshWeaver.Mesh.QueryProviderStalledException: Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.
   --- End of inner exception stack trace ---, expected: True)
MeshWeaver.Compiler.Pipeline.Test.SourcesWatcherStallBackoffTest ‑ The_production_classifier_recognises_a_stall_through_every_wrapping(because: "a stall as an aggregate's SECOND member", fault: System.AggregateException: One or more errors occurred. (The operation has timed out.) (Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.)
 ---> System.TimeoutException: The operation has timed out.
   --- End of inner exception stack trace ---
 ---> (Inner Exception #1) MeshWeaver.Mesh.QueryProviderStalledException: Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.<---
, expected: True)
MeshWeaver.Compiler.Pipeline.Test.SourcesWatcherStallBackoffTest ‑ The_production_classifier_recognises_a_stall_through_every_wrapping(because: "a stall nested in an aggregate inside a wrapper", fault: System.InvalidOperationException: outer
 ---> System.AggregateException: One or more errors occurred. (Value does not fall within the expected range.) (Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.)
 ---> System.ArgumentException: Value does not fall within the expected range.
   --- End of inner exception stack trace ---
 ---> (Inner Exception #1) MeshWeaver.Mesh.QueryProviderStalledException: Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.<---

   --- End of inner exception stack trace ---, expected: True)
MeshWeaver.Compiler.Pipeline.Test.SourcesWatcherStallBackoffTest ‑ The_production_classifier_recognises_a_stall_through_every_wrapping(because: "a stall wrapped as InnerException", fault: System.InvalidOperationException: outer
 ---> MeshWeaver.Mesh.QueryProviderStalledException: Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.
   --- End of inner exception stack trace ---, expected: True)
MeshWeaver.Compiler.Pipeline.Test.SourcesWatcherStallBackoffTest ‑ The_production_classifier_recognises_a_stall_through_every_wrapping(because: "an aggregate of unrelated faults", fault: System.AggregateException: One or more errors occurred. (The operation has timed out.) (Value does not fall within the expected range.)
 ---> System.TimeoutException: The operation has timed out.
   --- End of inner exception stack trace ---
 ---> (Inner Exception #1) System.ArgumentException: Value does not fall within the expected range.<---
, expected: False)
MeshWeaver.FaultInjection.Test.AChangeFeedGapReachesEveryConsumerTest ‑ OwnNodeMirror_ReReadsTheStore_AfterACommitToItDuringTheGap
…

♻️ This comment has been updated with latest results.

rbuergi and others added 2 commits October 22, 2026 11:27
…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>
Copilot AI balanced review requested due to automatic review settings October 22, 2026 09:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The cached iterator can retain a built validator after disposal, leaving a reference path to the disposed mesh hub.

1 open issue

Review effort: Balanced

rbuergi and others added 2 commits October 22, 2026 12:36
…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>
Copilot AI balanced review requested due to automatic review settings October 22, 2026 10:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The scoped-ownership and reference-release changes address the reported defects, with only a non-blocking collection-policy nit remaining.

1 open issue
1 resolved since last review

Review effort: Balanced

…mutable accumulator

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 22, 2026 11:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The prior findings are addressed with targeted regression coverage, and no unresolved defects remain in the reviewed changes.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants