Skip to content

Commit e49e4e2

Browse files
committed
test: add regression test for hang on spec-invalid defer/stream query
A streamed list field without a subselection that merge-conflicts with an unstreamed selection of the same field inside a sibling defer used to hang incremental delivery whenever another field of that sibling resolved later than the streamed field (issue #272). The WorkQueue architecture already fixes this; the test guards termination and asserts the delivered payloads.
1 parent 6fdd3a6 commit e49e4e2

1 file changed

Lines changed: 130 additions & 0 deletions

File tree

tests/execution/incremental/test_defer.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3766,3 +3766,133 @@ async def resolve_obj(_source, _info) -> dict:
37663766
(b_id,) = [id_ for id_, label in label_of_id.items() if label == "b"]
37673767
fast_entries = [i for i in incremental if i.get("data") == {"fast": "fast"}]
37683768
assert fast_entries == [{"data": {"fast": "fast"}, "id": b_id}]
3769+
3770+
3771+
def describe_defer_and_stream_on_spec_invalid_queries():
3772+
"""Regression tests for graphql-python/graphql-core#272."""
3773+
3774+
@pytest.mark.timeout(1)
3775+
@pytest.mark.parametrize("early_execution", [False, True])
3776+
async def terminates_when_streamed_field_conflicts_with_deferred_selection(
3777+
early_execution,
3778+
):
3779+
"""A spec-invalid ``@defer``/``@stream`` combination must not hang.
3780+
3781+
A streamed list field without a subselection that also merge-conflicts
3782+
with an unstreamed selection of the same field inside a sibling
3783+
``@defer`` used to deadlock incremental delivery whenever another field
3784+
of that sibling resolved later than the streamed field: the incremental
3785+
graph never converged to a terminal state, so ``subsequent_results``
3786+
never yielded a final payload with ``hasNext: False``. Spec-valid
3787+
queries were not affected, but ``experimental_execute_incrementally``
3788+
may be called without a prior validation pass and must terminate on
3789+
invalid input, too. See issue #272.
3790+
"""
3791+
3792+
async def resolve_fast(_source, _info) -> str:
3793+
# `fast` must resolve (at least) two event loop iterations later
3794+
# than `child` and `items` to trigger the deadlock
3795+
await sleep(0)
3796+
await sleep(0)
3797+
return "fast"
3798+
3799+
async def resolve_obj(_source, _info) -> dict:
3800+
return {}
3801+
3802+
async def resolve_list(_source, _info) -> list:
3803+
return [{}, {}]
3804+
3805+
obj_type: GraphQLObjectType = GraphQLObjectType(
3806+
"Obj",
3807+
lambda: {
3808+
"fast": GraphQLField(GraphQLString, resolve=resolve_fast),
3809+
"child": GraphQLField(obj_type, resolve=resolve_obj),
3810+
"items": GraphQLField(GraphQLList(obj_type), resolve=resolve_list),
3811+
},
3812+
)
3813+
local_schema = GraphQLSchema(
3814+
GraphQLObjectType(
3815+
"Query", {"obj": GraphQLField(obj_type, resolve=resolve_obj)}
3816+
)
3817+
)
3818+
# The query is spec-invalid in two ways: the streamed `items` field
3819+
# lacks a subselection, and it merge-conflicts with the unstreamed
3820+
# `items` selection inside the sibling `@defer`. Neither invalid
3821+
# feature alone triggered the deadlock, and fixing either (aliasing
3822+
# the conflict away or adding a subselection) made it disappear.
3823+
document = parse(
3824+
"""
3825+
query {
3826+
obj {
3827+
... {
3828+
child {
3829+
child {
3830+
... @defer(label: "L4") { items @stream(initialCount: 1) }
3831+
}
3832+
... @defer { child { fast items } }
3833+
}
3834+
}
3835+
}
3836+
}
3837+
"""
3838+
)
3839+
3840+
result = experimental_execute_incrementally(
3841+
local_schema, document, {}, enable_early_execution=early_execution
3842+
)
3843+
assert is_awaitable(result)
3844+
result = await result
3845+
assert isinstance(result, ExperimentalIncrementalExecutionResults)
3846+
initial = result.initial_result.formatted
3847+
# Draining the stream must terminate (guarded by the timeout above).
3848+
subsequent = [patch.formatted async for patch in result.subsequent_results]
3849+
payloads: list[Any] = [initial, *subsequent]
3850+
3851+
# The stream must terminate cleanly: `hasNext` is true on every
3852+
# payload except the last, which ends the stream.
3853+
assert initial["hasNext"] is True
3854+
assert subsequent[-1]["hasNext"] is False
3855+
assert all(patch["hasNext"] is True for patch in subsequent[:-1])
3856+
3857+
assert initial["data"] == {"obj": {"child": {"child": {}}}}
3858+
3859+
# Aggregate announcements, deliveries and completions across the
3860+
# whole stream; their grouping into payloads is not asserted as it
3861+
# depends on resolver scheduling and `enable_early_execution`.
3862+
pending = [p for payload in payloads for p in payload.get("pending", [])]
3863+
incremental = [
3864+
i for payload in payloads for i in payload.get("incremental", [])
3865+
]
3866+
completed = [c for payload in payloads for c in payload.get("completed", [])]
3867+
3868+
# Three delivery groups are announced: the anonymous `@defer`, the
3869+
# labeled nested `@defer`, and the stream over `items`.
3870+
id_of_path = {tuple(p["path"]): p["id"] for p in pending}
3871+
label_of_path = {tuple(p["path"]): p.get("label") for p in pending}
3872+
assert label_of_path == {
3873+
("obj", "child"): None,
3874+
("obj", "child", "child"): "L4",
3875+
("obj", "child", "child", "items"): None,
3876+
}
3877+
3878+
# Every announced group completes exactly once and without errors.
3879+
assert sorted(c["id"] for c in completed) == sorted(id_of_path.values())
3880+
assert all("errors" not in c for c in completed)
3881+
3882+
# All three groups deliver: the initial streamed item under `L4` and
3883+
# the second item via the stream (both as empty objects, since the
3884+
# invalid selection has no subfields), and `fast` exactly once under
3885+
# the anonymous `@defer` (the conflicting unstreamed `items` selection
3886+
# merges into the streamed one instead of being delivered again).
3887+
expected_deliveries = [
3888+
{"data": {"items": [{}]}, "id": id_of_path["obj", "child", "child"]},
3889+
{"items": [{}], "id": id_of_path["obj", "child", "child", "items"]},
3890+
{
3891+
"data": {"fast": "fast"},
3892+
"id": id_of_path["obj", "child"],
3893+
"subPath": ["child"],
3894+
},
3895+
]
3896+
assert len(incremental) == len(expected_deliveries)
3897+
for entry in expected_deliveries:
3898+
assert entry in incremental

0 commit comments

Comments
 (0)