Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 59 additions & 9 deletions src/MeshWeaver.Compiler.Pipeline/PrebuiltAssemblySeeder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -926,8 +926,9 @@ IObservable<SeedOutcome> Write(SeedOutcome onStamped)

return store
.PutWithLocation(nodeTypePath, version, assemblyBytes, pdbBytes)
.SelectMany(location => workspace.GetMeshNodeStream(nodeTypePath)
.Update(current =>
.SelectMany(location =>
{
MeshNode Apply(MeshNode? current)
{
var def = current?.ContentAs<NodeTypeDefinition>(hub.JsonSerializerOptions);
if (current is null || def is null)
Expand Down Expand Up @@ -1026,9 +1027,49 @@ IObservable<SeedOutcome> Write(SeedOutcome onStamped)
};
stampedDefinition = adopted;
return current with { Content = adopted };
}))
.Select(_ =>
}

IObservable<System.Reactive.Unit> StampWrite() => workspace
.GetMeshNodeStream(nodeTypePath)
.Update(Apply)
.Select(_ => System.Reactive.Unit.Default);

// 🚨 #5544 — THE ADOPTION STAMP IS A PUBLICATION, and it goes through the join
// point like the two compile stamps always did (#3478). It carries the same
// CompiledFrameworkVersion + assembly coordinates into the same shared record,
// so a replica that has not passed its own readiness validation re-keyed every
// record its bundles covered while the serving replicas — on another framework
// — lost their usable build: 168 adoptions per boot of the 9260 pod on memex,
// across five restarts, none of them ever Ready. Held while this process is
// provisional, released when its bake passes, discarded when it fails.
var admissionGate = hub.ServiceProvider.GetService<MeshPublicationGate>();
return (admissionGate is null
? StampWrite().LastOrDefaultAsync().Select(_ => MeshAdmission.Unarmed)
: admissionGate.Offer($"prebuilt adoption stamp for {nodeTypePath}", StampWrite))
.Select(admission =>
{
// A HELD stamp is still an adoption THIS process may build its sweep on:
// the bytes are on the store under this framework's key, and the record
// it would write is exactly what the ledger below needs. Computed off the
// owner's snapshot this pass already decided on — the same lambda, never
// written — so the local ledger and the eventual release cannot describe
// two different stamps.
if (admission is MeshAdmission.Provisional)
Apply(node);
Comment on lines +1057 to +1058

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not changed, on purpose. It is the same lifecycle the two held COMPILE stamps have had since #3478, and it heals the same way. Ledger: NodeTypeAdoptionRegistry is only read by OverlayOnto when the sweep classifies, and that sweep has already run by the time a refusal and then a retraction can happen, so a stale ledger entry cannot misclassify anything later. Record: if the adoption was discarded and the process is re-admitted, the shared record still names the serving replicas' framework, so it reads as Foreign to this process (NodeTypeBuildIdentity.RefusalReason). The next activation of that type here takes DecideFrameworkStale -> Recompile (the process is not leaving, so there is no #5353 yield). That compile's stamp goes through the now-admitted gate and lands. Re-offering discarded publications on re-admission would give a refused image a replay path for everything it produced while refused. Discarding them is the property #3478 asks for.

return admission;
});
})
.Select(admission =>
{
if (admission is MeshAdmission.Refused)
{
logger?.LogInformation(
"Prebuilt assembly for {NodeTypePath} NOT stamped: this process failed "
+ "its own readiness validation and publishes nothing into the shared "
+ "record (#3478/#5544) — the record is left as the serving replicas wrote it",
nodeTypePath);
return SeedOutcome.NotSeeded;
}
if (!stamped)
return SeedOutcome.NotSeeded;
// 🚨 #3703 — the ledger entry, written on the same branch as the ADOPTED line
Expand All @@ -1040,11 +1081,20 @@ IObservable<SeedOutcome> Write(SeedOutcome onStamped)
if (stampedDefinition is { } written)
hub.ServiceProvider.GetService<NodeTypeAdoptionRegistry>()
?.RecordAdopted(nodeTypePath, version, written);
logger?.LogInformation(
"Prebuilt assembly ADOPTED for {NodeTypePath} at version {Version} "
+ "(framework {Framework}, module version {Module}) — no compile needed",
nodeTypePath, version, NodeTypeCompilationHelpers.FrameworkVersion,
ModuleVersionCompatibility.Display(moduleVersion));
if (admission is MeshAdmission.Provisional)
logger?.LogInformation(
"Prebuilt assembly ADOPTED for {NodeTypePath} at version {Version} "
+ "(framework {Framework}, module version {Module}) — no compile needed; "
+ "the shared record's stamp is HELD until this process passes its "
+ "readiness validation (#5544), so the serving replicas keep their own build",
nodeTypePath, version, NodeTypeCompilationHelpers.FrameworkVersion,
ModuleVersionCompatibility.Display(moduleVersion));
else
logger?.LogInformation(
"Prebuilt assembly ADOPTED for {NodeTypePath} at version {Version} "
+ "(framework {Framework}, module version {Module}) — no compile needed",
nodeTypePath, version, NodeTypeCompilationHelpers.FrameworkVersion,
ModuleVersionCompatibility.Display(moduleVersion));
return onStamped;
});
}
Expand Down
35 changes: 35 additions & 0 deletions src/MeshWeaver.Documentation/Data/Architecture/MeshAdmission.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,21 @@ that carry a process's own build identity are few and enumerable:
|---|---|---|
| NodeType compile stamp, batch path | `NodeTypeBatchBake.WriteStamp` | `CompiledFrameworkVersion`, `CompiledModulesHash`, `CompiledDependencies`, assembly coordinates |
| NodeType compile stamp, activation path | `NodeTypeCompilationHelpers` (post-compile write-back) | the same field-set — the two paths share it literally |
| NodeType **prebuilt adoption** stamp | `PrebuiltAssemblySeeder` (the bundle seeding every boot runs, and on-demand adoption) | `CompiledFrameworkVersion` + assembly coordinates — the same poison, reached without a compile |
| module-set adoption | `ModuleSetStore.RecordAdoption` | "a replica is serving set N" |

🚨 **The adoption row was missing until #5544, and the gap was measured.** memex's 9260 pod
(2026-09-23/24) never became Ready across five restarts, and each boot's bundle seeding logged
`Prebuilt assembly ADOPTED … (framework s9e58a…)` 168 times before its sweep began. The two serving
9218 replicas' LIVE RECORD CENSUS then read *"21 NodeType record(s) were RE-KEYED to a framework
this replica does not run AFTER it booted"* — the #3472 outage shape, produced by a process the gate
was holding for every OTHER stamp. It is the inverse of #5353's bind-path yield: that one stops a
LEAVING process re-keying backwards, this one stops a process that has not JOINED re-keying forwards.
A held adoption is still an adoption **for this process**: the bytes are on the store under this
framework's key and the definition goes into `NodeTypeAdoptionRegistry`, so the sweep classifies the
type as baked exactly as before — only the shared record waits for the verdict. The seeder learns
which verdict applied from `MeshPublicationGate.Offer`, never from a second read of `Admission`.

**Assembly BYTES are deliberately not gated.** The assembly store is keyed
`(nodeTypePath, version)` with the producing framework identity baked into the key, and
`NodeTypeBakeStatus.ClassifyDetailed` cannot look for bytes at all until the RECORD names a
Expand Down Expand Up @@ -111,6 +124,28 @@ So the retraction now has two witnesses, and takes whichever answers first:
- **the record**, unchanged — a type baked by a *peer* on the same image is a real recovery the
local signal cannot see, and dropping it would narrow the retraction rather than widen it.

🚨 **The activation sweep's per-type wait needed the same second witness, and did not get it until
#5544.** `DynamicTypePreWarmer.WarmOne` activates a type and waits for its record to show a usable
build — and on a gated pod that record cannot move until the bake passes, which cannot happen until
this wait answers. A deadlock by construction: memex's 9260 pod fell back to the activation path
when its batched source discovery failed, and every one of its 107 pending types logged
`→ TimedOut — 300.0 s`. 107 × 5 minutes outlasts the three-hour startup probe, so the pod was killed
and restarted into the same sweep five times. `WarmOne` now merges `LocalNodeTypeBuilds` exactly as
the recovery watch does. What it still cannot hear locally is a compile that ran on ANOTHER silo
(the type's grain placed on a serving replica) or a local FAILURE (only successes are published
locally) — those still time out as *unevaluated*, never as a verdict.

### The readiness message names the step it is waiting on

The gate's detail used to be set once — `enumerating dynamic NodeTypes` — before the bundle seeding,
and held for the whole sweep, so the pod above reported "enumerating" for eleven hours while it was
30 types deep in an activation sweep. `NodeTypeBakeGateState.MarkProgress` now rewrites it at every
step while the phase is `Running`: waiting for the static import, adopting bundles, the enumeration
count, the build-claim election, **following another replica's build (the one unbounded wait — it
ends only on a GO or on the claim falling free)**, the batched discovery, and per type
`building k of n … waiting on <type>, for up to <budget>`. A terminal verdict is never overwritten
by a late progress line.

## One predicate, two consumers

The defect #3478 names is a **divergence**: two verdicts, one of them enforced. So admission is not
Expand Down
18 changes: 15 additions & 3 deletions src/MeshWeaver.Hosting/BuildProtocolDriver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,19 +87,25 @@ public static TimeSpan CoordinationBackoff(int attemptsSoFar) =>
/// <param name="store">The shared assembly store, for the follower's post-GO probe.</param>
/// <param name="bake">The actual sweep to run when this process wins the claim.</param>
/// <param name="logger">Diagnostics.</param>
/// <param name="progress">Names, for the readiness message, what this process is waiting on
/// (#5544) — the claim election, or another replica's build.</param>
/// <returns>The sweep's outcomes (winner), or the post-GO probe's outcomes (follower).</returns>
public static IObservable<PreWarmOutcome> Run(
IMessageHub mesh,
NodeTypeBakeReport report,
IReadOnlyDictionary<string, NodeTypeDefinition?> definitions,
IAssemblyStore store,
Func<IObservable<PreWarmOutcome>> bake,
ILogger? logger)
ILogger? logger,
Action<string>? progress = null)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Kept as an optional parameter on purpose. The only consumer outside core is MeshWeaver.Plugins, and it compiles against a pinned core source (MW_PLATFORM_REF), not against a shipped binary, so there is no already-compiled caller to break. I tried the overload route first (in this branch) and it breaks SOURCE instead: every existing cref to Run / WarmDynamicTypes turns into CS0419 under -warnaserror (6 sites in core alone), and Plugins' DynamicTypePreWarmerTest.cs:108 has and :171 does typeof(DynamicTypePreWarmer).GetMethod(nameof(WarmDynamicTypes)), which throws AmbiguousMatchException once a second overload exists. Every current call site (BuildCoordinationTest.cs:128/256/304, DynamicTypePreWarmerTest.cs:71/235) uses positional or named arguments that still bind to the optional-parameter shape.

{
// Unique per process RUN: two boots of the same pod must not look like one claimant, or
// the second boot would inherit (and heartbeat) a claim whose bake died with the first.
var holder = $"{Environment.MachineName}/{Guid.NewGuid():N}";
var fingerprint = report.FrameworkVersion;
progress?.Invoke(
$"asking for the build claim for framework {fingerprint} — the claim decides whether "
+ "this process bakes or follows another replica's build");

// A DEDICATED bake process outranks every serving pod in the claim election (#1424): when
// a bake Job is running, the pods lose deterministically, follow its GO, and never pay the
Expand Down Expand Up @@ -137,7 +143,7 @@ public static IObservable<PreWarmOutcome> Run(
.Catch((TimeoutException _) => Observable.Return(false))
.SelectMany(granted => granted
? BakeAsMaster(mesh, holder, fingerprint, definitions, bake, logger)
: FollowGo(mesh, holder, fingerprint, definitions, store, bake, logger))),
: FollowGo(mesh, holder, fingerprint, definitions, store, bake, logger, progress))),
mesh, fingerprint, definitions, store, logger);
}

Expand Down Expand Up @@ -635,8 +641,14 @@ internal static IObservable<PreWarmOutcome> FollowGo(
IReadOnlyDictionary<string, NodeTypeDefinition?> definitions,
IAssemblyStore store,
Func<IObservable<PreWarmOutcome>> bake,
ILogger? logger)
ILogger? logger,
Action<string>? progress = null)
{
// 🚨 #5544 — this wait is deliberately UNBOUNDED (see above), so it is the one wait whose
// readiness message must say what ends it: another replica's GO, or the claim falling free.
progress?.Invoke(
$"following another replica's build of framework {fingerprint} — waiting for its GO "
+ "(or for its claim to fall free, which hands the bake to this process)");
logger?.LogInformation(
"BuildProtocol: claim held elsewhere — {Holder} follows the build for framework "
+ "{Fingerprint} (durable witness + this cluster's GO + its own claim candidacy)",
Expand Down
Loading
Loading