Stale State Until a Recycle

"The grain keeps serving old state until we send a dispose request." — maintainer, 2026-09-17

A per-node hub — on Orleans, a grain activation — resolves its HubConfiguration once, while it is activating, and is then pinned by address. MeshNodeHubFactory says so at the seam that does it:

"The binding above is made ONCE and then PINNED by address: routing short-circuits on GetHostedHub for an already-hosted address and never resolves the path again, so nothing re-reads the NodeType for the hub's whole lifetime."src/MeshWeaver.Graph/Configuration/MeshNodeHubFactory.cs

Every change a portal absorbs while it keeps running — a merge that syncs in, a package installed or updated, a NodeType recompiled in place, a new build published — changes what the next activation would load, and reaches a live one through nothing at all. The failure has no signal of its own: no error, no warning, no log line, nothing to grep. The address answers promptly and answers the old thing. That is the missing half of three recurring stories — "merged and rolled is not loaded", "the NodeType recompiled but the hub still serves the previous assembly", and "the fix is in the image and invisible in the running mesh".

🚨 A pod restart or a roll is NOT in that list, and saying otherwise is the easy overstatement. Restarting a pod tears down every activation it hosts, so those addresses re-activate and re-read on their next access; a roll replaces the pods and does the same. What a roll leaves open is the second half of this page — what the fresh activation then chooses — plus anything that was pinned at process start. The gap this page is about is the running portal, not the restarted one.

What an activation pins, and what stays live

Live, and needing no recycle: content. GetMeshNodeStream(path).Update(...) and node CRUD route to the owning activation, and MeshDataSource holds a long-standing own-node subscription that picks up every emission; activation only seeds the workspace from the node routing already read. Cross-hub updates travel the change feed. This is why editing, chat and every data-bound view work without anyone recycling anything. See CQRS — Queries vs. Content Access.

Pinned for the activation's lifetime:

What Why a change to it cannot reach a live activation
The NodeType binding the hub was born with Resolved once in MeshNodeHubFactory.ResolveHubConfiguration, then short-circuited by HostedHubsCollection. "If the node acquires — or changes — its type while the hub is alive, the hub keeps serving the configuration it was born with for the rest of its lifetime" (NodeTypeRebindWatcher, #1104).
The compiled assembly and its collectible load context Resolved during that same enrichment (CompilationCacheService.GetOrCreateLoadContextForPath); the configuration delegate's closure captured types from that ALC. Only idle deactivation or a recycle ever replaced the binding.
Layout-area streams, the stale-build $Banner subject, the armed watchers Hub-owned, created in WithInitialization / hub.Set(...), destroyed with the hub.
Anything read from outside the mesh — the store modules under /data/modules "Pinned at process start and never swapped" — so this one is pinned to the PROCESS, not the activation, and a recycle does not touch it; a restart is its only lane, by construction.
A node written around the mesh — a raw psql UPDATE on a live portal The write never passes the workspace, so no activation hears about it. (Which is why that is forbidden — see Postgres Schema Architecture.)

🚨 Tests are not exempt. A shared fixture — the canonical case being an Orleans TestCluster with one silo per xUnit collection — keeps activations between tests, and "the grain caches its config, Test B activates a different node at the same path, reads stale state from Test A, and fails for reasons entirely unrelated to its own logic" is the same pinning seen from the test side. Disposing the hubs a test created is one of the two halves of Test State Isolation; the exemption is a fixture that builds and tears down its own mesh, not "it is a test".

"Published" therefore does not imply "every instance is running it". The framework states the consequence at the site that chose it:

"OFFER as the DEFAULT. The unconditional self-DisposeRequest that once lived here recycled every live instance on every publish … Stated consequence: an instance whose viewer never clicks keeps serving the OLDER assembly indefinitely — safe, since that build worked, but 'published' no longer implies 'every instance is running it'."NodeTypeEnrichmentHelpers.ArmStaleAssemblySelfHeal

Tearing the activation down is the only thing that makes it re-read

A DisposeRequest posted to the address ends the activation; demand routing re-creates the hub from its node on the next access (.WithReactivationOnDemand(), declared at the single funnel every per-node activation passes — Monolith routing and MessageHubGrain). The fresh activation re-resolves the NodeType, re-runs adopt-or-compile, rebuilds the data context and re-arms the watchers. There is no partial refresh and no invalidation hook: the lifetime of an activation is the unit.

The one framework surface — dispose-only

hub.RecycleNode(path, reason: "…")src/MeshWeaver.Mesh.Contract/HubRecycleExtensions.cs. It posts a DisposeRequest and waits. It requests no compile; the fresh activation binds whatever the store and the adoption lane then offer.

The operator / agent surface — dispose, plus a FORCED rebuild when the target is a NodeType

The recycle verb (MeshOperations.Recycle) backs the recycle MCP tool, a node's Recycle menu entry and mw recycle <path>. It checks Update permission on the target (a refusal is an answer rendered in the operation's envelope, not a fault), waits on the same install lease, and broadcasts a cache invalidation on the change feed. And only when the target is a NodeType node (IsNodeTypeNode — content is a NodeTypeDefinition, or nodeType is the NodeType path) it stamps RequestedReleaseAt and RequestedReleaseForce = true before the dispose. Recycling an ordinary page or instance stamps nothing and recompiles nothing — it is a plain teardown.

🚨 That forced flag is what makes the operator verb a rebuild rather than a re-bind, and it is the opposite of an adoption. NodeTypeCompilationHelpers short-circuits on it:

"A FORCE MEANS 'BUILD THE LIVE SOURCE', NOT 'SERVE ME WHATEVER A BUNDLE STILL RESOLVES' … until now this branch asked the bundle sources again regardless. On any mesh whose bundle still resolved, a force therefore re-adopted the very bytes the operator was trying to replace … That is how the stale prebuilt in #2813 could not be forced off a node whose live source was already fixed." — #2818

So on a NodeType the operator recycle is the "rebuild from what is there now" remedy. The stamp is also sequenced before the dispose, never merely issued alongside it: when the dispose won that race, the reactivated hub re-ran its source query against a half-invalidated state, matched zero Code nodes and recompiled the pre-fix source.

🚨 The Compile button is not a recycle. It writes the same RequestedReleaseAt / RequestedReleaseForce stamp through GetMeshNodeStream(hubPath).Update and posts no DisposeRequest. It rebuilds the type; it does not re-bind a live instance.

Three rules for posting one, each paid for

A recycle is announced to the subscribers it is about to orphan. HandleDispose calls AnnounceRecycle() on the turn the request is handled, while the hub is whole, because the teardown itself is silent by construction — a dying owner reaching up the hub tree for a last word resurrects the activation it is retiring (#2533 / #2551; the mechanism is in Hub Disposal Model).

🚨 A dispose makes the activation RE-READ. It does not change what the re-read FINDS

This is the half that turns a recycle into a ritual. A recycle is never itself a fix, and a second one proves nothing the first did not.

The same bytes come back. The assembly store is keyed (nodeTypePath, LastCompiledVersion), a recompile of an already-Ok type does not rewrite its node, and each pod resolves those bytes through its own local cache:

"A PATH is not an identity … so the path can match perfectly while the bytes behind it differ per replica. That is why a recycle is inert: it re-binds the same path from the same local copy."ServedBuildIdentity

That is why a same-path build mismatch (StaleBuildKind.ServedBuildIsNotPublished) is reported as a mismatch and deliberately carries no recycle link, while only a genuine path advance (NewerBuildAvailable) earns one (#2471). It was measured on memex on 2026-08-26 over 30+ minutes and six recycles, with every surface reporting success.

And an UNFORCED trigger can re-choose the same bundle. A release request that is not forced runs adopt-before-compile: the deployment's prebuilt bundle sources get one bounded chance to supply the assembly first, and an adopted type then satisfies its release request without Roslyn ever running. Modules:VersionStrictness decides what counts as a candidate, and its default is Family (Minimum on a Development host) — a bundle sealed for another identity of the same major line is adopted when its declared floor is satisfied and its type links resolve. So a bundle baked against a different platform surface is a legal candidate, the question is asked again, the same bundle is still the only answer, and the identical MVID lands.

🚨 This is exactly what RequestedReleaseForce exists to defeat, so it is not what the operator verb or the Compile button do — both force. Where it bites is the unforced lanes (install, boot, a watcher's own trigger), and on a Modules:RequirePrebuilt mesh, where there is no local compile to fall back to at all.

🚨 And in that unforced shape NO INSTRUMENT REPORTS IT, which is what makes it expensive. Measured on memex, 2026-09-17: Approvals/Desk was recompiled at 17:35 (v844 → v846) by a pod of the other generation while the deployment was split across two images. It came out with the same MVID, still stamped the foreign framework identity, and recorded buildProvenance: AdoptedVerified. Nobody ran the recycle verb, so nothing was forced — a boot-time compile re-adopted a prebuilt bundle, exactly the clause above. Neither of the two instruments a reader would reach for said so: /health's content-types records a degradation only when a content read degrades, and bake-report reads the shared record, which said Ok. All the reader had was a blank area and No renderer is registered for area … — and two hours. The question none of them answers is "is there a NodeType this replica cannot serve?", and the census built to answer it — per-type outcome, with its denominator stated, and with "no sweep has reported here" printed as its own sentence rather than as a clean one — is #4647's addition to bake-report. Read the outcome, not the plan, when a page is blank and the record says Ok.

The remedies there, none of which is another dispose-only recycle:

See Execute-Time Interlock for the provenance verdicts (AdoptedVerified, AdoptedUnverified, AdoptionRefused, StaleAdopted) — a dispose moves none of them — and Install-Time Prebuilt Adoption for the adoption lane itself.

Three more things a recycle does not do:

One symptom has stopped needing it: a cache failure era on a path is now cleared by the change feed resetting the storm breaker and evicting faulted entries (#3954), so "recycle is no longer load-bearing for this symptom" — it is a redundant second cure rather than the only escape. See The MeshNode Stream Cache.

The four automatic recyclers — real, and not a guarantee

Recycler Fires when Default Install-lease gated
NodeTypeRebindWatcher a post-commit change event retypes this path away from the bound type (#1104) — Take(1) always armed yes
WithOverlaySelfHeal a DEGRADED activation (error overlay, slow-path timeout, unresolved pin, missing bytes, compiling) reaches a usable build, by version advance, grace timer or the re-evaluation ladder — Take(1) always, on degraded branches no — by design: it recycles per-TYPE hubs an install is often waiting for
ArmStaleAssemblySelfHeal — convergence branch a NodeType publishes a usable build whose assembly path advanced past the bound one, throttled by a 10 s settle window — Take(1) config: Modules:AutoRecycleOnStaleBuild; the code default is OFF (absent or unparseable means OFF), the AKS chart sets it true no
PackageInstaller.SettleRetypedRoot an install retyped its own package root and the in-package type has a loadable build always, for such an install no — it is the lease holder, and deferring a holder against its own lease deadlocks

The first three post to their own instance hub, which is the sanctioned shape and the reason DisposeRequest carries a Reason at all — [QUIESCE-START] could otherwise only say "requested by itself — a rebind or self-heal recycle", one sentence covering three states. SettleRetypedRoot is the exception: it issues off-router through hub.NodeOperationIssuingHub() at the package root and then waits for that root to answer, so it is a routed teardown, not a self-post.

🚨 Read the EFFECTIVE Modules:AutoRecycleOnStaleBuild, not the code default. They disagree, and each reading is wrong about the other's portals:

On neither setting does it fire for a same-path byte change: a path that did not move is a build mismatch to report, not a build to converge on, because re-binding it lands the same bytes (#2471).

Where this bites, and what to do about it

Finishing a change set. A pull request that changes something a per-node hub serves — a NodeType's source, node content shipped in an image or a bake, a layout area compiled in the mesh — is not proven live by the merge, the seal, the image tag or the roll. Exercise the feature against the running address. If it still answers the old way, recycle that address and exercise it again; if it still answers the old way, the activation was never the problem and a third recycle will not find it. Name the addresses a deploy has to recycle in the PR body — a recycle nobody knew to run is indistinguishable from a fix that did not work.

Debugging. Before tracing a message flow for a fix that "did not take", ask whether the activation predates the change. Debugging Message Flow separates a hang from a wrong answer; this page separates a wrong answer from a stale one, which needs no debugging at all.

Operating a portal. Operations go through the control instance's Hosting API, never the cluster — see Operating from the Portal. A Roll moves the image; it does not follow that every address is serving it.

Reconnecting…
The connection to the server was interrupted. Trying to restore it…
Trying again…
The connection could not be restored. Reloading the page…
The server was updated. Reloading the page to pick up the latest version.