The Initialization Budget Ladder

A bound nested inside another bound has to be able to fire FIRST, because it is the only one that knows WHICH wait starved. The enclosing bound can say no more than "initialization ran out of time". This repo already states that rule twice — MeshOperationOptions expresses it for writes (see The Query Fan-In's Initial Stall Is a Terminal for its innermost rung) and ReadBudget expresses it for reads — and this page is the same rule applied to hub initialization.

Initialization is a nested wait

a hub's DataContext time-box                     ← waits on its data sources
  └─ IDataSource.Initialized                      ← waits on each stream's first frame
       └─ the sync/{clientId} sub-hub              ← a HUB, with an initialization of its own
            └─ its BuildupAction Concat            ← bounded by the sub-hub's own time-box

Every level of that nest used to take the same number, 120 s, written independently in three places: MessageHub's buildup bound, DataContext.InitializationTimeout, and the sub-hub's own copy of the first. Nothing in the code said the three were supposed to be ordered at all.

Equal is not an ordering

The clocks are armed microseconds apart, on different action blocks. DataContext.InitializeDataSources creates the sub-hubs a few microseconds before OpenInitializationGate arms the enclosing time-box — but the sub-hub's own bound is armed on the SUB-HUB's action block, after a scheduling hop, so which of the two expires first depends on how loaded the runner is at that instant. In the event consolidated on Systemorph/MeshWeaver#1122 the two lines were 5 ms apart.

That gap is not a curiosity, because the two outcomes are not symmetric:

The ladder

Exactly ONE bound is configured per hub — MessageHubConfiguration.InitializationBudget, which is either an explicit WithStartupTimeout or HubInitializationBudget.Root for a hub nothing is waiting on. Everything nested inside it is DERIVED by HubInitializationBudget.Nest, which is strictly contracting, so the ordering holds by construction and cannot drift apart again:

rung value what it bounds
1 InitializationBudget this hub's WHOLE initialization, as whatever waits on it bounds it
2 NestedInitializationBudget = Nest(rung 1) one wait inside it: the BuildupAction Concat, and the DataContext time-box
3 rung 1 of a hub created DURING this one's initialization = Nest(rung 2) that hub's whole initialization — this hub's rung-2 waits are what wait on it

Nest(t) = max(t − 5 s, t × 0.5), the same shape MeshOperationOptions.Nest uses. The reserve is absolute because what it covers is absolute: a hub construction, a post, and the inner hub's first turn reaching HandleInitialize on its own action block. The fraction floor only bites at the short budgets tests configure, where subtracting the reserve would drive a rung to zero.

It carries MeshOperationOptions' 1 ms domain as well as its formula, and for the same stated reason: below that, halving a tick truncates to zero, two rungs land on the same instant, and a timer at TimeSpan.Zero fires at once — the equal-bounds collision in its worst form. The domain is not academic, because the fraction floor halves per level: a configured 2 s budget reaches it around the twelfth level of nesting. Nest refuses a smaller enclosing bound rather than collapsing the ladder, and NestedInitializationBudgetTest pins both sides of that boundary.

At the default this reads 120 s / 115 s for a hub nothing is waiting on, 110 s / 105 s for a hub created inside that one's initialization, and so on down.

The two rung-2 bounds are SIBLINGS, and that is why they may be equal

The BuildupAction Concat and the DataContext time-box both take rung 2. They are not nested: DataExtensions.StartDataSourcesAndOpenGate arms the time-box and then completes at once, so only one of the two is ever the wait that is still open. Equal-by-coincidence is the defect; equal between siblings over disjoint subjects is not, and the word NESTED is doing all the work in the rule.

Where the rung is stamped

MessageHub.TryGetHostedHub stamps the host's rung 2 onto the new hub's configuration as it is created.

🚨 HOSTED is not ENCLOSED, and reading them as the same narrows a production bound for nothing. A per-node hub IS a hosted hub of the mesh root — both MessageHubGrain and MeshExtensions create it that way — but routing activates it on demand long after the mesh hub reached Started, so nothing in the mesh hub's initialization is waiting on it. The stamp is therefore applied only while the host's RunLevel is below Started, which is exactly the window in which one of its rung-2 waits can be waiting on the new hub (a data source's stream is served by a sync/{clientId} sub-hub built inside StartDataSourcesAndOpenGate). A per-node hub consequently takes the root budget, and only the sub-hubs born inside its initialization contract.

It is stamped, never resolved on read: MessageHubConfiguration.ParentHub answers out of the parent scope's IMessageHub registration, which for a hub built directly on a root provider resolves to the hub ITSELF — deriving the ladder from it recursed until the stack died (Stack overflow, exit 134, the whole test host down with zero tests run). The host is known at the one moment that matters, so the value is carried.

What changes, and what does not

Tests

See also