Skip to content

Prevent silent data: null responses when Fusion diagnostics throw - #10299

Open
rstaib wants to merge 7 commits into
mainfrom
rst/fusion-diagnostics-non-fatal
Open

Prevent silent data: null responses when Fusion diagnostics throw#10299
rstaib wants to merge 7 commits into
mainfrom
rst/fusion-diagnostics-non-fatal

Conversation

@rstaib

@rstaib rstaib commented Aug 26, 2026

Copy link
Copy Markdown
Member

Problem

When a Fusion gateway runs .AddInstrumentation() and an OTel ActivityListener is active, an exception thrown while opening an execution node's diagnostic scope silently killed the node: the subgraph fetch never ran and the operation returned data: null with an empty errors list.

We hit this through a package version skew (HotChocolate.Fusion.Diagnostics 16.5.0 next to Fusion.Execution 16.6.x throws MissingMethodException for OperationSourceText.get_Hash() inside ExecutePlanNodeSpan.SetSourceSchemaTags, reachable once StartActivity returns non-null). The skew is a packaging issue, but the engine turning any diagnostics exception into silent data loss is the defect fixed here:

  1. ExecutionNode.ExecuteAsync ran CreateScope(context) inside the same try as OnExecuteAsync, so a throwing listener skipped the fetch entirely.
  2. The catch only raised the ExecutionNodeError diagnostic event — no GraphQL error was ever produced, and the failed node cascaded Skipped to all dependents.
  3. The only invariant check at response assembly was a Debug.Assert — a no-op in Release — so the malformed data: null, errors: [] response shipped.
  4. scope?.Dispose() in the finally was unguarded and ran before context.CompleteNode(result); a throwing span dispose would skip node completion, unbalancing the active-node count and stalling the executor drain forever.

Fixes

  1. Diagnostics are non-fatal (ExecutionNode.ExecuteAsync): CreateScope is isolated in its own try/catch — on failure the node executes without a scope. scope?.Dispose() is guarded so CompleteNode always runs and the active-node count stays balanced. Failures are reported through a self-guarded ExecutionNodeError event, and the OnError call in the main catch is guarded as well.
  2. Failed nodes surface a GraphQL error: a node result carries an exception only when it escaped the node's own error handling. ExecutionState.CompleteNode tracks those exceptions (engine-cancellation OperationCanceledExceptions excluded; pending-merge failures are marked ErrorReported since ApplyMerge already added their errors) and OperationPlanContext.Complete() surfaces each as an Unexpected Execution Error via ErrorBuilder.FromException + IErrorHandler.Handle. Surfacing is unconditional, so an unrelated node's handled error cannot mask another node's silent crash. If data is invalidated and no error was collected from any source, a generic error is added as a last resort — data: null is never paired with empty errors, in Release too. The Debug.Assert stays.
  3. Missing EventStream step kind (ExecutePlanNodeSpan): the KindValues dictionary lacked ExecutionNodeType.EventStream (reachable via ExecuteSubscriptionNode for event-stream subscription events, where KindValues[node.Type] threw KeyNotFoundException). Added an event_stream kind value to the semantic conventions and the dictionary, and switched the lookup to TryGetValue so future enum members degrade to an untagged span instead of throwing.

Tests

  • DiagnosticEventListenerResilienceTests (new): a registered listener that throws in ExecuteOperationNode (scope creation) or from the returned scope's Dispose — the subgraph fetch still executes and the operation returns data.
  • ExecutionNodeCompletionTests (extended): a throwing CreateScope doesn't prevent execution; a throwing scope dispose doesn't swallow node completion; an unhandled node failure yields an Unexpected Execution Error from Complete(); an unrelated already-collected error does not suppress that surfacing; ErrorReported results are not double-surfaced.
  • FusionActivityExecutionDiagnosticListenerTests (extended): every ExecutionNodeType member maps to a step kind value, including EventStreamevent_stream.

The failure-mode tests fail against the previous code (the silent case fails with exactly the empty-errors symptom). Fusion.Execution.Tests: 1076 passed, 0 failed.

Follow-ups (flagged, not implemented)

  • Packaging version skew: HotChocolate.Fusion.Diagnostics ships with an open version floor on its Fusion dependencies, so NuGet resolves mismatched pairs (16.5.0 diagnostics next to 16.6.x execution — the skew that triggered this). Consider an upper-bounded dependency range or a startup version check in AddInstrumentation.
  • Aggregate listener fan-out has no per-listener isolation: AggregateFusionExecutionDiagnosticEvents invokes listeners in plain loops — with multiple listeners registered, one throwing during scope creation/dispose leaks the other listeners' already-created scopes (an Activity-backed span is never stopped and Activity.Current stays pinned).
  • Fallback errors carry no path: errors surfaced by fix 2 are root-level; the base ExecutionNode has no result-selection-set to derive paths from.

A diagnostic listener that threw while opening an execution node's scope
silently killed the node: the subgraph fetch never ran and the operation
returned data: null with an empty errors list. A throwing scope dispose
could additionally skip node completion and stall the executor drain.

- Isolate CreateScope and guard scope disposal and OnError in
  ExecutionNode.ExecuteAsync so diagnostics can never affect execution
  or node completion.
- Track exceptions that escaped a node's own error handling and surface
  them as GraphQL errors at completion, so missing data always comes
  with an explaining error, in Release too.
- Add the missing EventStream step kind to ExecutePlanNodeSpan and fall
  back to an untagged span for unmapped execution node types.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CrEmm3srU8R6WuGEGzZmos
@rstaib rstaib changed the title Make Fusion execution resilient to throwing diagnostic listeners Prevent silent data: null responses when Fusion diagnostics throw Aug 26, 2026
@rstaib
rstaib requested a review from michaelstaib August 26, 2026 13:56
…ections

The introspection node built its result document over the full root
selection set, but only populated the introspection selections. Property
slots belonging to data fields stayed unassigned, and building the
document crashed with a NullReferenceException that the engine swallowed
silently until the previous commit made such failures visible.

The document is now built over exactly the selections the node resolves,
and the build step moved inside the node's error handling so a failure
there surfaces as a proper GraphQL error.

The Fetch_User_With_Invalid_Node_Field snapshot now expects the error
that the previously silent node-id coercion failure produces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CrEmm3srU8R6WuGEGzZmos
@rstaib rstaib self-assigned this Aug 26, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Patch coverage

85.4% of changed lines covered (76/89)

File Covered Changed Patch %
…/src/Fusion.Execution/Execution/OperationPlanContext.cs 9 16 56.3% 🔴
…/Fusion/src/Fusion.Execution/Execution/Nodes/ExecutionNode.cs 22 28 78.6% 🔴
…/Fusion/src/Fusion.Diagnostics/Spans/ExecutePlanNodeSpan.cs 6 6 100.0% 🟢
…/Fusion/src/Fusion.Execution/Execution/ExecutionState.cs 10 10 100.0% 🟢
…/src/Fusion.Execution/Execution/Nodes/ExecutionNodeResult.cs 2 2 100.0% 🟢
…/Execution/Nodes/IntrospectionExecutionNode.cs 15 15 100.0% 🟢
…/Fusion.Execution/Text/Json/SourceResultDocumentBuilder.cs 12 12 100.0% 🟢
Uncovered changed lines (JSON)
{
  "sha": "212ca78496ff27be6d7f6871f601708ef0be2bcf",
  "files": [
    { "path": "src/HotChocolate/Fusion/src/Fusion.Execution/Execution/OperationPlanContext.cs", "ranges": [[784, 790]] },
    { "path": "src/HotChocolate/Fusion/src/Fusion.Execution/Execution/Nodes/ExecutionNode.cs", "ranges": [[118, 119], [123, 123], [173, 174], [178, 178]] }
  ]
}

Project coverage: 56.3% (265163/470766 lines)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants