Skip to content

Fix SystemStackError when cache_fragment is used inside a mutation - #144

Open
LRFalk01 wants to merge 1 commit into
DmitryTsepelev:masterfrom
LRFalk01:fix/mutation_stack_too_deep
Open

Fix SystemStackError when cache_fragment is used inside a mutation#144
LRFalk01 wants to merge 1 commit into
DmitryTsepelev:masterfrom
LRFalk01:fix/mutation_stack_too_deep

Conversation

@LRFalk01

@LRFalk01 LRFalk01 commented Aug 14, 2026

Copy link
Copy Markdown

What this fixes

cache_fragment inside a mutation's response type raises SystemStackError: stack level too deep. It doesn't need a lazy value, a particular cache store, or nested fragments to trigger, just cache_fragment anywhere under a mutation root field. The same call under a query root is fine.

This should close #135. It may be related to #142.

Why

Tracer#execute_query's ensure block calls after_query (and so Cacher.call) for every query as soon as run_eager returns, mutations included. That's only safe if context.fragments is still empty at that point, and for mutations it isn't.

cache_fragment wraps its result in a LazyCacheResolver, which graphql-ruby treats as a lazy value. Lazy values only get resolved inline, before run_eager returns, when the field is eager, and run_eager marks mutation root fields eager (is_eager = ast_node.operation_type == "mutation"). So by the time execute_query's ensure fires for a mutation, the fragment has already been pushed into context.fragments. For a query, that same resolution happens later, outside execute_query's boundary, so the early hook finds nothing and Cacher.call's own guard skips it.

Once context.fragments has something in it, Cacher.call reaches Fragment#final_value, which calls context.query.result. Query#result checks @executed, and if it's false, it kicks off a fresh Execution::Interpreter.run_all for that same query to compute it. @executed doesn't flip to true until later, in run_all's own result-assignment loop, well after execute_query already returned. So for a mutation, .result is called before that flag is set, triggers a whole new run, and that run hits the same execute_query hook again, with the flag still false. It never stops.

The fix

execute_multiplex already loops over every query and calls after_query on each one, and it does that after super returns, by which point @executed is true for all of them. The execute_query-level call is redundant with that, and it's the only one that can fire early. Removing it gets rid of the crash without changing any other behavior.

Testing

Added two specs: a mutation with a plain synchronous cache_fragment, and one with cache_fragment on a graphql-batch value (closer to how we actually hit this in production). Both raise SystemStackError on master and pass with the fix. Full suite is 121/121, standardrb is clean.

Tracer#execute_query's ensure block calls after_query (and so
Cacher.call) for every query, mutations included, right as
run_eager returns. Whether that's safe depends on whether
context.fragments has anything in it yet.

cache_fragment always wraps its result in a LazyCacheResolver, a
registered lazy type. graphql-ruby only resolves lazy values inline,
before run_eager returns, when the field is eager, and
Interpreter::Runtime#run_eager sets
is_eager = ast_node.operation_type == "mutation". So for mutations,
LazyCacheResolver#resolve (which pushes the fragment into
context.fragments) has already run by the time execute_query's
ensure fires. For queries, that same resolution is deferred to a
later phase outside execute_query's boundary, so context.fragments
is still empty when the early hook fires, and Cacher.call's own
guard (return unless query.context.fragments?) quietly no-ops.

When context.fragments isn't empty, Cacher.call reaches
Fragment#final_value, which calls context.query.result. Query#result
checks @executed and, if it's still false, runs
Execution::Interpreter.run_all(@Schema, [self], ...) to compute it.
@executed only becomes true later, in run_all's own
result_values= assignment loop, which runs after the whole
multiplex's dataloader has drained -- well after execute_query has
already returned for that query. So at the point execute_query's
ensure calls into Cacher.call for a mutation, @executed is still
false, and .result kicks off a brand new run_all for the same query.
That re-enters execute_multiplex and execute_query, firing the same
ensure again, with @executed still false, forever.

execute_multiplex's own loop already calls after_query for every
query in the multiplex, and only after super returns, by which point
result_values= has been set for all of them. The execute_query-level
call is redundant with that and is the only path that can fire while
@executed is still false. Removing it fixes the crash without
otherwise changing behavior: the full existing suite still passes,
and standardrb is clean.

Adds two specs covering a mutation with a synchronous cache_fragment
and one with cache_fragment on a graphql-batch value. Both raise
SystemStackError on master and pass with this fix.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Stack Level Too Deep When NullStore

1 participant