Repository navigation
fix(incremental): prevent error completions for unannounced fragments - #4869
Merged
Merged
Conversation
Follow-up to merged PR [graphql#4868](graphql#4868). Thanks to Duckki Oe (@duckki) for identifying these issues and providing an initial fix in [graphql#4859](graphql#4859). PR graphql#4868 fixed successful shared results. A nested fragment can now deliver its ready data, including a shared stream, as soon as its parent finishes. Another fragment's separate failure or slower fields no longer cause that data to be lost or delayed. Previously, even after that fix, a shared non-null field failure could complete a nested deferred fragment before it was announced, although its parent's separate fields succeeded. This was incorrect: the response referred to an ID that the client had never received in `pending`. This commit waits until the nested fragment is announced before sending its error completion. The client therefore receives its `pending` entry before its completion, including when both appear in the same response. If several shared fields have already failed, the fragment completes once with one shared error. Its parent's successful data is still delivered. Late outcomes and descendants belonging only to failed fragments are discarded. Shared work needed by a surviving fragment remains available. This enables the remaining regression tests introduced in graphql#4859 and adds integration coverage for error ordering, late outcomes, and shared streams whose remaining fragment fails. ## @defer: the shared field fails while the parent succeeds ```graphql { hero { ... @defer(label: "R") { nonNullName } ... @defer(label: "P") { name ... @defer(label: "C") { nonNullName } } } } ``` `nonNullName` returns `null`, failing R and C. P's separate `name` returns `"Luke"` successfully. Both results are available immediately. Both versions start with: ```json { "data": {"hero": {}}, "pending": [ {"id": "0", "path": ["hero"], "label": "R"}, {"id": "1", "path": ["hero"], "label": "P"} ], "hasNext": true } ``` **Before this fix:** Previously, C completed as ID `"2"` even though only IDs `"0"` and `"1"` had been announced. Response 2: ```json { "hasNext": false, "incremental": [ {"id": "1", "data": {"name": "Luke"}} ], "completed": [ { "id": "0", "errors": [ { "message": "Cannot return null for non-nullable field Hero.nonNullName.", "locations": [ {"line": 4, "column": 7}, {"line": 9, "column": 9} ], "path": ["hero", "nonNullName"] } ] }, { "id": "2", "errors": [ { "message": "Cannot return null for non-nullable field Hero.nonNullName.", "locations": [ {"line": 4, "column": 7}, {"line": 9, "column": 9} ], "path": ["hero", "nonNullName"] } ] }, {"id": "1"} ] } ``` **After this fix:** This commit announces C as ID `"2"` before sending its error completion, so the client can identify the failed fragment. P still returns its successful `name`. Response 2: ```json { "hasNext": false, "pending": [ {"id": "2", "path": ["hero"], "label": "C"} ], "incremental": [ {"id": "1", "data": {"name": "Luke"}} ], "completed": [ { "id": "0", "errors": [ { "message": "Cannot return null for non-nullable field Hero.nonNullName.", "locations": [ {"line": 4, "column": 7}, {"line": 9, "column": 9} ], "path": ["hero", "nonNullName"] } ] }, {"id": "1"}, { "id": "2", "errors": [ { "message": "Cannot return null for non-nullable field Hero.nonNullName.", "locations": [ {"line": 4, "column": 7}, {"line": 9, "column": 9} ], "path": ["hero", "nonNullName"] } ] } ] } ``` ## @defer: two shared fields have already failed ```graphql { hero { ... @defer(label: "R1") { first: nonNullName } ... @defer(label: "R2") { second: nonNullName } ... @defer(label: "P") { name ... @defer(label: "C") { first: nonNullName second: nonNullName } } } } ``` `name` returns `"Luke"`. Both `first` and `second` return `null`. C shares `first` with R1 and `second` with R2; both have failed by the time P completes. Both versions start with: ```json { "data": {"hero": {}}, "pending": [ {"id": "0", "path": ["hero"], "label": "R1"}, {"id": "1", "path": ["hero"], "label": "R2"}, {"id": "2", "path": ["hero"], "label": "P"} ], "hasNext": true } ``` **Before this fix:** Previously, C completed as ID `"3"` without a `pending` entry. Response 2: ```json { "hasNext": false, "incremental": [ {"id": "2", "data": {"name": "Luke"}} ], "completed": [ { "id": "0", "errors": [ { "message": "Cannot return null for non-nullable field Hero.nonNullName.", "locations": [ {"line": 4, "column": 7}, {"line": 12, "column": 9} ], "path": ["hero", "first"] } ] }, { "id": "3", "errors": [ { "message": "Cannot return null for non-nullable field Hero.nonNullName.", "locations": [ {"line": 4, "column": 7}, {"line": 12, "column": 9} ], "path": ["hero", "first"] } ] }, { "id": "1", "errors": [ { "message": "Cannot return null for non-nullable field Hero.nonNullName.", "locations": [ {"line": 7, "column": 7}, {"line": 13, "column": 9} ], "path": ["hero", "second"] } ] }, {"id": "2"} ] } ``` **After this fix:** This commit announces C before completing it once with a shared error. In this example, its completion reports the `first` error. R1 and R2 each report their own error, and P delivers `name`. Response 2: ```json { "hasNext": false, "pending": [ {"id": "3", "path": ["hero"], "label": "C"} ], "incremental": [ {"id": "2", "data": {"name": "Luke"}} ], "completed": [ { "id": "0", "errors": [ { "message": "Cannot return null for non-nullable field Hero.nonNullName.", "locations": [ {"line": 4, "column": 7}, {"line": 12, "column": 9} ], "path": ["hero", "first"] } ] }, { "id": "1", "errors": [ { "message": "Cannot return null for non-nullable field Hero.nonNullName.", "locations": [ {"line": 7, "column": 7}, {"line": 13, "column": 9} ], "path": ["hero", "second"] } ] }, {"id": "2"}, { "id": "3", "errors": [ { "message": "Cannot return null for non-nullable field Hero.nonNullName.", "locations": [ {"line": 4, "column": 7}, {"line": 12, "column": 9} ], "path": ["hero", "first"] } ] } ] } ``` Co-authored-by: Duckki Oe <duckki.dev@gmail.com>
|
@yaacovCR is attempting to deploy a commit to the The GraphQL Foundation Team on Vercel. A member of the Team first needs to authorize it. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to merged PR #4868. Thanks to Duckki Oe (@duckki) for identifying these issues and providing an initial fix in #4859.
PR #4868 fixed successful shared results. A nested fragment can now deliver its ready data, including a shared stream, as soon as its parent finishes. Another fragment's separate failure or slower fields no longer cause that data to be lost or delayed.
Previously, even after that fix, a shared non-null field failure could complete a nested deferred fragment before it was announced, although its parent's separate fields succeeded. This was incorrect: the response referred to an ID that the client had never received in
pending.This PR waits until the nested fragment is announced before sending its error completion. The client therefore receives its
pendingentry before its completion, including when both appear in the same response. If several shared fields have already failed, the fragment completes once with one shared error. Its parent's successful data is still delivered.Late outcomes and descendants belonging only to failed fragments are discarded. Shared work needed by a surviving fragment remains available.
This enables the remaining regression tests introduced in #4859 and adds integration coverage for error ordering, late outcomes, and shared streams whose remaining fragment fails.
@defer: the shared field fails while the parent succeeds
{ hero { ... @defer(label: "R") { nonNullName } ... @defer(label: "P") { name ... @defer(label: "C") { nonNullName } } } }nonNullNamereturnsnull, failing R and C. P's separatenamereturns"Luke"successfully. Both results are available immediately.Both versions start with:
{ "data": {"hero": {}}, "pending": [ {"id": "0", "path": ["hero"], "label": "R"}, {"id": "1", "path": ["hero"], "label": "P"} ], "hasNext": true }Before this fix:
Previously, C completed as ID
"2"even though only IDs"0"and"1"had been announced.Response 2:
{ "hasNext": false, "incremental": [ {"id": "1", "data": {"name": "Luke"}} ], "completed": [ { "id": "0", "errors": [ { "message": "Cannot return null for non-nullable field Hero.nonNullName.", "locations": [ {"line": 4, "column": 7}, {"line": 9, "column": 9} ], "path": ["hero", "nonNullName"] } ] }, { "id": "2", "errors": [ { "message": "Cannot return null for non-nullable field Hero.nonNullName.", "locations": [ {"line": 4, "column": 7}, {"line": 9, "column": 9} ], "path": ["hero", "nonNullName"] } ] }, {"id": "1"} ] }After this fix:
This commit announces C as ID
"2"before sending its error completion, so the client can identify the failed fragment. P still returns its successfulname.Response 2:
{ "hasNext": false, "pending": [ {"id": "2", "path": ["hero"], "label": "C"} ], "incremental": [ {"id": "1", "data": {"name": "Luke"}} ], "completed": [ { "id": "0", "errors": [ { "message": "Cannot return null for non-nullable field Hero.nonNullName.", "locations": [ {"line": 4, "column": 7}, {"line": 9, "column": 9} ], "path": ["hero", "nonNullName"] } ] }, {"id": "1"}, { "id": "2", "errors": [ { "message": "Cannot return null for non-nullable field Hero.nonNullName.", "locations": [ {"line": 4, "column": 7}, {"line": 9, "column": 9} ], "path": ["hero", "nonNullName"] } ] } ] }@defer: two shared fields have already failed
{ hero { ... @defer(label: "R1") { first: nonNullName } ... @defer(label: "R2") { second: nonNullName } ... @defer(label: "P") { name ... @defer(label: "C") { first: nonNullName second: nonNullName } } } }namereturns"Luke". Bothfirstandsecondreturnnull. C sharesfirstwith R1 andsecondwith R2; both have failed by the time P completes.Both versions start with:
{ "data": {"hero": {}}, "pending": [ {"id": "0", "path": ["hero"], "label": "R1"}, {"id": "1", "path": ["hero"], "label": "R2"}, {"id": "2", "path": ["hero"], "label": "P"} ], "hasNext": true }Before this fix:
Previously, C completed as ID
"3"without apendingentry.Response 2:
{ "hasNext": false, "incremental": [ {"id": "2", "data": {"name": "Luke"}} ], "completed": [ { "id": "0", "errors": [ { "message": "Cannot return null for non-nullable field Hero.nonNullName.", "locations": [ {"line": 4, "column": 7}, {"line": 12, "column": 9} ], "path": ["hero", "first"] } ] }, { "id": "3", "errors": [ { "message": "Cannot return null for non-nullable field Hero.nonNullName.", "locations": [ {"line": 4, "column": 7}, {"line": 12, "column": 9} ], "path": ["hero", "first"] } ] }, { "id": "1", "errors": [ { "message": "Cannot return null for non-nullable field Hero.nonNullName.", "locations": [ {"line": 7, "column": 7}, {"line": 13, "column": 9} ], "path": ["hero", "second"] } ] }, {"id": "2"} ] }After this fix:
This PR announces C before completing it once with a shared error. In this example, its completion reports the
firsterror. R1 and R2 each report their own error, and P deliversname.Response 2:
{ "hasNext": false, "pending": [ {"id": "3", "path": ["hero"], "label": "C"} ], "incremental": [ {"id": "2", "data": {"name": "Luke"}} ], "completed": [ { "id": "0", "errors": [ { "message": "Cannot return null for non-nullable field Hero.nonNullName.", "locations": [ {"line": 4, "column": 7}, {"line": 12, "column": 9} ], "path": ["hero", "first"] } ] }, { "id": "1", "errors": [ { "message": "Cannot return null for non-nullable field Hero.nonNullName.", "locations": [ {"line": 7, "column": 7}, {"line": 13, "column": 9} ], "path": ["hero", "second"] } ] }, {"id": "2"}, { "id": "3", "errors": [ { "message": "Cannot return null for non-nullable field Hero.nonNullName.", "locations": [ {"line": 4, "column": 7}, {"line": 12, "column": 9} ], "path": ["hero", "first"] } ] } ] }