Skip to content

fix(modules): build module singletons before they are published (#6542) - #6545

Merged
rbuergi merged 3 commits into
mainfrom
fix/6542-prebuild-module-singletons
Oct 12, 2026
Merged

rbuergi merged 3 commits into
mainfrom
fix/6542-prebuild-module-singletons

Conversation

@rbuergi

@rbuergi rbuergi commented Oct 12, 2026

Copy link
Copy Markdown
Contributor

Closes #6542 (sev:M)

What

The maintainer chose build before publish. Every shared instance of a module container (each singleton, and each scoped registration, which is shared at the container root) is now built before the root can reach it:

  • At boot: a new IRootContainerBuilt instance hook runs on the building thread in SetupModules, after the root container is built and before the provider is returned. It calls ModuleContexts.BuildAllBeforePublish, which builds a module after the modules it depends on.
  • On a live swap: ModuleLiveUpdater.LoadAndCommit builds every staged generation after all of them are loaded, materialised and prepared, and before CommitAll.

After this, the root only ever reads module instances that already exist. Autofac answers those without taking the lock, so the root lock never waits on a module container's lock and the lock-order cycle cannot form. Module constructors still run under no lock except their own container's, so a slow constructor delays only its own boot step or swap. That is why the rejected "take the root lock first" approach (the #6432 shape) does not come back.

Root-reachable set. It covers every explicit shared registration in the module container except the module's hosted services. Dependencies are covered transitively. This covers the four paths: ModuleOwnedRootSource stand-ins, the Current forwarder, ModuleServiceProxy.Invoke, and ModuleHealthCheckOptionsForwarder. A descriptor's factory does not name its dependencies, so "everything shared" is the set that is safe to compute. Hosted services stay with their host: the root never reaches them, and #2449 isolation is unchanged.

Residual (documented). One case is not covered: a shared closed generic that a registration source makes on demand, which no constructor takes and which is only resolved later from inside a factory or a method (ILogger<ModuleType>, IOptions<ModuleOptions>). When a constructor takes it, it is built along with that constructor.

Failure. On a swap, a constructor that throws ends the swap as Failed with the constructor's own message. DiscardStaged runs, N keeps serving, and N's hosted services are never stopped. At boot, the module and its dependents are withdrawn (uncommitted, container disposed). Each is reported as an IncompatibleModule on stderr and in the log, and through ModuleOwnedRootSource, so /health and the module-status surfaces read it. The context stays loaded until restart because a dependent may already hold its types.

Staged view. A staged container is built before the commit, so it binds the other generations of the same swap (ModuleServices.Stage, *SeenFrom lookups). For example, the AI module's catalog sees its re-loaded providers' contributions. ModuleSwapStage.Seal() now also forgets its list. Without that, a context or container that keeps its stage for life would pin generations that a later swap retires.

Tests

  • RootAndModuleSingletonsBuildInOneLockOrderTest is the repro from wip/6542-lock-order-repro, adapted. It forces the two first builds to overlap and goes green when the container's singletons are built before publish. Its negative control publishes the same production container without building it first and deadlocks, which is the lifecycle on main.
  • ModuleRootServicesSwapTest.AnUpdateWhoseSingletonCannotBeBuilt_IsRefusedBeforeItCommits_AndNKeepsServing: the swap fails, v1 keeps serving through the cached proxy and the module-type stand-ins, and the hosted journal stays ["start v1"]. The existing live swap is its control.
  • AModuleWhoseSingletonCannotBeBuiltAtBootTest checks that the module is withdrawn and reported as an IncompatibleModule, and that the forwarder names the module. AModuleWhoseSingletonsBuildAtBootTest is the negative control.
  • ACreateWaitsOnAModuleContainerUnderConstructionTest has been renamed to ACreateNeverWaitsOnAModuleContainerUnderConstructionTest. Its premise was a published module whose singleton is built lazily, and that state no longer exists: the handler is built at boot, so the old precondition HandlersBuilt == 0 is false by design. The new test covers three things:

Local, Release: the whole MeshWeaver.Compiler.Pipeline.Test project passes 1406/1406, which includes the module swap and GC-collection tests. Memex.Portal.Shared.Test ~Module passes 445/445 and StaticNodePrecedenceTest passes 6/6. Touched projects build with -c Release -warnaserror: 0 warnings, 0 errors.

Behaviour change to watch in the dependent suites. Module singleton constructors (MeshWeaver.Plugins modules included) now run at boot or swap instead of on first use, so their side effects and failures move there. A module whose singleton cannot be built at boot is now withdrawn and reported, where before it failed on first use.

Docs: Doc/Architecture/LiveModuleUpdate has a new section "Built before published". ReadingASiloEviction's open item is now closed. WriteVerdictTotality is updated.

Pairs-with: none — no public type or member removed; adds the public interface MeshWeaver.ServiceProvider.IRootContainerBuilt (no existing interface gains a member)
Mirror-sync: none — no i18n catalog change

🤖 Generated with Claude Code

A root singleton reaching a module service while a module singleton needed a root singleton
deadlocked: Autofac holds each scope's shared-instance lock for the whole build, so two first
builds in opposite orders cycled. Every shared instance of a module container is now built
before the root can reach it - at boot in a root-container build callback before the provider
is handed out, and on a live swap after every staged generation is prepared and before
CommitAll. The root only ever reads built module instances, answered without the lock.

A constructor that throws refuses the swap (N keeps serving) or, at boot, withdraws the module
and its dependents and reports each as an IncompatibleModule. Staged containers bind the other
generations of their own swap; a sealed stage forgets its list. Hosted services stay with their
host (#2449). Residual: on-demand closed generics no constructor takes.

Refs #6542

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 12, 2026 05:05
@rbuergi
rbuergi enabled auto-merge October 12, 2026 05:05

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

Staged publication can expose mixed generations, failure records can be cached incomplete, and callback failure leaks the root provider.

3 open findings
What changed in this PR

Pre-builds shared module instances before root publication or live-swap commit to prevent root/module lock inversion.

Changes:

  • Adds root-container build hooks and eager shared-instance construction.
  • Adds staged-generation binding and failure withdrawal.
  • Expands regression tests and architecture documentation.
File Description
test/​MeshWeaver.Compiler.Pipeline.Test/​RootAndModuleSingletonsBuildInOneLockOrderTest.cs Adds lock-order regression tests.
test/​MeshWeaver.Compiler.Pipeline.Test/​ModuleRootServicesSwapTest.cs Tests failed singleton construction during swaps.
test/​MeshWeaver.Compiler.Pipeline.Test/​AModuleWhoseSingletonCannotBeBuiltAtBootTest.cs Tests boot-time module withdrawal.
test/​MeshWeaver.Compiler.Pipeline.Test/​ACreateNeverWaitsOnAModuleContainerUnderConstructionTest.cs Updates create-operation concurrency coverage.
src/​MeshWeaver.ServiceProvider/​ServiceProviderExtensions.cs Executes post-root-build hooks.
src/​MeshWeaver.ServiceProvider/​ModuleServiceProvider.cs Adds shared-instance pre-building.
src/​MeshWeaver.ServiceProvider/​IRootContainerBuilt.cs Defines the root build hook.
src/​MeshWeaver.Mesh.Contract/​ModuleServices.cs Pre-builds module services with staged visibility.
src/​MeshWeaver.Mesh.Contract/​ModuleOwnedRootSource.cs Exposes withdrawn-module records.
src/​MeshWeaver.Mesh.Contract/​ModuleContexts.cs Coordinates building, staging, and withdrawal.
src/​MeshWeaver.Mesh.Contract/​MeshBuilder.cs Registers the module build hook.
src/​MeshWeaver.Graph/​Configuration/​ModuleLiveUpdater.cs Builds staged generations before commit.
src/​MeshWeaver.Documentation/​Data/​Architecture/​WriteVerdictTotality.md Updates create-verdict behavior.
src/​MeshWeaver.Documentation/​Data/​Architecture/​ReadingASiloEviction.md Documents the resolved lock inversion.
src/​MeshWeaver.Documentation/​Data/​Architecture/​LiveModuleUpdate.md Documents build-before-publication semantics.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

    20 files   -   4      20 suites   - 4   55m 32s ⏱️ + 2m 16s
11 714 tests  - 298  11 714 ✅  - 105  0 💤  - 193  0 ❌ ±0 
11 728 runs   - 298  11 728 ✅  - 105  0 💤  - 193  0 ❌ ±0 

Results for commit 268e24d. ± Comparison against base commit 70362c4.

This pull request removes 349 and adds 27 tests. Note that renamed tests count towards both.

   --- 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<---
 ---> (Inner Exception #1) System.Net.Sockets.SocketException (0xFFFDFFFF): Name or service not known<---
…
Memex.Portal.Shared.Test.InstanceIdRulesMatchTheRegistryTest ‑ TheSetupHostAgreesWithTheRegistry(candidate: "eb9d7a16-62ae-4f2e-a509-6b98ab998e10")
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.Graph.Test.ACreateNeverWaitsOnAModuleContainerUnderConstructionTest ‑ WhileAModuleHandlerIsParked_AnUnrelatedCreate_IsAnsweredNamingTheModulesHandler
MeshWeaver.Graph.Test.ACreateNeverWaitsOnAModuleContainerUnderConstructionTest ‑ WhileANewGenerationsSingletonIsBeingBuilt_AnUnrelatedCreate_IsAnsweredOk
MeshWeaver.Graph.Test.ACreateNeverWaitsOnAModuleContainerUnderConstructionTest ‑ WithNothingHeld_TheFirstCreate_Succeeds
…

♻️ This comment has been updated with latest results.

rbuergi and others added 2 commits October 12, 2026 09:53
…t; a failed build callback disposes the root

- ModuleSwapStage: Seal() hands the generations over but keeps answering them;
  CommitAll releases the stage only after every generation is current, so a
  staged container reached through the first committed generation never falls
  back to the retiring generation of a later module in the same swap.
- SetupModules: an IRootContainerBuilt that throws disposes the just-built root
  (and the shared instances earlier callbacks built) before rethrowing.

Both new tests fail with the fixes reverted.

Refs #6542

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 12, 2026 07:57
@meshweaver-cloud
meshweaver-cloud Bot disabled auto-merge October 12, 2026 07:58

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.

Comment on lines +281 to +286
generation.Services?.Dispose();
var record = new IncompatibleModule(generation.Location, generation.Name,
$"its shared instances could not be built before it was published, so it was withdrawn: {why}", null);
ImmutableInterlocked.Update(ref notBuilt, list => list.Add(record));
Console.Error.WriteLine($"[MeshWeaver.Mesh.IncompatibleModule] {record.Report()}");
logger?.LogError("[MeshWeaver.Mesh.IncompatibleModule] {Report}", record.Report());
Comment on lines +318 to +324
if (contexts.BuildBeforePublish(generation) is { } notBuilt)
{
contexts.DiscardStaged(stage);
return refused with
{
Outcome = Fail(name, old.Location, target,
$"{generation.Name}'s shared instances could not be built before the swap commits: {notBuilt}"),
Comment on lines +83 to +88
catch
{
// Nobody else holds this root: a callback that throws leaves no caller who could dispose
// it, or the shared instances the callbacks before it already built.
root.Dispose();
throw;
Comment on lines +253 to +255
providers' contributions, not the retiring ones'. A sealed stage answers nothing and forgets its
list, so from the commit on every lookup is the current generations, and no stage pins a generation
a later swap retires.
Comment on lines +250 to +254
var ordered = new List<ModuleGeneration>();
var visited = new HashSet<string>(StringComparer.Ordinal);
foreach (var generation in current.Values.OrderBy(g => g.Name, StringComparer.Ordinal))
Visit(generation);
var withdrawn = new HashSet<string>(StringComparer.Ordinal);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants