Skip to content

Build the re-evaluation lane, so the toolchain MVID can demote from invalidation unit to trigger #1976

Closed

Description

@rbuergi

Follow-up to #1707 slice 1 (#1712 / #9d55ac5df). The extraction did what it said — MeshWeaver.Graph is no longer a full-MVID root — but measured, the identity now moves MORE often than it did before, not less.

What slice 1 was for

"Factor out minimalistic build tools … so FrameworkBuildIdentity.FullMvidAssemblies pins a low-churn assembly instead of the highest-churn one in the repo." — #1707, requirement 2

and its acceptance:

"A body-only edit anywhere in MeshWeaver.Graph outside the toolchain rebakes nothing."

The acceptance line holds. The requirement it was serving does not.

Measured

FullMvidAssemblies is no longer a list — it is a computed transitive closure over the roots' MeshWeaver.* AssemblyRefs (src/MeshWeaver.Compiler/FrameworkBuildIdentity.cs:192-219):

internal static readonly ImmutableArray<string> ToolchainRoots =
    ["MeshWeaver.Compiler", "MeshWeaver.NuGet"];

private static readonly Lazy<ImmutableArray<string>> _fullMvidAssemblies = new(() =>
    ComputeToolchainClosure(ToolchainRoots, ReferencedMeshWeaverAssembliesOf));

Resolved against the built assemblies, that closure is 16 assemblies, because MeshWeaver.Compiler → MeshWeaver.Mesh.Contract → Layout / Messaging.Hub / Data.Contract, and ContentCollections → Markdown:

MeshWeaver.Compiler          MeshWeaver.Layout                MeshWeaver.Reflection
MeshWeaver.ContentCollections MeshWeaver.Markdown             MeshWeaver.ServiceProvider
MeshWeaver.Data              MeshWeaver.Mesh.Contract         MeshWeaver.ShortGuid
MeshWeaver.Data.Contract     MeshWeaver.Messaging.Contract    MeshWeaver.Utils
MeshWeaver.Domain            MeshWeaver.Messaging.Hub
MeshWeaver.Kernel            MeshWeaver.NuGet

Commits on origin/main in the last 30 days, per member — the number that matters is how often anything in the set moves, because any one of them mints a new global identity and rebakes the world:

assembly commits/30d
MeshWeaver.Mesh.Contract 190
MeshWeaver.Messaging.Hub 135
MeshWeaver.Data 59
MeshWeaver.Layout 40
MeshWeaver.Messaging.Contract 17
MeshWeaver.Data.Contract 15
MeshWeaver.ContentCollections 14
MeshWeaver.Compiler 12
Markdown / Utils / NuGet / Kernel 5 / 3 / 2 / 1
Domain / Reflection / ServiceProvider / ShortGuid 0
union of the closure 383
(for comparison) MeshWeaver.Graph, the OLD sole root 311
before #1712:  public static readonly ImmutableArray<string> FullMvidAssemblies = ["MeshWeaver.Graph"];
                 → 311 commits/30d could mint a new identity
after  #1712:  computed 16-assembly closure
                 → 383 commits/30d can mint a new identity

So the highest-churn single assembly left the boundary and four more of the top five walked in behind it. MeshWeaver.Mesh.Contract alone (190) is over half of Graph's whole rate, and it is the assembly nearly every change in this repo touches.

Why the closure is nonetheless the right shape

The comment at FrameworkBuildIdentity.cs:179-186 is not wrong:

"The set is COMPUTED from the roots' AssemblyRef metadata … rather than hand-listed, so a new toolchain dependency can never be silently outside the identity."

A hand-list is how NuGetDirectiveParser sat outside the boundary while shaping compile input — the hole #1707 opened by naming. Reverting to a hand-list would re-open it. The problem is not the walk; it is that the roots reach Mesh.Contract.

Options, none of them a one-line change

  1. Cut MeshWeaver.Compiler's reference to Mesh.Contract / ContentCollections. It needs IAssemblyStore, AtomicFileWrite, MeshNode and the content-collection config shapes. A MeshWeaver.Compiler.Contract holding just those would let the walk terminate before Layout/Messaging.Hub. Biggest win, most work, and it must not fracture the surface manifest (fix(#1814): the bake published to an address no portal read — restore the portal's canonical surface + guard the equality #1847 pinned bake and portal to the SAME canonical set — a split has to move both).
  2. Ship slice 4 and delete the full-MVID rule entirely. Compile cleanup: MeshWeaver.Compiler, dependency-scoped rebuilds, consume-prebuilt-everywhere (maintainer-directed) #1707 already names this as the end state: "a toolchain change invalidates exactly the types whose generated input actually changed, and the full-MVID rule can be deleted." Strictly better than any narrowing, and it makes this issue moot rather than solved.
  3. Do nothing and accept it, on the argument that per-type CompiledDependencies (feat(#1707 slices 2+3): per-type dependency records + adopt-before-compile at install, push, and release #1719) already keeps a stale global identity from serving wrong bytes. That is true — but the identity is still what empties the share's key-space and forces the rebake, which is the cost NodeType assemblies are built in CI and LOADED at runtime — no runtime Roslyn, no warm-up window (maintainer-directed) #1660 exists to remove.

Recommendation: (2), on #1707's own sequencing. Filing this so the measurement is on the record either way — slice 1's stated acceptance passes while its purpose regressed, and that combination is exactly the kind that stays invisible.

How to re-measure

# the closure, resolved against built assemblies
dotnet run --project <a console referencing src/MeshWeaver.Compiler> \
  # prints FrameworkBuildIdentity.FullMvidAssemblies

# churn of the union
git log --oneline --since=30.days --no-merges origin/main -- \
  $(printf 'src/%s ' MeshWeaver.Compiler MeshWeaver.ContentCollections MeshWeaver.Data \
      MeshWeaver.Data.Contract MeshWeaver.Domain MeshWeaver.Kernel MeshWeaver.Layout \
      MeshWeaver.Markdown MeshWeaver.Mesh.Contract MeshWeaver.Messaging.Contract \
      MeshWeaver.Messaging.Hub MeshWeaver.NuGet MeshWeaver.Reflection \
      MeshWeaver.ServiceProvider MeshWeaver.ShortGuid MeshWeaver.Utils) | wc -l

A regression test worth having regardless: assert the closure does NOT contain MeshWeaver.Layout / MeshWeaver.Messaging.Hub once (1) or (2) lands, so it cannot silently widen again. Today FrameworkBuildIdentityTest.CanonicalList_MatchesTheTesterClosure asserts membership of the roots and does not bound the set.

🤖 Generated with Claude Code

Activity

  1. added a commit that references this issue on Aug 21, 2026
  2. changed the title [-]The full-MVID boundary got BIGGER, not smaller: the toolchain closure spans 16 assemblies and 383 commits/30d (was Graph alone, 311)[/-] [+]Build the re-evaluation lane, so the toolchain MVID can demote from invalidation unit to trigger[/+] on Aug 22, 2026
  3. rbuergi commented on Aug 22, 2026

    @rbuergi
    ContributorAuthor

    Retargeted (maintainer decision, 2026-08-22). The original framing — the full-MVID boundary got bigger, shrink it — cannot be acted on, and slice 4 established why.

    Why the closure cannot simply be deleted

    FullMvidAssemblies is currently the only signal that a generator body change invalidates compiled bytes. Slice 4 built the generated-input content key (#1994, merged) and then deliberately did not delete the rule, because the key has no cheap live counterpart by construction: evaluating it means regenerating the compilation input, and every rebuild-or-not consumer is deliberately metadata-only.

    Trace a body-only toolchain commit that does change generated input (the #1802 global-usings fix and the AnchorIncludePath fix are both this shape) through HasStaleFrameworkBuild — the only automatic re-drive: framework version unchanged, per-assembly entries unchanged, !toolchain constant, !input skipped because FindMismatch has no live value. Result false. Nothing recompiles, stale bytes are served, and it surfaces as a TypeLoadException inside an ALC at activation with no diagnostic.

    That is pinned as a test — DeletingTheFullMvidRule_WouldLeaveNothingWatchingTheToolchain — rather than left as prose.

    What this issue is now

    Build the re-evaluation lane: on a toolchain change, regenerate the input and compare. Equal ⇒ keep the bytes and restamp. Different ⇒ compile. The MVID then demotes from invalidation unit to cheap trigger, and the closure's size stops mattering — which resolves the original complaint properly rather than by narrowing a list.

    Slice 4 is the precondition and it has landed. Note also that determinism was a silent precondition nobody had noticed was missing: before #1994's join-order fix the mesh compiled identical content into a different assembly on every process, so a re-evaluation lane built earlier would have regenerated a different key every time and recompiled every type forever.

    #1977's pin stays

    It guards a boundary we have now documented as un-deletable-for-now. It becomes removable when this lane exists, not before.

  4. rbuergi commented on Aug 27, 2026

    @rbuergi
    ContributorAuthor

    Not covered by #2492 — measured, not assumed. But the lane is blocked on a store capability that does not exist.

    Picked this up to build the lane. Two findings: the first clears #2492 out of the way, the second is
    a scope call I am not making unilaterally.

    1. #2492 renamed a state. It did not move the lane one inch.

    Its own PR body says so — "Same NodeTypes rebuild at the same time — only the name and the
    diagnostics change"
    — and the code confirms it independently rather than on the author's word:

    // src/MeshWeaver.Graph/Configuration/NodeTypeBakeStatus.cs:86,99
    public bool NeedsBake  => State is not BakeState.Baked;
    public bool WasHealthy => State is not BakeState.PreviouslyBroken;

    FrameworkStale and DependencyStale are therefore behaviourally identical — both NeedsBake,
    both WasHealthy, and the return still preempts the bytes-win rule. #2492 made FrameworkStale
    reachable and recovered the discarded mismatch string. Valuable, and orthogonal: what recompiles,
    and when, is byte-for-byte what it was. So "demote from invalid" does not partly already exist as
    FrameworkStale.

    2. The precondition is stamped on every compile and read by nothing

    This is the sharper finding. CompiledDependencies.FindMismatch has carried the
    liveContentKey parameter since #1994:

    // src/MeshWeaver.Compiler/CompiledDependencies.cs:181-183
    if (string.IsNullOrEmpty(liveContentKey))
        continue;
    live = liveContentKey;

    Every production call site passes the 3-arg overload, so that continue fires 100% of the time:

    call site liveContentKey
    NodeTypeBakeStatus.ClassifyDetailed — NodeTypeBakeStatus.cs:249 omitted → null
    NodeTypeCompilationHelpers.DependenciesValid (HasUsableBuild / HasStaleFrameworkBuild) — :1655 omitted → null
    framework-stale kickoff log line — NodeTypeCompilationHelpers.cs:736 omitted → null
    PrebuiltAssemblySeeder.Seed adoption gate — PrebuiltAssemblySeeder.cs:280 omitted → null

    The only 4-arg callers in the repo are two assertions inside GeneratedInputIdentityTest. !input
    is written on every compile and compared by nothing.
    That is the lane's missing half, exactly as
    DeletingTheFullMvidRule_WouldLeaveNothingWatchingTheToolchain predicts.

    3. 🚨 The blocker: "keep the bytes and restamp" has no read path

    The issue's shape is "Equal ⇒ keep the bytes and restamp. Different ⇒ compile." The restamp half
    is cheap. The keep the bytes half cannot be written today, and this is structural, not an
    oversight:

    // src/MeshWeaver.Compiler/FileSystemAssemblyStore.cs:335
    public static readonly string FrameworkTag = FrameworkBuildIdentity.FrameworkVersion[..8];
    // :80 and :148 — the ONLY two lookups
    .EnumerateFiles($"v{version}-{FrameworkTag}-*.dll")

    IAssemblyStore.TryGetAssemblyPath(nodeTypePath, version) takes no tag and globs the live one.
    So after a framework roll a type's previous bytes are unaddressable, and there is no
    fetch-by-(Collection, ContentPath) either — MeshDataSource.cs:1725 and MeshOperations.cs:2731
    use LatestAssemblyCollection only to pick the store, then look up by (path, version), which
    re-applies the live tag.

    The bytes are still on the volume — per-type eviction deliberately never crosses the tag boundary
    (FileSystemAssemblyStore.cs:226-228) — so this is a missing read, not missing data. Adding it is
    one method. What makes it a scope call is the reason the boundary is there, stated in that same
    comment:

    "A generation belongs to an IMAGE — another pod may be running it, and loading the wrong
    generation's bytes is BadImageFormatException → failed grain activations → portal-wide wedge
    (prod 2026-06-20)."

    Carrying a build across the tag boundary on the strength of the content key is precisely a
    cross-generation load. The key is designed to make that sound — "two compiles whose generated input
    hashes equal produce interchangeable bytes, whoever built them and whenever"
    — but it also carries
    four deliberate coarsenings (GeneratedInputIdentity: the BOM drop, the CRLF→LF fold, and the two
    normalised wall-clock lines), each documented as a trade. Betting the 2026-06-20 failure mode on them
    is a decision for the maintainer, not for an implementing session.

    The design, ready to build once that call is made

    1. CompiledDependencies.FindMismatchAfterReevaluation(record, liveIdOf, liveToolchainId, liveContentKey) —
      the demotion itself, pure and unit-testable like Classify. When a live key is supplied and it
      matches
      !input, the !toolchain entry stops being decisive (MVID = trigger, not invalidation
      unit); every assembly ref:/mvid: entry still decides, so a real surface or module drift is
      still a mismatch. When !input differs it is decisive — which also closes the
      under-invalidation hole the pinned test names (a body-only toolchain commit that does move
      generated input). Kept separate from FindMismatch so the four metadata-only call sites keep
      their exact current semantics.
    2. A regeneration entry point. The bake lane already has one that is pure —
      NodeSetCompiler.ResolveInputs (documented "WITHOUT compiling") + GeneratedInputDigestOf
      (NodeSetCompiler.cs:101,265). The runtime lane does not: the digest is taken inline three
      statements before Roslyn at MeshNodeCompilationService.cs:1698-1704. Extracting lines 1657-1704
      into a RegenerateContentKey returning IObservable<string?> (async leaf bridged through
      IIoPool, never Observable.FromAsync) is the work item.
    3. The cross-generation read — the gated decision above.
    4. The wiring, at the one automatic re-drive: frameworkStaleKickoffSub,
      NodeTypeCompilationHelpers.cs:646-767. It already has the right shape — Take(1), then a
      bounded probe whose whole purpose is to avoid a rebuild (:707-717). The lane is a second
      predicate after it: regenerate → compare → carry forward and restamp, else flip Pending.
    5. DeletingTheFullMvidRule_WouldLeaveNothingWatchingTheToolchain becomes the lane's positive test
      rather than a pin on its absence, and NodeTypeCompilation.md gains the BakeState section it
      currently lacks entirely.

    What I need decided

    Is a content-key-gated cross-generation assembly load acceptable, given 2026-06-20? If yes, the
    lane is one PR along the five points above. If no, the alternative is to make the roll cheap at the
    producer end instead (bundle coverage for the 273 uncovered types), which is a different issue and
    leaves the MVID as the invalidation unit.

    No files were touched for #1976.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions