What the DataContext Init Time-Box Bounds

Every hub that carries data runs a 120 s time-box around its DataContext initialization (DataContext.OpenInitializationGate). When it expires, a hub that routing re-creates on demand (every per-node hub) is retired and comes back on the next access; any other hub enters a FAILED state and answers every later request with a terminal DeliveryFailure. The bound is a liveness guarantee and it stays — see Initialization Gates. This page is about what it is actually waiting on, because the timeout used to guess and the guess was wrong.

The wait is nested three deep

DataContext.tasks                                   ← the 120 s time-box waits on this
  └─ IDataSource.Initialized                         = Task.WhenAll(each stream's sub-hub.Started)
       └─ sync/{clientId} sub-hub BuildupAction      = SynchronizationStream.Initialize
            └─ GetInitialValueAsync                  = SelectMany over EVERY type source
                 └─ ITypeSource.Initialize(…).Take(1)     + Aggregate + FirstAsync

Two consequences follow directly from that shape:

For a per-node hub it is one storage read

A default per-node hub has exactly one data source (AddMeshDataSource) with one type source, MeshNodeTypeSource. Its Initialize concatenates a durable read ahead of the routing-supplied node:

DurableSeed()                  IStorageAdapter.Read(hubPath) — NO wall-clock bound, on purpose
  .Concat(_ownNodeStream)       subscribed only after the read settles

The missing bound is deliberate and documented on DurableSeed: a timeout there would let a per-node hub seed from stale routing state, which is the acked-write-loss family. So for a per-node hub the 120 s box is, in practice, a box around one storage read, which queues on the process-wide pg-read gate (cap 16) — see Controlled I/O Pooling for that gate's measured wait profile.

"A stuck NodeType compile" cannot reach this time-box

The timeout message used to end "— likely a stuck NodeType compile, or a data source that never initialised". The first candidate is unreachable on this path:

So the sentence sent every reader towards a mechanism the platform does not implement. Because a LogIncident fingerprint is category + message template + exception type, every cause folded into one issue behind it: 217 occurrences over five weeks, covering at least three populations the text could not tell apart — a pod-wide wave of four hubs timing out inside one millisecond; a mixed _Access / _Activity / _Issue wave; and one user partition whose per-node hubs all went dark for 34 minutes.

The timeout names what it was waiting on

At the instant the box expires every layer of the wait can be inspected, so the message now reports it instead of guessing:

Hub 'sglauser/_Answers/…/Quiz' DataContext initialization did not complete within 120s.
Still waiting on 1 of 1 data source(s): 'MeshNodes (MeshDataSource)' — 1 of 1 stream(s) never
produced a first frame (sync/… stream=… owner=… partition=(none)), type-source legs still
outstanding: …/MeshNode.

The ledger is diagnostic only: nothing waits on it and nothing branches on it. The log template is unchanged, so the existing incident keeps collecting its history while every sample line now carries the cause.

🚨 For a per-node hub the leg alone did NOT separate those two — it now says which half

The claim just above — that the leg "separates a storage read never came back from a remote hub never answered" — was not true for the case that matters most. A per-node hub has ONE leg, …/MeshNode, and that leg is MeshNodeTypeSource.Initialize: the durable read concatenated ahead of the routing-supplied own-node stream (above). The first attributed production sample after the ledger shipped, Collaboration on memex-cloud (2026-09-21 17:06:24Z, Systemorph/MeshWeaver#1122), read "type-source legs still outstanding: 7j8ehN2m0UCo51iBcrGL2A/MeshNode" — and could be either.

A type source that implements IReportsInitialLoadProgress now has its own sentence appended to its leg, and MeshNodeTypeSource reports which half is outstanding:

sentence after the leg where to look
[durable seed read of 'P' outstanding for 115.0s — a storage read that has not come back] storage: the pg-read gate's wait, a wedged adapter
[durable seed read of 'P' found no row after 0.1s; the routing-supplied own-node stream has not emitted] the routing / stream-cache side — the read is done and the node never arrived
[… ; the routing-supplied own-node stream emitted N time(s) and none was accepted] the own-node gate dropped every emission (a null, or a stale version)
[durable seed read of 'P' FAULTED (…) after …] the read faulted and degraded to the routing leg, which then did not deliver

Still diagnostic only: the progress is written from the load's own callbacks and read by the failure path; nothing waits on it. Pinned by DataContextInitTimeoutNamesTheWaitInsideTheLegTest (the rendering — red with the ledger printing keys only) and MeshNodeTypeSourceInitialLoadProgressTest (the sentences, and the TrackSeed/TrackRouting wiring Initialize composes, driven with controllable streams). A reporter that throws is printed as [progress report FAULTED (…)] rather than escaping: the rendering runs inside SettleInitializationGate, and with that guard removed the hub wedges instead of reaching FAILED — measured, it is the second assertion of the Data test.

A failed init errors every stream it holds, and creates none

The failure used to be propagated with ds.GetStreamForPartition(null).OnError(failure). That accessor is get-or-create, and the call was wrong twice:

  1. It errored one stream of however many the source held. Every other stream — each partition stream of a partitioned source — was left un-errored, so its subscribers were never told and each waited out an unrelated deadline instead. That is how one stall produced four log sites reporting four different causes: path-resolution timeouts, a broken quiz, and two 120 s bounds, none naming the stall.
  2. On a source with no null-partition stream it minted one. PartitionedHubDataSource.Initialize opens only its declared partitions, so the null key misses, and a SynchronizationStream constructor always builds its sub-hub. The failure path therefore built a hub and a second container for a hub it had just declared FAILED — and the null-partition stream of that source opens one remote stream per declared partition, each starting its own 120 s initialization against the dependency that had just failed to answer.

The failure path now walks IDataSource.OpenStreams — presence only — and errors each one.

A timed-out activation is retired, not latched

Policy init-timeout-retires-activation. When the time-box expires on a hub that demand routing re-creates — WithReactivationOnDemand, which every per-node hub declares in NodeTypeRebindWatcher — the hub is disposed, and the next access re-creates it and initializes again. It used to take the FAILED latch: InitializationError recorded, a rejection handler answering every later request terminally, for the life of the process. That turned one stall into an outage of the address until a restart — the 2026-09-15 event in #1122 took one user's course navigation, quiz and activity tracking dark for 34 minutes, and a transient fault on the same hubs was already retired rather than latched (Retiring an Activation).

DataContext.SettleInitializationGate does it in the transient branch's order — FailGate first, with the classification stated, then Dispose() — and logs at Error:

fail: DataContext initialization TIMED OUT for {Address}. Retiring this activation instead of
      latching it FAILED — the next access re-creates it; nothing retries on its own.
      System.TimeoutException: Hub '…' DataContext initialization did not complete within 115s.
      Still waiting on …

🚨 That is a new log template. The old one ("… Hub is now in FAILED state.") would now be a false sentence for these hubs, so a LogIncident watching this fault collects a new fingerprint from the first roll that carries the change; the old fingerprint keeps collecting only hubs that still latch (below).

Why it cannot storm

The objection recorded when the latch was kept was that retiring on a timeout trades a latch for a loop. It does not, for four reasons that each hold on their own:

Property What makes it true
Self-pacing. A permanently stuck address costs at most ONE activation per time-box, whatever the caller's rate. The branch runs only after the whole box expired on this activation, and activation is single-flight per address (HostedHubsCollection's creation Lazy; one grain activation on Orleans). A hot caller's deliveries park behind the one gate; none mints a hub.
No re-ask. Nothing re-creates the address without an access. The parked backlog is answered ErrorType.Failed, in words no transient classifier matches — deliberately not the ShuttingDown banner the transient retirement uses. A ShuttingDown answer is ridden out by every [ReaskedOnShutdown] producer and resubscribe latch, which would re-create the address by itself: a background retry loop under another name. There is no timer.
Re-access is already bounded on the read path. MeshNodeStreamCache records every non-missing-node fault in its transient breaker: the first TransientGraceFailures re-probe at once, after that re-probes back off exponentially up to TransientMaxCooldown, and the cached fault is replayed without opening an upstream subscribe while the window is open. A successful read or a change-feed invalidation clears it. No new breaker was needed.
Only the timeout retires. A deterministic init fault fails in milliseconds, so retiring on it would re-create at the caller's rate — that is the loop the latch guards against, and a non-transient fault keeps the latch. A time-out is paced by its own box.

What still latches

Pinned by

DataContextInitTimeoutRetiresTheActivationTest (Data.Test, HubTestBase, no mocks):

What this does not change

Tests