Skip to content

Commit e08abde

Browse files
GH-3519: restart a projection agent whose shard wedged out from under it (#3550)
Follow-up to GH-3535 (start-failure isolation) and GH-3520 (rebuild/rewind resume). On multi-store, Wolverine-managed Marten hosts a projection shard can start successfully and then die underneath its EventSubscriptionAgent wrapper -- a lost first-assignment start race that wedged daemon-side, a daemon-side stop, or an execution-loop fault. Two gaps kept it dead for the process lifetime: 1. EventSubscriptionAgent.Status latched Running once started, so a shard that stopped underneath the wrapper kept reporting Running. NodeAgentController only restarts agents it can see are non-Running, so it saw nothing to fix. 2. NodeAgentController.StartAgentAsync short-circuited on a bare Agents.ContainsKey, treating any registered agent as healthy forever. The recurring Solo reevaluation therefore never resurrected a registered-but-dead agent -- it just sat in the observable 30s retry loop reporting a stale Running while its high-water climbed. Fix both: Status now delegates to the live inner daemon agent once started (and still reads Stopped before start / after an explicit stop), and StartAgentAsync re-drives a registered agent whose shard has Stopped -- stopping it first to release any lingering daemon-side shard state -- while leaving genuinely Running and deliberately Paused (error backoff / blue-green gate) agents untouched. Red-green regression test agent_restart_when_wedged in CoreTests. Also bumps to 6.22.0-alpha.3. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent edf58fc commit e08abde

4 files changed

Lines changed: 217 additions & 5 deletions

File tree

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
1515
<ImplicitUsings>true</ImplicitUsings>
1616
<Nullable>enable</Nullable>
17-
<Version>6.22.0-alpha.2</Version>
17+
<Version>6.22.0-alpha.3</Version>
1818
<RepositoryUrl>$(PackageProjectUrl)</RepositoryUrl>
1919
<PublishRepositoryUrl>true</PublishRepositoryUrl>
2020
<EmbedUntrackedSources>true</EmbedUntrackedSources>
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
using JasperFx;
2+
using JasperFx.Core;
3+
using Microsoft.Extensions.Logging.Abstractions;
4+
using NSubstitute;
5+
using Shouldly;
6+
using Wolverine.Runtime;
7+
using Wolverine.Runtime.Agents;
8+
using Xunit;
9+
10+
namespace CoreTests.Runtime.Agents;
11+
12+
/// <summary>
13+
/// Regression coverage for GH-3519. A projection / event-subscription shard that starts and then dies
14+
/// underneath its wrapper (a lost first-assignment start race that wedged at the daemon, a daemon-side
15+
/// stop, or an execution-loop fault) used to stay registered on the node reporting a stale Running.
16+
/// NodeAgentController.StartAgentAsync short-circuited on the bare ContainsKey, so the recurring Solo
17+
/// reevaluation never resurrected it -- it just sat in the observable 30s retry loop forever. The
18+
/// controller must now notice a registered-but-Stopped agent and re-drive its start.
19+
/// </summary>
20+
public class agent_restart_when_wedged
21+
{
22+
private readonly WolverineOptions _options;
23+
private readonly IWolverineRuntime _runtime;
24+
private readonly CancellationTokenSource _cancellation = new();
25+
26+
public agent_restart_when_wedged()
27+
{
28+
_options = new WolverineOptions
29+
{
30+
ApplicationAssembly = GetType().Assembly
31+
};
32+
_options.Durability.Mode = DurabilityMode.Solo;
33+
_options.Durability.DurabilityAgentEnabled = false; // skip MessageStoreCollection wiring
34+
_options.Durability.CheckAssignmentPeriod = 1.Hours(); // keep the recurring loop out of the way
35+
36+
_runtime = Substitute.For<IWolverineRuntime>();
37+
_runtime.Options.Returns(_options);
38+
_runtime.DurabilitySettings.Returns(_options.Durability);
39+
_runtime.Observer.Returns(Substitute.For<IWolverineObserver>());
40+
}
41+
42+
private NodeAgentController controllerFor(params FakeAgent[] agents)
43+
{
44+
var family = new FakeAgentFamily("test-family");
45+
foreach (var agent in agents)
46+
{
47+
family.Add(agent);
48+
}
49+
50+
return new NodeAgentController(
51+
_runtime,
52+
Substitute.For<INodeAgentPersistence>(),
53+
[family],
54+
NullLogger<NodeAgentController>.Instance,
55+
_cancellation.Token);
56+
}
57+
58+
[Fact]
59+
public async Task restarts_a_registered_agent_whose_shard_has_stopped_underneath_it()
60+
{
61+
var uri = new Uri("test-family://wedged");
62+
var agent = new FakeAgent(uri);
63+
var controller = controllerFor(agent);
64+
65+
await controller.StartAgentAsync(uri);
66+
agent.StartCount.ShouldBe(1);
67+
controller.Agents.ContainsKey(uri).ShouldBeTrue();
68+
69+
// Simulate the shard dying underneath the wrapper: the agent is still registered on the node,
70+
// but its underlying status has flipped to Stopped (what EventSubscriptionAgent.Status now
71+
// surfaces from the live daemon shard).
72+
agent.SimulateShardStopped();
73+
74+
await controller.StartAgentAsync(uri);
75+
76+
// Before the fix this second call short-circuited on ContainsKey and left the shard dead. Now
77+
// the wedged agent is stopped, evicted, and re-driven.
78+
agent.StartCount.ShouldBe(2);
79+
agent.Status.ShouldBe(AgentStatus.Running);
80+
controller.Agents.ContainsKey(uri).ShouldBeTrue();
81+
}
82+
83+
[Fact]
84+
public async Task does_not_restart_a_genuinely_running_agent()
85+
{
86+
var uri = new Uri("test-family://healthy");
87+
var agent = new FakeAgent(uri);
88+
var controller = controllerFor(agent);
89+
90+
await controller.StartAgentAsync(uri);
91+
await controller.StartAgentAsync(uri);
92+
await controller.StartAgentAsync(uri);
93+
94+
// A running agent stays idempotent -- no needless stop/start churn.
95+
agent.StartCount.ShouldBe(1);
96+
agent.StopCount.ShouldBe(0);
97+
}
98+
99+
[Fact]
100+
public async Task leaves_a_paused_agent_alone()
101+
{
102+
var uri = new Uri("test-family://paused");
103+
var agent = new FakeAgent(uri);
104+
var controller = controllerFor(agent);
105+
106+
await controller.StartAgentAsync(uri);
107+
108+
// Paused is a deliberate error-backoff / blue-green state that owns its own resume schedule.
109+
agent.SimulatePaused();
110+
await controller.StartAgentAsync(uri);
111+
112+
agent.StartCount.ShouldBe(1);
113+
agent.StopCount.ShouldBe(0);
114+
}
115+
116+
private class FakeAgentFamily : IAgentFamily
117+
{
118+
private readonly Dictionary<Uri, FakeAgent> _agents = new();
119+
120+
public FakeAgentFamily(string scheme)
121+
{
122+
Scheme = scheme;
123+
}
124+
125+
public void Add(FakeAgent agent) => _agents[agent.Uri] = agent;
126+
127+
public string Scheme { get; }
128+
129+
public ValueTask<IReadOnlyList<Uri>> AllKnownAgentsAsync()
130+
=> ValueTask.FromResult<IReadOnlyList<Uri>>(_agents.Keys.ToList());
131+
132+
public ValueTask<IAgent> BuildAgentAsync(Uri uri, IWolverineRuntime wolverineRuntime)
133+
=> ValueTask.FromResult<IAgent>(_agents[uri]);
134+
135+
public ValueTask<IReadOnlyList<Uri>> SupportedAgentsAsync()
136+
=> ValueTask.FromResult<IReadOnlyList<Uri>>(_agents.Keys.ToList());
137+
138+
public ValueTask EvaluateAssignmentsAsync(AssignmentGrid assignments) => ValueTask.CompletedTask;
139+
}
140+
141+
private class FakeAgent : IAgent
142+
{
143+
public FakeAgent(Uri uri) => Uri = uri;
144+
145+
public int StartCount { get; private set; }
146+
public int StopCount { get; private set; }
147+
148+
public Uri Uri { get; }
149+
public AgentStatus Status { get; private set; } = AgentStatus.Stopped;
150+
151+
// Flip the underlying status without touching the controller registration, mimicking a daemon
152+
// shard that stops out from under the wrapper.
153+
public void SimulateShardStopped() => Status = AgentStatus.Stopped;
154+
public void SimulatePaused() => Status = AgentStatus.Paused;
155+
156+
public Task StartAsync(CancellationToken cancellationToken)
157+
{
158+
StartCount++;
159+
Status = AgentStatus.Running;
160+
return Task.CompletedTask;
161+
}
162+
163+
public Task StopAsync(CancellationToken cancellationToken)
164+
{
165+
StopCount++;
166+
Status = AgentStatus.Stopped;
167+
return Task.CompletedTask;
168+
}
169+
}
170+
}

src/Wolverine/Runtime/Agents/EventSubscriptionAgent.cs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,23 @@ await _daemon.RewindSubscriptionAsync(_shardName.Name, _shardName.TenantId, canc
124124

125125
public Uri Uri { get; }
126126

127-
// Be nice for this to get the Paused too
128-
public AgentStatus Status { get; private set; } = AgentStatus.Stopped;
127+
private AgentStatus _status = AgentStatus.Stopped;
128+
129+
// GH-3519: reflect the LIVE daemon shard status once we have started rather than latching a value.
130+
// The wedge in the field: a shard that stops or idles underneath this wrapper -- a lost
131+
// first-assignment start race, a daemon-side stop, or an execution-loop fault -- used to keep
132+
// reading Running because nothing flipped the latched field back. NodeAgentController only restarts
133+
// agents it can see are non-Running, so a wrapper that lies "Running" over a dead shard froze at
134+
// RegisteredIdle forever while its high-water climbed. Delegating to the inner agent (whose Status
135+
// the daemon keeps current) lets the controller's reevaluation notice the dead shard and restart it.
136+
// Before an inner agent exists, or after an explicit StopAsync (which sets _status = Stopped), fall
137+
// back to the wrapper's own tracked value so a not-yet-started / deliberately-stopped agent still
138+
// reads Stopped.
139+
public AgentStatus Status
140+
{
141+
get => _status == AgentStatus.Stopped ? AgentStatus.Stopped : _innerAgent?.Status ?? _status;
142+
private set => _status = value;
143+
}
129144

130145
public Task<HealthCheckResult> CheckHealthAsync(HealthCheckContext context,
131146
CancellationToken cancellationToken = default)

src/Wolverine/Runtime/Agents/NodeAgentController.cs

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,9 +171,36 @@ private ValueTask<IAgent> findAgentAsync(Uri uri)
171171

172172
public async Task StartAgentAsync(Uri agentUri)
173173
{
174-
if (Agents.ContainsKey(agentUri))
174+
if (Agents.TryGetValue(agentUri, out var existing))
175175
{
176-
return;
176+
// Idempotent for an agent that is genuinely still running -- or one deliberately Paused by an
177+
// error backoff / blue-green side-effect gate, which owns its own resume schedule and must not
178+
// be fought here.
179+
if (existing.Status != AgentStatus.Stopped)
180+
{
181+
return;
182+
}
183+
184+
// GH-3519: the agent is still registered on this node but its underlying shard has stopped
185+
// (e.g. an event-subscription shard that lost a first-assignment startup race and wedged, or
186+
// whose daemon execution loop faulted). The old blanket ContainsKey short-circuit treated any
187+
// registered agent as healthy forever, so the recurring reevaluation never resurrected a
188+
// wedged one -- it just sat in the 30s retry loop reporting a stale Running. Evict the dead
189+
// registration -- stopping it first to release any lingering daemon-side shard state -- so the
190+
// start below actually re-drives it.
191+
_logger.LogInformation(
192+
"Agent {AgentUri} is still registered on node {NodeNumber} but its shard is stopped; restarting it",
193+
agentUri, _runtime.Options.Durability.AssignedNodeNumber);
194+
195+
Agents.TryRemove(agentUri, out _);
196+
try
197+
{
198+
await existing.StopAsync(_cancellation.Token);
199+
}
200+
catch (Exception e)
201+
{
202+
_logger.LogDebug(e, "Error stopping wedged agent {AgentUri} before restarting it", agentUri);
203+
}
177204
}
178205

179206
var agent = await findAgentAsync(agentUri);

0 commit comments

Comments
 (0)