Skip to content

Commit 636ce3a

Browse files
alisan3claude
andcommitted
Cover the command and publish legs of saga fault routing
OnReplyFault was only exercised through a request carrying a response type. Add the command variant, handled by SendConsumer rather than the request consumer, which both faults and acknowledges through the same reply endpoint. Characterise the publish leg as well, which is not covered. A published event carries no reply address, so a failing subscriber is routed to the error endpoint and the saga is never told, holding its state until it times out. The saga header does survive onto the error endpoint, which is what any correlated workaround, or a later fix, has to build on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e4deae6 commit 636ce3a

2 files changed

Lines changed: 357 additions & 0 deletions

File tree

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using Mocha.Events;
3+
using Mocha.Features;
4+
using Mocha.Middlewares;
5+
using Mocha.Transport.InMemory;
6+
7+
namespace Mocha.Sagas.Tests;
8+
9+
/// <summary>
10+
/// Characterizes the publish leg of saga fault handling. A published event carries no reply address,
11+
/// so a failing subscriber is routed to the error endpoint rather than back to the saga.
12+
/// </summary>
13+
public class SagaPublishFaultTests
14+
{
15+
private static readonly TimeSpan s_timeout = TimeSpan.FromSeconds(10);
16+
17+
private static readonly TaskCompletionSource<NotAcknowledgedEvent> s_faultObserved = new();
18+
19+
[Fact]
20+
public async Task Saga_Should_NotReceiveFault_When_PublishedEventSubscriberFaults()
21+
{
22+
// arrange
23+
var handlerRan = new TaskCompletionSource();
24+
var services = new ServiceCollection();
25+
services.AddInMemorySagas();
26+
var builder = services.AddMessageBus();
27+
builder.Services.AddSingleton(handlerRan);
28+
builder.AddEventHandler<FaultingSubscriber>();
29+
builder.AddSaga<PublishingSaga>();
30+
builder.AddInMemory();
31+
32+
await using var provider = services.BuildServiceProvider();
33+
var runtime = (MessagingRuntime)provider.GetRequiredService<IMessagingRuntime>();
34+
await runtime.StartAsync(CancellationToken.None);
35+
36+
using var scope = provider.CreateScope();
37+
var bus = scope.ServiceProvider.GetRequiredService<IMessageBus>();
38+
var storage = provider.GetRequiredService<InMemorySagaStateStorage>();
39+
40+
// act
41+
await bus.PublishAsync(new StartPublishEvent(), CancellationToken.None);
42+
43+
// assert - the subscriber ran and threw, proving the published event was delivered
44+
await handlerRan.Task.WaitAsync(s_timeout, TestContext.Current.CancellationToken);
45+
46+
// assert - the saga is never told, so it stays in its waiting state holding its state
47+
var completed = await Task.WhenAny(s_faultObserved.Task, Task.Delay(2000, TestContext.Current.CancellationToken));
48+
Assert.NotSame(s_faultObserved.Task, completed);
49+
Assert.Equal(1, storage.Count);
50+
}
51+
52+
[Fact]
53+
public async Task ErrorEndpoint_Should_ReceiveSagaId_When_PublishedEventSubscriberFaults()
54+
{
55+
// The fault carries the original envelope, so the saga header survives onto the error
56+
// endpoint even though the saga itself is never notified.
57+
58+
// arrange
59+
var handlerRan = new TaskCompletionSource();
60+
var services = new ServiceCollection();
61+
services.AddInMemorySagas();
62+
var builder = services.AddMessageBus();
63+
builder.Services.AddSingleton(handlerRan);
64+
builder.AddEventHandler<ErrorQueueSubscriber>();
65+
builder.AddSaga<ErrorQueuePublishingSaga>();
66+
builder.AddInMemory(d => d.AddConvention(new TestErrorEndpointConvention()));
67+
68+
await using var provider = services.BuildServiceProvider();
69+
var runtime = (MessagingRuntime)provider.GetRequiredService<IMessagingRuntime>();
70+
await runtime.StartAsync(CancellationToken.None);
71+
72+
using var scope = provider.CreateScope();
73+
var bus = scope.ServiceProvider.GetRequiredService<IMessageBus>();
74+
75+
// act
76+
await bus.PublishAsync(new StartErrorQueueEvent(), CancellationToken.None);
77+
await handlerRan.Task.WaitAsync(s_timeout, TestContext.Current.CancellationToken);
78+
79+
// assert
80+
var envelope = await ReadFirstErrorEnvelopeAsync(runtime);
81+
Assert.Equal(MessageKind.Fault, envelope.Headers!.Get(MessageHeaders.MessageKind));
82+
Assert.True(Guid.TryParse(envelope.Headers!.Get(SagaContextData.SagaId), out _));
83+
}
84+
85+
/// <summary>
86+
/// Drains whichever error queue receives a message first, since every default endpoint gets one.
87+
/// </summary>
88+
private static async Task<MessageEnvelope> ReadFirstErrorEnvelopeAsync(MessagingRuntime runtime)
89+
{
90+
var transport = runtime.Transports.OfType<InMemoryMessagingTransport>().Single();
91+
var topology = (InMemoryMessagingTopology)transport.Topology;
92+
var errorQueues = topology.Queues.Where(q => q.Name.EndsWith("_error")).ToArray();
93+
94+
using var cts = new CancellationTokenSource(s_timeout);
95+
96+
var reads = errorQueues
97+
.Select(async queue =>
98+
{
99+
await foreach (var item in queue.ConsumeAsync(cts.Token))
100+
{
101+
var envelope = new MessageEnvelope(item.Envelope);
102+
item.Dispose();
103+
return envelope;
104+
}
105+
106+
return null;
107+
})
108+
.ToArray();
109+
110+
var completed = await Task.WhenAny(reads);
111+
await cts.CancelAsync();
112+
113+
return await completed ?? throw new InvalidOperationException("no error envelope was received");
114+
}
115+
116+
private sealed class TestErrorEndpointConvention : IInMemoryReceiveEndpointConfigurationConvention
117+
{
118+
public void Configure(
119+
IMessagingConfigurationContext context,
120+
InMemoryMessagingTransport transport,
121+
InMemoryReceiveEndpointConfiguration configuration)
122+
{
123+
if (configuration is { Kind: ReceiveEndpointKind.Default, QueueName: { } queueName })
124+
{
125+
var feature = configuration.Features.GetOrSet<ReceiveFaultEndpointFeature>();
126+
feature.Address ??= new Uri($"{transport.Schema}:q/{queueName}_error");
127+
}
128+
}
129+
}
130+
131+
public sealed class StartErrorQueueEvent;
132+
133+
public sealed record ErrorQueueEvent(Guid Id);
134+
135+
public sealed class ErrorQueueSubscriber(TaskCompletionSource handlerRan) : IEventHandler<ErrorQueueEvent>
136+
{
137+
public ValueTask HandleAsync(ErrorQueueEvent message, CancellationToken cancellationToken)
138+
{
139+
handlerRan.TrySetResult();
140+
throw new InvalidOperationException("terminal failure");
141+
}
142+
}
143+
144+
public sealed class ErrorQueuePublishingSaga : Saga<PublishState>
145+
{
146+
protected override void Configure(ISagaDescriptor<PublishState> descriptor)
147+
{
148+
descriptor
149+
.Initially()
150+
.OnEvent<StartErrorQueueEvent>()
151+
.StateFactory(_ => new PublishState())
152+
.Publish((_, state) => new ErrorQueueEvent(state.Id))
153+
.TransitionTo("Awaiting");
154+
155+
descriptor.During("Awaiting").OnReplyFault().TransitionTo("Failed");
156+
157+
descriptor.Finally("Failed");
158+
}
159+
}
160+
161+
public sealed class PublishState : SagaStateBase;
162+
163+
public sealed class StartPublishEvent;
164+
165+
public sealed record PublishedEvent(Guid Id);
166+
167+
public sealed class FaultingSubscriber(TaskCompletionSource handlerRan) : IEventHandler<PublishedEvent>
168+
{
169+
public ValueTask HandleAsync(PublishedEvent message, CancellationToken cancellationToken)
170+
{
171+
handlerRan.TrySetResult();
172+
throw new InvalidOperationException("terminal failure");
173+
}
174+
}
175+
176+
public sealed class PublishingSaga : Saga<PublishState>
177+
{
178+
protected override void Configure(ISagaDescriptor<PublishState> descriptor)
179+
{
180+
descriptor
181+
.Initially()
182+
.OnEvent<StartPublishEvent>()
183+
.StateFactory(_ => new PublishState())
184+
.Publish((_, state) => new PublishedEvent(state.Id))
185+
.TransitionTo("Awaiting");
186+
187+
descriptor
188+
.During("Awaiting")
189+
.OnReplyFault()
190+
.Then((_, fault) => s_faultObserved.TrySetResult(fault))
191+
.TransitionTo("Failed");
192+
193+
descriptor.Finally("Failed");
194+
}
195+
}
196+
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using Mocha.Events;
3+
using Mocha.Transport.InMemory;
4+
5+
namespace Mocha.Sagas.Tests;
6+
7+
/// <summary>
8+
/// Tests the reply leg of a saga send whose command has no response type, so it is handled by
9+
/// <c>SendConsumer</c> rather than the request consumer.
10+
/// </summary>
11+
public class SagaSendCommandTests
12+
{
13+
private static readonly TimeSpan s_timeout = TimeSpan.FromSeconds(10);
14+
15+
private static readonly TaskCompletionSource<NotAcknowledgedEvent> s_faultObserved = new();
16+
17+
private static readonly TaskCompletionSource<object> s_replyObserved = new();
18+
19+
private static async Task<ServiceProvider> CreateBusAsync(Action<IMessageBusHostBuilder> configure)
20+
{
21+
var services = new ServiceCollection();
22+
services.AddInMemorySagas();
23+
var builder = services.AddMessageBus();
24+
configure(builder);
25+
builder.AddInMemory();
26+
27+
var provider = services.BuildServiceProvider();
28+
var runtime = (MessagingRuntime)provider.GetRequiredService<IMessagingRuntime>();
29+
await runtime.StartAsync(CancellationToken.None);
30+
return provider;
31+
}
32+
33+
[Fact]
34+
public async Task Saga_Should_ReceiveFault_When_CommandWithoutResponseFaults()
35+
{
36+
// arrange
37+
var handlerRan = new TaskCompletionSource();
38+
await using var provider = await CreateBusAsync(b =>
39+
{
40+
b.Services.AddSingleton(handlerRan);
41+
b.AddRequestHandler<FaultingCommandHandler>();
42+
b.AddSaga<FaultingCommandSaga>();
43+
});
44+
45+
using var scope = provider.CreateScope();
46+
var bus = scope.ServiceProvider.GetRequiredService<IMessageBus>();
47+
48+
// act
49+
await bus.PublishAsync(new StartFaultingCommandEvent(), CancellationToken.None);
50+
51+
// assert - the handler ran and threw, proving the command was delivered
52+
await handlerRan.Task.WaitAsync(s_timeout, TestContext.Current.CancellationToken);
53+
54+
// assert - the fault reply routed back to the saga
55+
var fault = await s_faultObserved.Task.WaitAsync(s_timeout, TestContext.Current.CancellationToken);
56+
Assert.Equal(ErrorCodes.Exception, fault.ErrorCode);
57+
}
58+
59+
[Fact]
60+
public async Task Saga_Should_ReceiveAcknowledgement_When_CommandWithoutResponseSucceeds()
61+
{
62+
// A command with no response type still acknowledges, so the saga's reply route sees an
63+
// AcknowledgedEvent rather than nothing at all.
64+
65+
// arrange
66+
var handlerRan = new TaskCompletionSource();
67+
await using var provider = await CreateBusAsync(b =>
68+
{
69+
b.Services.AddSingleton(handlerRan);
70+
b.AddRequestHandler<SucceedingCommandHandler>();
71+
b.AddSaga<SucceedingCommandSaga>();
72+
});
73+
74+
using var scope = provider.CreateScope();
75+
var bus = scope.ServiceProvider.GetRequiredService<IMessageBus>();
76+
77+
// act
78+
await bus.PublishAsync(new StartSucceedingCommandEvent(), CancellationToken.None);
79+
80+
// assert - the handler ran and returned
81+
await handlerRan.Task.WaitAsync(s_timeout, TestContext.Current.CancellationToken);
82+
83+
// assert - the acknowledgement routed back to the saga
84+
var reply = await s_replyObserved.Task.WaitAsync(s_timeout, TestContext.Current.CancellationToken);
85+
Assert.IsType<AcknowledgedEvent>(reply);
86+
}
87+
88+
public sealed class CommandState : SagaStateBase;
89+
90+
public sealed class StartFaultingCommandEvent;
91+
92+
public sealed class StartSucceedingCommandEvent;
93+
94+
public sealed record FaultingCommand;
95+
96+
public sealed record SucceedingCommand;
97+
98+
public sealed class FaultingCommandHandler(TaskCompletionSource handlerRan)
99+
: IEventRequestHandler<FaultingCommand>
100+
{
101+
public ValueTask HandleAsync(FaultingCommand request, CancellationToken cancellationToken)
102+
{
103+
handlerRan.TrySetResult();
104+
throw new InvalidOperationException("terminal failure");
105+
}
106+
}
107+
108+
public sealed class SucceedingCommandHandler(TaskCompletionSource handlerRan)
109+
: IEventRequestHandler<SucceedingCommand>
110+
{
111+
public ValueTask HandleAsync(SucceedingCommand request, CancellationToken cancellationToken)
112+
{
113+
handlerRan.TrySetResult();
114+
return default;
115+
}
116+
}
117+
118+
public sealed class FaultingCommandSaga : Saga<CommandState>
119+
{
120+
protected override void Configure(ISagaDescriptor<CommandState> descriptor)
121+
{
122+
descriptor
123+
.Initially()
124+
.OnEvent<StartFaultingCommandEvent>()
125+
.StateFactory(_ => new CommandState())
126+
.Send((_, _) => new FaultingCommand())
127+
.TransitionTo("Awaiting");
128+
129+
descriptor
130+
.During("Awaiting")
131+
.OnReplyFault()
132+
.Then((_, fault) => s_faultObserved.TrySetResult(fault))
133+
.TransitionTo("Failed");
134+
135+
descriptor.Finally("Failed");
136+
}
137+
}
138+
139+
public sealed class SucceedingCommandSaga : Saga<CommandState>
140+
{
141+
protected override void Configure(ISagaDescriptor<CommandState> descriptor)
142+
{
143+
descriptor
144+
.Initially()
145+
.OnEvent<StartSucceedingCommandEvent>()
146+
.StateFactory(_ => new CommandState())
147+
.Send((_, _) => new SucceedingCommand())
148+
.TransitionTo("Awaiting");
149+
150+
descriptor
151+
.During("Awaiting")
152+
.OnAnyReply()
153+
.Then((_, reply) => s_replyObserved.TrySetResult(reply))
154+
.TransitionTo("Done");
155+
156+
descriptor.During("Awaiting").OnReplyFault().TransitionTo("Done");
157+
158+
descriptor.Finally("Done");
159+
}
160+
}
161+
}

0 commit comments

Comments
 (0)