Skip to content

Commit e693a7f

Browse files
authored
Fix Federated Event Stream subscription teardown on per-event errors (#9995)
1 parent 2ec6985 commit e693a7f

8 files changed

Lines changed: 1515 additions & 16 deletions

File tree

src/HotChocolate/Fusion/src/Fusion.Execution/Execution/ExecutionState.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ internal sealed class ExecutionState
2424
private ulong[] _failedOrSkippedBitset = [];
2525

2626
private bool _collectTelemetry;
27+
private bool _processingCompletedEarly;
2728
private CancellationTokenSource _cts = default!;
2829
private byte[] _nodeStates = [];
2930
private int[] _remainingDependencies = [];
@@ -39,6 +40,20 @@ public void Initialize(bool collectTelemetry, CancellationTokenSource cts)
3940
_cts = cts;
4041
}
4142

43+
/// <summary>
44+
/// Sets the CancellationTokenSource that <see cref="CancelProcessing"/> cancels, letting a subscription scope it
45+
/// per event instead of to the whole request.
46+
/// </summary>
47+
public void SetCancellationSource(CancellationTokenSource cts)
48+
=> _cts = cts;
49+
50+
/// <summary>
51+
/// True when processing stopped early because a field error null-propagated to the root,
52+
/// settling the result as <c>null</c>. Lets the caller tell this self-inflicted stop from a real
53+
/// cancellation (timeout or abort).
54+
/// </summary>
55+
public bool ProcessingCompletedEarly => _processingCompletedEarly;
56+
4257
public void Clean()
4358
{
4459
Reset();
@@ -141,6 +156,7 @@ public void Reset()
141156
ClearPendingMerges();
142157
_mergeFailures?.Clear();
143158
_activeNodes = 0;
159+
_processingCompletedEarly = false;
144160

145161
Traces.Clear();
146162
Signal.TryResetToIdle();
@@ -223,6 +239,8 @@ public ExecutionNodeResult ApplyPendingMergeFailure(ExecutionNodeResult result)
223239

224240
public void CancelProcessing()
225241
{
242+
_processingCompletedEarly = true;
243+
226244
if (!_cts.IsCancellationRequested)
227245
{
228246
_cts.Cancel();

src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanExecutor.cs

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,13 +1047,12 @@ private static async IAsyncEnumerable<OperationResult> CreateResponseStream(
10471047
static state => Unsafe.As<AsyncAutoResetEvent>(state)!.TryResetToIdle(),
10481048
executionState.Signal);
10491049

1050-
// We allocate a single CancellationTokenSource per subscription and reuse it
1051-
// across all events via TryReset(). The execution token is linked in so that
1052-
// client-abort / server-shutdown still propagates.
1053-
var eventCts = new CancellationTokenSource();
1054-
var eventCtsRegistration = executionCancellationToken.UnsafeRegister(
1055-
static state => Unsafe.As<CancellationTokenSource>(state)!.Cancel(),
1056-
eventCts);
1050+
// We allocate one CancellationTokenSource per subscription and reuse it across
1051+
// healthy events via TryReset(). If a cancellation is requested because of
1052+
// null-propagation to the root, we replace the source before the next event.
1053+
// The execution token is linked in so that client-abort / server-shutdown still
1054+
// propagates.
1055+
var (eventCts, eventCtsRegistration) = CreateEventCancellation();
10571056

10581057
var schemaName = GetSubscriptionSchemaName(context, subscriptionNode);
10591058

@@ -1140,9 +1139,14 @@ private static async IAsyncEnumerable<OperationResult> CreateResponseStream(
11401139
// so we throw here to properly cancel the request execution.
11411140
requestCancellationToken.ThrowIfCancellationRequested();
11421141

1143-
// If the event budget was exhausted, surface it as a cancellation so the
1144-
// stream tears down and the caller can observe the timeout.
1145-
eventToken.ThrowIfCancellationRequested();
1142+
// If the event token was cancelled by a genuine timeout or abort, tear the
1143+
// stream down. A root-null halt also cancels the event token, but that is benign
1144+
// (the result is a settled {data: null, errors: [...]}), so we keep the stream
1145+
// alive and let context.Complete() produce it.
1146+
if (!executionState.ProcessingCompletedEarly)
1147+
{
1148+
eventToken.ThrowIfCancellationRequested();
1149+
}
11461150

11471151
result = context.Complete(reusable: true);
11481152
}
@@ -1166,11 +1170,24 @@ private static async IAsyncEnumerable<OperationResult> CreateResponseStream(
11661170
concurrencyGate!.Release();
11671171
}
11681172

1169-
// Reset the shared CTS so the next event can start with a fresh budget.
1170-
// If TryReset() returns false the source was cancelled (timeout or
1171-
// client-abort); the thrown OperationCanceledException has already
1172-
// propagated and the enumerator surfaces the teardown.
1173-
eventCts.TryReset();
1173+
if (executionState.ProcessingCompletedEarly)
1174+
{
1175+
// A root-null halt cancelled the event source. A cancelled source cannot be
1176+
// reset, so swap in a fresh one (and re-link client-abort / shutdown) for the
1177+
// next event. This only happens on events that null-propagate to the root.
1178+
await eventCtsRegistration.DisposeAsync();
1179+
eventCts.Dispose();
1180+
(eventCts, eventCtsRegistration) = CreateEventCancellation();
1181+
}
1182+
else
1183+
{
1184+
// Healthy event: reset the shared source so the next event starts with a
1185+
// fresh timeout budget and no allocation. If TryReset() returns false the
1186+
// source was cancelled by a timeout or client-abort; the thrown
1187+
// OperationCanceledException has already propagated and the enumerator
1188+
// surfaces the teardown.
1189+
eventCts.TryReset();
1190+
}
11741191
}
11751192

11761193
yield return result;
@@ -1181,6 +1198,18 @@ private static async IAsyncEnumerable<OperationResult> CreateResponseStream(
11811198
await eventCtsRegistration.DisposeAsync();
11821199
eventCts?.Dispose();
11831200
}
1201+
1202+
// Creates a fresh event-scoped cancellation source, links client-abort / shutdown into it,
1203+
// and installs it as the engine's cancellation source for the next event.
1204+
(CancellationTokenSource Source, CancellationTokenRegistration Registration) CreateEventCancellation()
1205+
{
1206+
var cts = new CancellationTokenSource();
1207+
var registration = executionCancellationToken.UnsafeRegister(
1208+
static state => Unsafe.As<CancellationTokenSource>(state)!.Cancel(),
1209+
cts);
1210+
executionState.SetCancellationSource(cts);
1211+
return (cts, registration);
1212+
}
11841213
}
11851214

11861215
private static string GetSubscriptionSchemaName(

src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Results/FetchResultStore.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1819,7 +1819,10 @@ private static SourceResultElement GetDataElement(SelectionPath sourcePath, Sour
18191819
{
18201820
if (current.ValueKind != JsonValueKind.Object)
18211821
{
1822-
return default;
1822+
// An intermediate value on the path is not an object. If it is null, the target
1823+
// value null-propagates from here, so we surface the null element (not Undefined)
1824+
// and let value completion integrate it together with any source error.
1825+
return current.ValueKind is JsonValueKind.Null ? current : default;
18231826
}
18241827

18251828
var segment = sourcePath[i];
@@ -1867,6 +1870,13 @@ private static SourceResultElement GetDataElement(SelectionPath sourcePath, Sour
18671870
{
18681871
var segment = sourcePath[i];
18691872

1873+
// Source schema error paths only carry response names and list indices, never type
1874+
// conditions, so inline-fragment segments have no corresponding level in the trie.
1875+
if (segment.Kind is SelectionPathSegmentKind.InlineFragment)
1876+
{
1877+
continue;
1878+
}
1879+
18701880
if (!current.TryGetValue(segment.Name, out current))
18711881
{
18721882
return null;
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
using System.Text;
2+
using DotNet.Testcontainers.Builders;
3+
using DotNet.Testcontainers.Containers;
4+
using HotChocolate.Execution;
5+
using HotChocolate.Fusion.Subscriptions.NATS;
6+
using HotChocolate.Transport.Http;
7+
using HotChocolate.Types.Composite;
8+
using HotChocolate.Types.Relay;
9+
using Microsoft.Extensions.DependencyInjection;
10+
using NATS.Client.Core;
11+
using NATS.Client.JetStream;
12+
using NATS.Client.JetStream.Models;
13+
using OperationRequest = HotChocolate.Transport.OperationRequest;
14+
using OperationResult = HotChocolate.Transport.OperationResult;
15+
16+
namespace HotChocolate.Fusion;
17+
18+
/// <summary>
19+
/// End-to-end coverage for a Federated Event Stream whose selected fields require an enrichment
20+
/// lookup that the source schema rejects. Models the fusion-demo-sfo Reviews subgraph: a relay
21+
/// Node <c>Review</c> (so <c>Review.id</c> is a global id and the enrichment runs through
22+
/// <c>node(id:)</c>), an <c>@eventStream</c> message that only carries the id, and a client that
23+
/// also selects <c>body</c>. When an event carries an id that is not a valid global id the lookup
24+
/// errors; the subscription must surface that as a per-event error result (subgraph error plus
25+
/// standard non-null propagation) and stay alive, not silently close.
26+
/// </summary>
27+
public class EventStreamEnrichmentErrorOverHttpTests : FusionTestBase
28+
{
29+
private const string BrokerName = "nats";
30+
private const string Topic = "onCreateReview";
31+
private const string GatewayUrl = "http://localhost:5000/graphql";
32+
33+
[Fact]
34+
public async Task Subscribe_Should_ReturnErrorAndStayAlive_When_EnrichmentLookupRejectsEventId()
35+
{
36+
// arrange
37+
await using var nats = await JetStreamNatsFixture.StartAsync();
38+
var stream = "S" + Guid.NewGuid().ToString("N");
39+
var durable = "D" + Guid.NewGuid().ToString("N");
40+
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
41+
42+
await CreateStreamAsync(nats.Url, stream, Topic, cts.Token);
43+
44+
using var events = CreateSourceSchema(
45+
"EVENTS",
46+
b => b
47+
.AddQueryType<ReviewsApi.Query>()
48+
.AddSubscriptionType<ReviewsApi.Subscription>()
49+
.AddGlobalObjectIdentification(o => o.MarkNodeFieldAsLookup = true)
50+
.ModifyOptions(o => o.StrictValidation = false));
51+
52+
using var gateway = await CreateCompositeSchemaAsync(
53+
[("EVENTS", events)],
54+
configureServices: services => services.AddNatsEventStreamBroker(
55+
BrokerName,
56+
o =>
57+
{
58+
o.Url = nats.Url;
59+
o.JetStream = new NatsJetStreamOptions { Stream = stream, DurableConsumer = durable };
60+
}),
61+
configureGatewayBuilder: b => b.ModifyRequestOptions(o => o.AllowOperationPlanRequests = false));
62+
63+
using var client = GraphQLHttpClient.Create(gateway.CreateClient());
64+
65+
var request = new OperationRequest(
66+
"""
67+
subscription {
68+
onCreateReview {
69+
review {
70+
id
71+
body
72+
}
73+
cursor
74+
}
75+
}
76+
""");
77+
78+
// The encoded global id the second (valid) event publishes, matching what Review.id is.
79+
var schema = await events.Services.GetSchemaAsync("EVENTS", cts.Token);
80+
var validId = schema.Services
81+
.GetRequiredService<INodeIdSerializerAccessor>()
82+
.Serializer
83+
.Format("Review", 1);
84+
85+
// act
86+
// The first event carries the raw database key (not a valid global id), so the node lookup
87+
// that fetches `body` fails. The second event carries the encoded global id and resolves.
88+
using var response = await client.PostAsync(request, new Uri(GatewayUrl), cts.Token);
89+
await PublishAsync(nats.Url, Topic, """{"review":{"id":1}}""", cts.Token);
90+
await PublishAsync(nats.Url, Topic, "{\"review\":{\"id\":\"" + validId + "\"}}", cts.Token);
91+
92+
var results = new List<OperationResult>();
93+
await foreach (var result in response.ReadAsResultStreamAsync().WithCancellation(cts.Token))
94+
{
95+
results.Add(result);
96+
97+
if (results.Count == 2)
98+
{
99+
break;
100+
}
101+
}
102+
103+
// assert
104+
// First event: the message carried a raw int id, so the relay `node(id: ID!)` enrichment
105+
// lookup is fed an int where a string global id is required. That error is integrated and
106+
// `body` (non-null) null-propagates to the non-null root; the stream is NOT torn down.
107+
// Second event: the stream recovered and delivered the enriched review.
108+
Assert.Equal(2, results.Count);
109+
results.MatchInlineSnapshots(
110+
[
111+
"""
112+
{
113+
"data": null,
114+
"errors": [
115+
{
116+
"message": "The argument literal representation is `HotChocolate.Language.IntValueNode` which is not compatible with the request literal type `HotChocolate.Language.StringValueNode`.",
117+
"path": [
118+
"onCreateReview",
119+
"review",
120+
"body"
121+
],
122+
"extensions": {
123+
"fieldName": "node",
124+
"argumentName": "id",
125+
"requestedType": "HotChocolate.Language.StringValueNode",
126+
"actualType": "HotChocolate.Language.IntValueNode"
127+
}
128+
}
129+
]
130+
}
131+
""",
132+
"""
133+
{
134+
"data": {
135+
"onCreateReview": {
136+
"review": {
137+
"id": "UmV2aWV3OjE=",
138+
"body": "A great read"
139+
},
140+
"cursor": "Mg=="
141+
}
142+
}
143+
}
144+
"""
145+
]);
146+
}
147+
148+
private static async Task CreateStreamAsync(string url, string stream, string subject, CancellationToken ct)
149+
{
150+
await using var connection = new NatsConnection(new NatsOpts { Url = url });
151+
var js = new NatsJSContext(connection);
152+
await js.CreateStreamAsync(new StreamConfig { Name = stream, Subjects = [subject] }, ct);
153+
}
154+
155+
private static async Task PublishAsync(string url, string subject, string body, CancellationToken ct)
156+
{
157+
await using var connection = new NatsConnection(new NatsOpts { Url = url });
158+
var js = new NatsJSContext(connection);
159+
await js.PublishAsync(subject, Encoding.UTF8.GetBytes(body), cancellationToken: ct);
160+
}
161+
162+
public static class ReviewsApi
163+
{
164+
public class Query
165+
{
166+
public string Version => "1.0.0";
167+
168+
// Mirrors the demo's `[Lookup, NodeResolver] GetReviewByIdAsync(int id)`.
169+
[Lookup, NodeResolver]
170+
public Review? GetReviewById(int id)
171+
=> id == 1 ? new Review(1, "A great read") : null;
172+
}
173+
174+
public class Subscription
175+
{
176+
[EventStream("review { id }", Topic = Topic, Broker = BrokerName)]
177+
public ReviewCreated OnCreateReview([EventCursor] string? after)
178+
=> EventStream.Create<ReviewCreated>(after);
179+
}
180+
181+
public record ReviewCreated(Review Review, [property: EventCursor] string Cursor);
182+
183+
public record Review(int Id, string Body);
184+
}
185+
186+
private sealed class JetStreamNatsFixture : IAsyncDisposable
187+
{
188+
private readonly IContainer _container;
189+
190+
private JetStreamNatsFixture(IContainer container) => _container = container;
191+
192+
public string Url => $"nats://localhost:{_container.GetMappedPublicPort(4222)}";
193+
194+
public static async Task<JetStreamNatsFixture> StartAsync()
195+
{
196+
var fixture = new JetStreamNatsFixture(
197+
new ContainerBuilder("nats:2.10-alpine")
198+
.WithPortBinding(4222, assignRandomHostPort: true)
199+
.WithCommand("-js")
200+
.WithWaitStrategy(Wait.ForUnixContainer().UntilInternalTcpPortIsAvailable(4222))
201+
.Build());
202+
203+
await fixture._container.StartAsync();
204+
await fixture.WaitForConnectionAsync();
205+
return fixture;
206+
}
207+
208+
private async Task WaitForConnectionAsync()
209+
{
210+
var attempt = 0;
211+
while (true)
212+
{
213+
try
214+
{
215+
await using var connection = new NatsConnection(new NatsOpts { Url = Url });
216+
await connection.ConnectAsync();
217+
return;
218+
}
219+
catch (NatsException) when (++attempt < 20)
220+
{
221+
await Task.Delay(TimeSpan.FromMilliseconds(250));
222+
}
223+
}
224+
}
225+
226+
public async ValueTask DisposeAsync() => await _container.DisposeAsync();
227+
}
228+
}

0 commit comments

Comments
 (0)