Skip to content

Thread grain permanently wedged: one async task Running for >1h on the ActivationTaskScheduler, 1376 messages queued, no recovery path short of pod restart #147

Closed

Description

@sierragolflima

Symptom

Threads stop loading on the portal. After several retries the UI shows:

Area unavailable. The view at ‹path› did not become addressable after 5 retries — it may still be initialising or its NodeType may be compiling. Reload to retry.

The portal log contains a delivery timeout for the specific thread grain with the following diagnostic block (condensed):

Delivery to 'AgenticPension/Dashboard/_Thread/change-the-pie-chart-to-a-bar-chart-unde-4367' failed:
Response did not arrive on time in 00:00:30 …
Last known status:
  IsExecuting: False, IsWaiting: True
  NonReentrancyQueueSize: 1376
  NumRunning: 0
  IdlenessTimeSpan: 01:07:53.5440000
  CollectionAgeLimit: 00:15:00
TaskScheduler status:
  WorkGroupStatus=Running
  QueuedWorkItems=1; Total Enqueued=659; Total processed=657
  Executing Task Id=1 Status=Running for 01:07:53.5440000

The thread's durable DB state is already terminal (IsExecuting=false, pendingUserMessages={}) — the round completed. But the grain remains permanently wedged in memory. Recovery requires a pod restart.


Root cause chain

1. Hub ActionBlock runs on the grain's ActivationTaskScheduler

MessageHubGrain.CompleteActivation wires the hosted hub with .WithTaskScheduler(grainScheduler) (line 247). This means all hub message processing — handlers, subscriptions, workspace updates, watchdog actions — runs on the Orleans grain's ActivationTaskScheduler.

2. One async task captures the scheduler and never completes

ExecuteMessageAsync (or its surrounding orchestration) places an async continuation onto the grain's scheduler — either via InvokeAsync(async () => { ... }) or await Task.Run(...) called from inside a turn. The async state machine becomes Task Id=1, Status=Running on the scheduler.

The actual async work (LLM streaming via the IoPool) ran on a thread-pool thread. But the continuation awaiting its result was posted back to the grain's ActivationTaskScheduler. That scheduler has WorkGroupStatus=Running and QueuedWorkItems=1 — the continuation is queued but cannot execute because Task Id=1 is still logically "Running" from the scheduler's perspective, blocking the single scheduler slot.

3. The hub's ActionBlock is therefore also blocked

Because the hub's ActionBlock uses the same grain scheduler (WithTaskScheduler(grainScheduler)), no hub messages can be processed while the scheduler slot is occupied. Every incoming IMessageHubGrain.DeliverMessage call from Orleans piles up in the NonReentrancyQueueSize — which reached 1,376 messages over 1h7m.

4. The stuck-round watchdog cannot rescue the grain

InstallLifecycle (ThreadExecution.cs line 534) installs a watchdog: if the thread node emits nothing for StuckGracePeriod = 90s while IsExecuting=true, it calls workspace.GetMeshNodeStream().Update(...) to force the thread to Idle. This watchdog fires correctly — but its Update call posts a PatchDataRequest to the same thread hub grain whose scheduler is stuck. That message joins the 1,376-message backlog and is never processed.

5. _activeOperations > 0 prevents grain idle-collection

BeginAsyncOperation() (line 1634) calls BeginLongRunningOperation(), which increments _activeOperations and calls DelayDeactivation(10min). A RegisterGrainTimer fires every minute: while _activeOperations > 0, it calls DelayDeactivation(10min) again. If the round's LongRunningOperationScope is never disposed (because the streaming continuation never resumes), _activeOperations stays positive — and DelayDeactivation is called indefinitely, preventing Orleans idle-collection even though CollectionAgeLimit=00:15:00.

Orleans also skips idle-collection when NonReentrancyQueueSize > 0. Both conditions keep the grain alive permanently.

6. No external deactivation path exists

The only way to break the loop today is a pod restart, which disposes the grain and triggers executionCts.Cancel() via RegisterForDisposal, which cancels the AI API call. There is no admin API or MCP tool to force-deactivate a specific grain.


Proposed fixes

Fix 1 — Cap the total DelayDeactivation budget (highest priority)

Add a per-round wall-clock deadline on BeginLongRunningOperation. If _activeOperations > 0 for longer than MaxStreamingDuration (e.g., 30 minutes), stop calling DelayDeactivation and allow natural idle-collection to proceed. When the grain deactivates, executionCts.Cancel() fires via RegisterForDisposal, cancelling the AI API call and breaking the circular dependency.

// In BeginLongRunningOperation or the keep-alive timer:
if (_operationStartedAt.HasValue
    && DateTime.UtcNow - _operationStartedAt.Value > MaxStreamingDuration)
{
    // Stop extending the grain lifetime — let idle-collection deactivate it.
    // executionCts.Cancel() will fire on deactivation and cancel the API call.
    return;
}
DelayDeactivation(TimeSpan.FromMinutes(10));

Fix 2 — Stuck-round watchdog calls RequestDeactivation instead of (or alongside) stream.Update

The watchdog's stream.Update routes through the stuck grain's queue and is never processed. The watchdog should ALSO (or instead) call the Orleans DeactivateOnIdle() API via a grain callback, forcing the grain to deactivate and the CancellationToken to fire:

// In InstallLifecycle, expose the grain's DeactivateOnIdle via a callback:
.Set(new GrainDeactivateCallback(() => DeactivateOnIdle()))

// In the stuck-round watchdog:
hub.Get<GrainDeactivateCallback>()?.Invoke();   // triggers executionCts.Cancel via RegisterForDisposal

This deactivates the grain immediately without going through the hub's ActionBlock, bypassing the stuck scheduler.

Fix 3 — Add an admin endpoint or MCP tool to force-deactivate a grain by path

Expose IManagementGrain.DeactivateActivation(grainId) (Orleans built-in) through a portal admin endpoint or the MeshWeaver MCP server's execute_script surface. This would allow recovery without a pod restart.

Fix 4 — Add an independent streaming timeout (defence in depth)

The AI streaming call (IIoPool.Invoke(async ct => { await foreach ... })) should carry its own wall-clock CancellationTokenSource linked to executionCts with a hard upper limit (e.g., CancellationTokenSource.CreateLinkedTokenSource(executionCts.Token, new CancellationTokenSource(MaxStreamingDuration).Token)). This ensures the streaming call itself times out regardless of the grain lifecycle.


Files to edit

File Change
src/MeshWeaver.Hosting.Orleans/MessageHubGrain.cs Cap DelayDeactivation total time; expose GrainDeactivateCallback
src/MeshWeaver.AI/ThreadExecution.cs InstallLifecycle watchdog: call GrainDeactivateCallback alongside stream.Update; add per-round streaming timeout

Affected deployment

Confirmed on client-i.meshweaver.cloud (thread AgenticPension/Dashboard/_Thread/change-the-pie-chart-to-a-bar-chart-unde-4367, wedge duration ≥ 1h7m, NonReentrancyQueueSize=1376). The pattern is general: any distributed portal where an LLM streaming call hangs without a timeout is exposed to this permanent wedge.

Activity

  1. rbuergi commented on Jul 1, 2026

    @rbuergi
    Contributor

    Permanent-wedge core fixed in #150.

    Implemented the highest-priority fix (Fix 1 — bound the keep-alive): the 1-minute keep-alive timer now stops re-arming DelayDeactivation once an active-operation run exceeds 30 min (MaxLongRunningOperationDuration) and calls DeactivateOnIdle(). On deactivation the hub disposes → RegisterForDisposal fires executionCts.Cancel() → the hung AI call is cancelled and the round faults gracefully to the output cell. So the grain self-recovers instead of pinning forever (no more pod-restart-only recovery). The cap decision is extracted as the pure, unit-tested LongRunningOperationCapExceeded.

    The remaining proposed items are defence-in-depth and not required to break the wedge, left open here as follow-ups:

    • Fix 4 — an idle-based streaming timeout (finer than the 30-min backstop; cancels a stream that goes silent rather than one that's genuinely working through a long delegation).
    • Fix 3 — an admin/MCP force-deactivate-by-path for instant manual recovery.

    Keeping this issue open until those follow-ups are decided; the permanent-wedge itself is cured by #150.

  2. added a commit that references this issue on Jul 1, 2026
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