Requesting Work via stream.Update()

🚨 DEFAULT PATTERN β€” required for every mesh-node mutation. Threads, thread messages, NodeType compile state, code editing, satellite annotations β€” all of them. If you are about to write class XxxRequest : IRequest<XxxResponse> to mutate a node's content, stop and read this page first.

The single rule: mutate the target node's state via workspace.GetMeshNodeStream(path).Update(current => modified). A server-side watcher on the owning hub picks up the change and dispatches any side-effect work. Results are published by writing back to the same node, and the synchronization protocol propagates them cluster-wide automatically.

Reads use the same stream. IMeshNodeStreamCache (src/MeshWeaver.Hosting/MeshNodeStreamCache.cs) hands out a single shared handle per node path β€” server-side hubs and Blazor views alike resolve it through GetMeshNodeStream(path) (see GUI Data Binding). Read and write share one stream β€” there is no separate read API to keep in sync.

Why this is mandatory, not merely preferred. Every recent "hub becomes unresponsive after the second operation" CI failure β€” CodeEditRecompile, NodeTypeRelease, LinkedInPullActions, ThreadAgentIntegration in run 26036857424 β€” traced back to bespoke request/response handlers racing the watcher: two concurrent activities, leaked callbacks, wedged hub. The stream-based pattern is race-free by construction. Caller any hub / UI Subscriber other silos / clients MeshNode stream (shared) IMeshNodeStreamCache one stream per node Owning Hub WatchSubmission watcher Worker dispatch / side-effect stream.Update(…) emits change subscribe dispatch work write-back result propagate Stream-based mutation flow: caller writes via stream.Update, the owning hub's watcher dispatches work, the worker writes results back to the same node, and every subscriber (other silos, clients) receives the change automatically.


Sanctioned Exceptions

hub.Observe(request) is valid only for:

Use case Why it's different
Node lifecycle β€” CreateNodeRequest, DeleteNodeRequest, MoveNodeRequest Creates, destroys, or re-keys the node itself; does not mutate its content
Transient queries β€” autocomplete completions, one-shot diagnostic probes Result does not belong on any node's persistent state

Everything else β€” state machines, "trigger work and observe progress" flows, every UI button that mutates a node β€” uses stream.Update().


Cross-Hub Patch Semantics

When you call workspace.GetMeshNodeStream(otherPath).Update(...) from a non-owner hub (UpdateRemote), the framework re-runs your lambda against the caller's snapshot and ships an RFC 7396 JSON-merge patch of the diff to the owner.

πŸ”‘ Identity across the hop. The outbound patch carries the caller's AccessContext. On the owner, applying it and propagating the result through the data-source sync stream happens on a deferred continuation where the live AsyncLocal is gone β€” so the owner falls back to its standing owner identity (the node's CreatedBy, carried via CircuitContext). On a cold start the owner must have that identity established before the first such write, or the deferred sync post carries a null context and fails closed. See Owner Injection.

This is safe only when the patch is idempotent under merge β€” applying it twice yields the same result as applying it once.

Merge-safe operations:

Not merge-safe:

Design rule: Cross-hub mutations should be a single stream.Update(...) on the target node. The owning hub's action-block serialisation guarantees race-free merge; RFC 7396 patch semantics ensure you touch only the fields you intend to change.

A worked consequence. Logon Actions builds its whole idempotency guarantee on the two rules above: the run-once ledger is a dictionary keyed by action id (merge-safe SetItem, never a list append), and the action's effect plus its ledger entry go into one patch on the user's profile β€” so two concurrent logons cannot apply a migration twice, and no restart can land between "it happened" and "we recorded it".

The thread refactor as the canonical example. The full resubmit/delete-from/record-failure flow once posted bespoke trigger messages, then briefly used intent-field payloads (RequestedResubmit, RequestedDeleteFromMessageId, PendingFailures) consumed by per-operation watchers. Today, the full mutation is inline inside the hub extension method's stream.Update lambda β€” truncate Messages, re-queue PendingUserMessages, etc., all in one patch. See ThreadOperations.md for the public API (hub.ResubmitMessage, hub.DeleteFromMessage, hub.RecordSubmissionFailure).


Canonical Helpers

Don't roll your own watcher. Two helpers in MeshWeaver.Mesh.Contract (ActivityControlPlaneExtensions.cs) implement this pattern as one-liners:

hub.WatchControlPlane(onRequestedStatus, logger) Use when the trigger is the single ActivityLog.RequestedStatus field. Drives Cancel / Start / Retry off a property patch.

hub.WatchSubmission(fingerprint, needsDispatch, dispatch, logger) Use when the trigger is an arbitrary "this state needs work now" predicate β€” for example, a thread has unprocessed user messages and isn't already executing. Internally it composes:

GetMeshNodeStream β†’ DistinctUntilChanged(fingerprint) β†’ Where(needsDispatch) β†’ SelectMany(dispatch)

The canonical reference for both helpers is ActivityControlPlane.md (see Β§ "Generalising: WatchSubmission" and Β§ "Anti-patterns to remove on sight"). If you find yourself reaching for Throttle(...), Interlocked.CompareExchange, or a manual Subject + flag, that's the signal you should be calling one of these helpers instead.


🚨 The watcher runs on ACTIVATION β€” so a CREATE has to wake the owner

The watcher is installed by the owning per-node hub's WithInitialization, which means it runs if and only if that hub is activated. A create only writes the row. Nothing else in a create opens the node, so before #3153 a request filed by a create was never seen: it sat at Requested with no error, no failed state, no log line β€” and then ran, correctly and immediately, the next time anything happened to open the node (a page view, a stream subscription, an MCP get).

Measured on memex.meshweaver.cloud: a Store/Subscription created via MCP sat untouched for 4.5 hours and completed 20 s after the first read. The same node created from the admin UI activated in 31 s β€” because the open page held a stream handle, which activated the owner as a side effect.

That side effect is also why the pattern's own tests could not catch it. A control-plane test that subscribes to the node to await its terminal state is the thing making the watcher run, so it passes with the defect fully present. A test that means to pin this must file the request with IMeshService.CreateNode and observe the outcome through a channel that does not activate the owner β€” durable storage (IStorageAdapter.Read) is the one that qualifies. See ControlPlaneRunsWhenTheRequestArrivesByCreateTest.

MeshExtensions.ActivatePendingControlPlane now closes it: after the create response is posted, a node whose content carries a pending RequestedXxx has its owner opened once, so the watcher sees the request. Two properties matter and are deliberate:


When to Use This Pattern

Use it when:

Do not use it for:


The Canonical Example: Thread Execution

MeshWeaver.AI already implements this pattern end-to-end for thread execution dispatch.

Caller β€” writing the request into node state

// MeshWeaver.AI/ThreadInput.cs β€” AppendUserInput
workspace.GetMeshNodeStream(threadPath).Update(node =>
{
    // Bad-data tolerance: an existing node whose content can't be read is
    // left alone β€” NEVER clobbered with a fresh MeshThread.
    var thread = node.ContentAs<MeshThread>(workspace.Hub.JsonSerializerOptions, logger);
    if (node.Content is not null && thread is null)
        return node;
    thread ??= new MeshThread();
    return node with
    {
        Content = thread with
        {
            UserMessageIds = thread.UserMessageIds.Add(msgId),
            PendingUserMessages = thread.PendingUserMessages.SetItem(msgId, message),
            // ... other pending fields ...
        }
    };
}).Subscribe(
    _ => { },
    ex => logger?.LogWarning(ex, "AppendUserInput failed for {ThreadPath}", threadPath));

The caller writes the request into the thread node's state. PendingUserMessages is the request payload. No IRequest<TResponse> is posted. No callback is registered. The work is requested by the very act of mutating the node. Note the trailing .Subscribe(...) β€” Update returns a cold observable and the write only happens on subscribe; forgetting it means the submission silently never lands.

Server β€” the dispatch watcher

// The generic helper β€” use this for a NEW watcher:
hub.WatchSubmission(
    fingerprint:   node => /* the control-plane tuple that decides "actionable" */,
    needsDispatch: node => /* predicate over that state */,
    dispatch:      node => DoWork(node),          // IObservable<Unit>
    logger:        logger);

The watcher subscribes once at hub init. DistinctUntilChanged on the fingerprint guarantees the same actionable state cannot fire twice β€” there is no dispatching flag, no Throttle, no reentrancy guard; dispatch composes via SelectMany into one run per emission, and a failure is logged rather than killing the subscription (the next state change retries naturally).

The thread's own watcher (ThreadSubmissionServer.InstallServerWatcher in MeshWeaver.AI/ThreadSubmission.cs) is the same pattern hand-composed, because it needs two extras the helper does not carry: a fault re-establish (a dead watcher would park the thread forever) and an explicit owner-identity scope around its writes. Its shape is GetMeshNodeStream() β†’ DistinctUntilChanged(SubmissionFingerprint) (status + pending/ingested/user-message ids β€” deliberately excluding every streaming field, so an executing round does not wake it) β†’ an atomic claim Status: Idle β†’ StartingExecution through stream.Update. Single-flight falls out of that claim: the owner's action block serialises the concurrent emissions, the first lambda that sees Idle flips it, and every other lambda re-reads Status != Idle and bails. Read that method before copying it.

Result publication

The dispatched work writes its progress and final result back onto the same node (or a satellite node it owns) via workspace.GetMeshNodeStream(path).Update(...). The result reaches every subscriber automatically:


Applied: Dynamic NodeType Compilation

NodeTypeEnrichmentHelpers.EnrichWithNodeType's slow path uses this pattern: the caller flips CompilationStatus to Pending on the NodeType node and waits for a terminal status on the same stream.

Caller β€” the trigger + observe pair

Note: this is the shape, not a copy of a live method β€” there is no NodeTypeService type in the tree. Split the chain into a one-shot trigger pipeline and a terminal-status observation pipeline, both reactive; .Take(1) is what makes the trigger one-shot, so no triggered flag and no Interlocked.CompareExchange is needed.

// Subscribe to the per-NodeType node via the shared handle.
// Trigger pipeline: takes the first emission whose CompilationStatus is null/Unknown
// and composes one stream.Update flipping it to Pending.
// .Take(1) makes the trigger inherently one-shot β€” no `triggered` flag, no CompareExchange.
// Observation pipeline: waits for terminal status (Ok / Error) on the same stream.
var stream = workspace.GetMeshNodeStream(nodeTypePath);

var trigger = stream
    .Where(node => node?.Content is NodeTypeDefinition def
        && (def.CompilationStatus is null || def.CompilationStatus == CompilationStatus.Unknown))
    .Take(1)
    .SelectMany(_ => stream.Update(current =>
        current?.Content is NodeTypeDefinition d
            && (d.CompilationStatus is null || d.CompilationStatus == CompilationStatus.Unknown)
            ? current with { Content = d with { CompilationStatus = CompilationStatus.Pending } }
            : current))      // no-op when someone else already flipped it
    .IgnoreElements();

var terminal = stream
    .Where(node => node?.Content is NodeTypeDefinition def
        && (def.CompilationStatus == CompilationStatus.Ok
            || def.CompilationStatus == CompilationStatus.Error))
    .Take(1);

return trigger.Merge(terminal!)
    .Take(1)
    .Timeout(TimeSpan.FromSeconds(30));

Server β€” NodeTypeCompilationHelpers.InstallCompileWatcher

Note: the live watcher is NodeTypeCompilationHelpers.InstallCompileWatcher (src/MeshWeaver.Graph/Configuration/) β€” it is installed per NodeType hub and carries a good deal more than the sketch below (kickoff, settle/registration waits, the Unavailable marker rules). The shape below is the minimal pattern it is an instance of: CompilationStatus as the fingerprint makes a Pending β†’ Compiling throttle-guard unnecessary, because DistinctUntilChanged already prevents re-firing on our own write.

hub.WatchSubmission(
    fingerprint:   node => (node.Content as NodeTypeDefinition)?.CompilationStatus,
    needsDispatch: node => node.Content is NodeTypeDefinition d
                         && d.CompilationStatus == CompilationStatus.Pending,
    dispatch:      node => Compile(workspace, compilationService, node)
                              .Select(_ => Unit.Default),
    logger:        logger);

// Compile is an IObservable<Unit> that flips Pending β†’ Compiling, runs
// compilationService.CompileAndGetConfigurations, then writes the terminal
// (Ok / Error) status + AssemblyLocation back via GetMeshNodeStream().Update.
// Because the fingerprint is CompilationStatus, the watcher's own writes
// are filtered out by DistinctUntilChanged β€” no Throttle needed, no reentrancy guard.

Cluster-wide cache propagation

Every silo subscribes to the NodeType nodes through a synced query (workspace.GetQuery β€” see Synced Mesh Node Queries). When the compiled result is written back, the synced query emits the new node state to every subscriber, so each silo's local NodeType-configuration cache picks up the new (AssemblyLocation, HubConfiguration) automatically β€” EnrichWithNodeType's fast path then hits for that NodeType, and grain activation stays synchronous.


Error Notification β€” Callers Must Observe Failure

The watcher dispatching the work must publish failure as well as success. A missing or invalid NodeType, a compilation error, an unreachable persistence layer β€” all must flip a status field on the node.

Silent timeout is not acceptable. The caller observes the same node's reducer. If the request never lands a response, the caller cannot distinguish "still working" from "broken".

Conventional status shape β€” the live one is MeshWeaver.Mesh.Contract/Services/CompilationStatus.cs:

enum CompilationStatus { Unknown, Pending, Compiling, Ok, Error, Unavailable }

Unavailable is deliberately distinct from Error: it means "the state could not be determined" (a settle wait or registration lookup timed out) β€” an availability problem, never "the source is broken".

Caller observation chain (GetMeshNodeStream, never GetRemoteStream<MeshNode, …> β€” that overload throws):

workspace.GetMeshNodeStream(nodeTypePath)
    .Where(node => node?.Content is NodeTypeDefinition def
             && def.CompilationStatus is CompilationStatus.Ok or CompilationStatus.Error)
    .Take(1)
    .Timeout(TimeSpan.FromSeconds(60))
    .Subscribe(node => { /* … */ }, ex => logger.LogWarning(ex, "compile wait failed"));

Pattern Comparison

Aspect Request/response (hub.Observe) State-change-driven (stream.Update)
Trigger Posted message Node state mutation
Result delivery Response message Node state write-back
Cluster propagation Targeted at caller only Every subscriber sees it automatically
Activation-time cost Cross-silo round-trip Local cache lookup (after first warm-up)
Error model DeliveryFailure / Timeout Status field on node
Use when Node lifecycle + transient queries (see exceptions above) Every other mutation β€” the default

Request/response is the exception, not a peer pattern. The work's result almost always belongs on a node (thread, message, NodeType, satellite). Making the node's own content the contract eliminates an entire class of race conditions, leaked callbacks, and hub wedges that bespoke handlers reintroduce every time.

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.