Repository navigation
fix: yield synchronous hub drains to queued peers - #5182
Conversation
|
Production baseline, 2026-09-22 05:31:20Z, memex.meshweaver.cloud: Activity Exact bounded production probe (preserved for repeatability)using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Reactive;
using System.Reactive.Linq;
using System.Reactive.Subjects;
using System.Threading;
using System.Threading.Tasks;
using MeshWeaver.Messaging;
using MeshWeaver.Mesh;
using Microsoft.Extensions.Logging;
const int turnCount = 1024;
var pair = new ConcurrentExclusiveSchedulerPair(TaskScheduler.Default, 1, 1);
var scheduler = pair.ExclusiveScheduler;
var processed = new ConcurrentQueue<int>();
var finished = new AsyncSubject<Unit>();
var atQuiescing = -1;
var wrongScheduler = 0;
var suffix = Guid.NewGuid().ToString("N");
var peer = (MessageHub)Mesh.GetHostedHub(new Address("peer-" + suffix, "pump-fairness-probe"), c => c
.WithTaskScheduler(scheduler).WithPostingIdentity(PostingIdentity.System), HostedHubCreation.Always);
var busy = (MessageHub)Mesh.GetHostedHub(new Address("busy-" + suffix, "pump-fairness-probe"), c => c
.WithTaskScheduler(scheduler).WithPostingIdentity(PostingIdentity.System)
.WithHandler<int>((h, delivery) =>
{
if (TaskScheduler.Current != scheduler) Interlocked.Increment(ref wrongScheduler);
processed.Enqueue(delivery.Message);
if (delivery.Message == 0) peer.Dispose();
if (delivery.Message + 1 < turnCount)
h.Post(delivery.Message + 1, o => o.WithTarget(h.Address));
else { finished.OnNext(Unit.Default); finished.OnCompleted(); }
return delivery.Processed();
}), HostedHubCreation.Always);
try
{
await peer.RunLevelChanged.Where(l => l == MessageHubRunLevel.Started).Take(1)
.Timeout(TimeSpan.FromSeconds(10)).Await(Ct);
await busy.RunLevelChanged.Where(l => l == MessageHubRunLevel.Started).Take(1)
.Timeout(TimeSpan.FromSeconds(10)).Await(Ct);
using var observed = peer.RunLevelChanged.Where(l => l >= MessageHubRunLevel.Quiescing).Take(1)
.Subscribe(_ => Volatile.Write(ref atQuiescing, processed.Count));
busy.Post(0, o => o.WithTarget(busy.Address));
await finished.Timeout(TimeSpan.FromSeconds(10)).Await(Ct);
await peer.DisposalCompleted.Timeout(TimeSpan.FromSeconds(10)).Await(Ct);
Log.LogInformation("PUMP_FAIRNESS_PROBE turns={Turns} peerAtTurn={PeerAtTurn} fifo={Fifo} originalScheduler={OriginalScheduler} framework={Framework}",
processed.Count, atQuiescing, processed.SequenceEqual(Enumerable.Range(0, turnCount)), wrongScheduler == 0,
typeof(MessageHub).Assembly.GetCustomAttributes(typeof(System.Reflection.AssemblyInformationalVersionAttribute), false)
.Cast<System.Reflection.AssemblyInformationalVersionAttribute>().Single().InformationalVersion);
}
finally
{
busy.Dispose(); peer.Dispose();
await busy.DisposalCompleted.Timeout(TimeSpan.FromSeconds(10)).Await(Ct);
await peer.DisposalCompleted.Timeout(TimeSpan.FromSeconds(10)).Await(Ct);
pair.Complete();
await pair.Completion;
finished.Dispose();
} |
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The drain handoff has unresolved scheduling-failure and overlapping-drain race risks.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
What changed in this PR
This PR bounds synchronous hub drain batches so queued peer hubs can progress during self-posting workloads.
Changes:
- Limits synchronous drains to 64 turns before rescheduling.
- Adds real-hub fairness, FIFO, and scheduler regression coverage.
- Documents the starvation mechanism and attribution limits.
| File | Description |
|---|---|
test/MeshWeaver.Messaging.Hub.Test/PumpDrainFairnessTest.cs |
Adds fairness and FIFO regression coverage. |
src/MeshWeaver.Messaging.Hub/MessageService.cs |
Implements bounded drain scheduling. |
src/MeshWeaver.Documentation/Data/Architecture/DisposalStallVerdicts.md |
Documents disposal-stall diagnostics. |
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Test Results (shard 1)533 tests 533 ✅ 58s ⏱️ Results for commit 358ec85. |
Test Results (shard 0) 1 files 1 suites 3m 1s ⏱️ Results for commit 358ec85. |
Test Results (shard 3)429 tests 429 ✅ 1m 3s ⏱️ Results for commit 358ec85. |
|
The same byte-identical bounded probe also reproduced the baseline on memex.systemorph.com at 2026-09-22 05:33:40Z: Activity |
Test Results (shard 4) 2 files 2 suites 6m 9s ⏱️ Results for commit 358ec85. |
Test Results (shard 2)1 551 tests 1 551 ✅ 7m 37s ⏱️ Results for commit 358ec85. |
Test Results (shard 5) 5 files 5 suites 15m 6s ⏱️ Results for commit 358ec85. |
Test Results 13 files 13 suites 33m 55s ⏱️ Results for commit 358ec85. |
|
Production verification passed on memex.systemorph.com, The unchanged baseline script Before → after: peer disposal was processed at turn 1024 → 64, with all 1,024 messages still FIFO and the original scheduler retained. Strict checks passed, including This proves the synchronous-drain fairness correction in production. Attribution of every broader historical #3593/#4847 stall remains separate; those issues are not closed by this verification. |

A hub whose synchronous handler keeps posting another turn owns its scheduler worker until the queue empties.
PreferFairnessmakes queued peer drains reachable but cannot preempt that running drain; a peer's queued shutdown therefore waits behind the entire chain.The drain now yields after 64 synchronous turns, retaining its serialization latch and scheduling the next batch on the original scheduler. FIFO processing, the side lane, genuinely asynchronous turns, and scheduling-failure handling remain unchanged. The owning disposal-diagnostics documentation records the mechanism and its attribution limits.
Related: #3593, #4847. This fixes a deterministic starvation mechanism; it does not by itself attribute every historical production stall in those issues.
Validation:
MeshWeaver.Messaging.Hub.Test: 494 passed, 0 failed, 0 skipped (3m18s), with a real TRX.-warnaserror: 0 warnings, 0 errors.Production baseline reproduced on memex.meshweaver.cloud at 2026-09-22 05:31:20Z, framework
026442fff0f2728e96bb838494559b68b5c2b797:turns=1024 peerAtTurn=1024 fifo=True originalScheduler=True. Activity:rbuergi/_Activity/c2a91f2eb521417489ade5b4f6823647. The same Code node,rbuergi/Script/pump-fairness-20260922, will be executed unchanged after rollout; acceptance is0 < peerAtTurn < 1024, all 1,024 turns in FIFO order, and the original scheduler retained. It creates only two ephemeral hubs and completes their disposal; it changes no global thread-pool limits or persistent business nodes.This is host framework code, so rollout replaces the implementation; no individual node recycle address is required.