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
55 changes: 48 additions & 7 deletions src/MeshWeaver.Compiler.Pipeline/NodeTypeCompilationHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1306,6 +1306,9 @@ public static IDisposable InstallSourcesWatcher(
// changed" path). Same mesh-scoped singleton the compile watcher's parked short-circuit
// uses; null in the rare host that does not register it (no auto-retry, unchanged before).
var parkRegistry = hub.ServiceProvider.GetService<NodeTypeCompileParkRegistry>();
// #2948 — the mesh read the source fingerprint's `@@`-include walk needs. Built once per
// watcher; it issues nothing at all for a source set with no `@@` in it.
var includeReader = SourceFingerprintIncludeReader.For(hub, logger);

// Outer subscription: discover the source path set via the shared
// synced query (NodeSources.GetSources). When the path set changes
Expand Down Expand Up @@ -1400,7 +1403,36 @@ public static IDisposable InstallSourcesWatcher(
// adopted fingerprint to compare it against. The nodes are already materialised
// here; the hash is what costs, and it is paid only where it is used.
return (Snapshot: (IReadOnlyDictionary<string, long>)snap, Nodes: sources);
}),
})
// 🚨 #2948 — the CONTENT fingerprint now covers the `@@`-include closure, and resolving
// that needs mesh READS, so it happens HERE (an observable step) rather than inside the
// pure Update lambda below. Cost: `CollectIncludeClosure` scans each source's text for
// "@@" and issues NOTHING when there is none — which is nearly every type — so the
// reads are paid only by the types that actually have includes.
//
// 🚨 Switch(), not SelectMany: a newer source set must SUPERSEDE an in-flight include
// resolution, or a slow read could publish a fingerprint for a set that is already gone.
//
// 🚨 An include the mesh could not READ (a stall, an unavailable owner) is INCONCLUSIVE,
// never "absent". Degrading it to absence shortens the hashed set, which is
// indistinguishable from a stale bundle and refuses a perfectly good adoption — on a
// Modules:RequirePrebuilt mesh that is terminal. So the fingerprint is dropped for this
// emission (null) and the previously published value stands; the judgement then takes
// ApplyAdoptedSourceStamp's "nothing has been compared" branch (AdoptedUnverified),
// which is the honest answer and the same #890 rule the emit canary follows.
.Select(published => NodeTypeSourceFingerprint
.Compute(published.Nodes, hubPath, includeReader, logger)
.Select(fingerprint => (published.Snapshot, Fingerprint: (string?)fingerprint))
.Catch((SourceIncludeUnavailableException ex) =>
{
logger?.LogWarning(ex,
"SourcesWatcher: the @@-include closure for {HubPath} could not be "
+ "established, so CurrentSourceFingerprint is left at its previous value "
+ "for this emission — an unreadable include must never read as an absent "
+ "one", hubPath);
return Observable.Return((published.Snapshot, Fingerprint: (string?)null));
}))
.Switch(),
published =>
{
var snapshot = published.Snapshot;
Expand Down Expand Up @@ -1479,8 +1511,13 @@ or CompilationStatus.Compiling
// in memory, on a path that runs at hub activation and per source edit —
// i.e. exactly where a Roslyn compile is about to cost four orders of
// magnitude more.
var fingerprint = NodeTypeSourceFingerprint.Compute(
published.Nodes, hubPath, logger);
//
// 🚨 #2948 — it is now resolved UPSTREAM (the include closure needs mesh
// reads, which cannot happen inside this pure lambda), and it can be NULL:
// null means the closure could not be established, NOT that there is
// nothing to hash. The previous value stands in that case — inventing one
// from a short closure is a false refusal.
var fingerprint = published.Fingerprint;

// Idempotent: no-op when CurrentSourceVersions already
// matches the just-computed snapshot. IsDirty is a
Expand All @@ -1492,18 +1529,22 @@ or CompilationStatus.Compiling
// before this field existed — snapshot already current, fingerprint null —
// would no-op forever and never acquire one, so the type could never be
// judged again. The condition is what makes the field SELF-HEALING on the
// first activation after an upgrade.
// first activation after an upgrade. An INCONCLUSIVE emission (fingerprint
// null) drops out of the comparison entirely: it has nothing to say about
// the field, so it must neither force a write nor block the snapshot's.
if (!pendingStamp
&& def.CurrentSourceVersions is not null
&& DictEquals(def.CurrentSourceVersions, snapshot)
&& string.Equals(
def.CurrentSourceFingerprint, fingerprint, StringComparison.Ordinal))
&& (fingerprint is null
|| string.Equals(
def.CurrentSourceFingerprint, fingerprint,
StringComparison.Ordinal)))
return curr;

var updated = def with
{
CurrentSourceVersions = snapshot,
CurrentSourceFingerprint = fingerprint,
CurrentSourceFingerprint = fingerprint ?? def.CurrentSourceFingerprint,
};

if (pendingStamp)
Expand Down
153 changes: 153 additions & 0 deletions src/MeshWeaver.Compiler.Pipeline/SourceFingerprintIncludeReader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
using System.Reactive.Linq;
using MeshWeaver.Compiler;
using MeshWeaver.Mesh;
using MeshWeaver.Mesh.Services;
using MeshWeaver.Messaging;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

namespace MeshWeaver.Graph.Configuration;

/// <summary>
/// The mesh-actor half of the <c>@@</c>-include walk that
/// <see cref="NodeTypeSourceFingerprint"/> runs (#2948) — the System-impersonated, bounded read
/// <see cref="NodeCompileShaping.CollectIncludeClosure"/> is handed so it can find out what an
/// included-only snippet says TODAY.
///
/// <para><b>Why this is not simply <c>MeshNodeCompilationService.ReadIncludeNode</c>.</b> The
/// anchoring, the anchored-then-authored fallback ORDER and the cycle brake are shared — they live
/// in <see cref="NodeCompileShaping"/> and neither reader gets to have an opinion about them. What
/// differs is what a read that could not be COMPLETED means, and it differs in opposite
/// directions:</para>
/// <list type="bullet">
/// <item><b>The compile</b> degrades a stall to "unresolved": the directive stays VERBATIM, Roslyn
/// reports on the <c>@@</c> line, the type parks with a diagnostic a human can read. Turning a
/// transient stall into a hard fault there would park the type on the ACTIVATION path, which is
/// worse than a compile error you can see.</item>
/// <item><b>The fingerprint</b> must NOT degrade it. A stalled read that reads as "absent"
/// shortens the include closure, the hash then differs from the producer's, and the adoption is
/// REFUSED — a false refusal, which on a <c>Modules:RequirePrebuilt</c> mesh is terminal and
/// needs a human to rebake. The honest answer is INCONCLUSIVE: this reader faults, the caller
/// leaves the previous fingerprint standing, and the adoption judgement degrades to
/// <c>AdoptedUnverified</c> — the branch that exists precisely for "nothing has been compared".
/// Same rule as the emit canary (#890): a probe must not answer its scariest branch on its own
/// inability to run.</item>
/// </list>
///
/// <para>🚨 <b>ABSENT is not a failure and must keep being an answer.</b> An <c>@@</c> match that
/// resolves to nothing is ordinary — the scanner runs over raw C# and a path-shaped fragment that
/// names no node is a legitimate outcome, and an include the mesh genuinely does not hold
/// contributes nothing to the bytes either. Both producer and consumer therefore record nothing
/// for it and agree. Only <see cref="NodeReadStatus.Unavailable"/> /
/// <see cref="NodeReadStatus.DeleteInProgress"/> and a timeout are inconclusive.</para>
/// </summary>
public static class SourceFingerprintIncludeReader
{
/// <summary>The per-read budget — the same 15s the compile path spends on an include, so a
/// fingerprint can never be the slower of the two on the same mesh.</summary>
public static readonly TimeSpan ReadBudget = TimeSpan.FromSeconds(15);

/// <summary>
/// Builds the reader for <paramref name="hub"/>: anchored path first, authored path as the
/// fallback only when the anchored one is genuinely ABSENT, reporting the path that actually
/// produced the node so nested includes anchor there.
///
/// <para>🚨 <c>RunAsSystem</c>, never <c>Observable.Using(access.ImpersonateAsSystem, …)</c>.
/// Impersonation is an <c>AsyncLocal</c> store/restore pair and Rx disposes <c>Using</c>'s
/// resource when the inner observable TERMINATES — a different thread for a cross-hub read —
/// leaving the subscriber latched as System (#1790). Each read is wrapped INDIVIDUALLY because
/// the fallback read is subscribed from the first read's emission, i.e. on another thread
/// again, so one outer scope would not cover it.</para>
///
/// <para>Source-set discovery is framework infrastructure, not a user-scoped read: a per-user
/// read of a source node UNDER this NodeType routes a permission check BACK into the grain
/// being read, which deadlocks a single-threaded, non-reentrant activation (#1253).</para>
/// </summary>
/// <param name="hub">The reading hub.</param>
/// <param name="logger">Diagnostics.</param>
public static Func<string, string?, IObservable<(MeshNode? Node, string Path)>> For(
IMessageHub hub, ILogger? logger = null)
{
ArgumentNullException.ThrowIfNull(hub);
var access = hub.ServiceProvider.GetService<AccessService>();

return (path, fallbackPath) => Read(hub, access, path, logger)
.SelectMany(outcome => outcome.Status switch
{
NodeReadStatus.Present => Hit(outcome.Node, path),

// Genuinely absent, and there is another legal reading of the authored path.
NodeReadStatus.Absent when fallbackPath is { Length: > 0 } =>
Read(hub, access, fallbackPath, logger)
.SelectMany(fallback => fallback.Status switch
{
NodeReadStatus.Present => Hit(fallback.Node, fallbackPath),
NodeReadStatus.Absent => Hit(null, fallbackPath),
_ => Miss(fallbackPath, fallback.Failure),
}),

NodeReadStatus.Absent => Hit(null, path),

_ => Miss(path, outcome.Failure),
});
}

/// <summary>An established answer: the node, or a well-founded "there is nothing there".</summary>
private static IObservable<(MeshNode? Node, string Path)> Hit(MeshNode? node, string path) =>
Observable.Return<(MeshNode? Node, string Path)>((node, path));

/// <summary>An answer that was never established — the fingerprint is inconclusive.</summary>
private static IObservable<(MeshNode? Node, string Path)> Miss(string path, Exception? cause) =>
Observable.Throw<(MeshNode? Node, string Path)>(Inconclusive(path, cause));

/// <summary>
/// One System-impersonated read. <see cref="ReadTimeoutBehavior.EmitNull"/> — deliberately, and
/// it is NOT the "treat indeterminate as absent" contract that flag usually expresses: it maps a
/// timeout to <see cref="NodeReadStatus.Unavailable"/> carrying the
/// <see cref="TimeoutException"/>, which is exactly the third state this reader needs. Under
/// <see cref="ReadTimeoutBehavior.Throw"/> the exception would arrive out of band and could not
/// be told apart from a routing fault by the caller of <see cref="For"/>.
/// </summary>
private static IObservable<NodeReadOutcome> Read(
IMessageHub hub, AccessService? access, string path, ILogger? logger) =>
access.RunAsSystem(() =>
hub.GetMeshNodeOutcome(path, ReadBudget, ReadTimeoutBehavior.EmitNull))
.Do(outcome =>
{
if (outcome.Status is NodeReadStatus.Present)
logger?.LogDebug("Fingerprint include read resolved {Path}", path);
});

/// <summary>
/// The one exception shape a caller catches to mean "I could not establish the include closure".
/// Named so the sources watcher can distinguish it from a genuine defect and skip the
/// fingerprint publication instead of tearing its subscription down.
/// </summary>
/// <param name="path">The include target that could not be established.</param>
/// <param name="cause">What the read reported, when it reported anything.</param>
public static SourceIncludeUnavailableException Inconclusive(string path, Exception? cause) =>
new(path, cause);
}

/// <summary>
/// Raised when an <c>@@</c>-include target could not be READ — as distinct from being absent
/// (#2948). It means the source fingerprint is INCONCLUSIVE for this NodeType right now, never
/// that the include is gone.
/// </summary>
public sealed class SourceIncludeUnavailableException : Exception
{
/// <summary>The include target whose read could not be completed.</summary>
public string IncludePath { get; }

/// <summary>Builds the exception.</summary>
/// <param name="includePath">The include target whose read could not be completed.</param>
/// <param name="cause">The underlying read failure, when the read reported one.</param>
public SourceIncludeUnavailableException(string includePath, Exception? cause = null)
: base(
$"The @@ include '{includePath}' could not be READ (it is not known to be absent), so "
+ "the NodeType's source fingerprint is INCONCLUSIVE. Treating an unreadable include as "
+ "absent would shorten the hashed set and turn a good prebuilt bundle into a refused "
+ "adoption — the previous fingerprint stands instead.",
cause)
=> IncludePath = includePath;
}
Loading
Loading