Skip to content

Commit e1fe9c5

Browse files
committed
add Abort signals
1 parent e691177 commit e1fe9c5

File tree

5 files changed

+646
-55
lines changed

5 files changed

+646
-55
lines changed

src/execution/__tests__/executor-test.ts

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,299 @@ describe('Execute: Handles basic execution tasks', () => {
635635
expect(isAsyncResolverFinished).to.equal(true);
636636
});
637637

638+
it('exits early on early abort', () => {
639+
let isExecuted = false;
640+
641+
const schema = new GraphQLSchema({
642+
query: new GraphQLObjectType({
643+
name: 'Query',
644+
fields: {
645+
field: {
646+
type: GraphQLString,
647+
/* c8 ignore next 3 */
648+
resolve() {
649+
isExecuted = true;
650+
},
651+
},
652+
},
653+
}),
654+
});
655+
656+
const document = parse(`
657+
{
658+
field
659+
}
660+
`);
661+
662+
const abortController = new AbortController();
663+
abortController.abort();
664+
665+
const result = execute({
666+
schema,
667+
document,
668+
abortSignal: abortController.signal,
669+
});
670+
671+
expect(isExecuted).to.equal(false);
672+
expectJSON(result).toDeepEqual({
673+
data: { field: null },
674+
errors: [
675+
{
676+
message: 'This operation was aborted',
677+
locations: [{ line: 3, column: 9 }],
678+
path: ['field'],
679+
},
680+
],
681+
});
682+
});
683+
684+
it('exits early on abort mid-execution', async () => {
685+
let isExecuted = false;
686+
687+
const asyncObjectType = new GraphQLObjectType({
688+
name: 'AsyncObject',
689+
fields: {
690+
field: {
691+
type: GraphQLString,
692+
/* c8 ignore next 3 */
693+
resolve() {
694+
isExecuted = true;
695+
},
696+
},
697+
},
698+
});
699+
700+
const schema = new GraphQLSchema({
701+
query: new GraphQLObjectType({
702+
name: 'Query',
703+
fields: {
704+
asyncObject: {
705+
type: asyncObjectType,
706+
async resolve() {
707+
await resolveOnNextTick();
708+
return {};
709+
},
710+
},
711+
},
712+
}),
713+
});
714+
715+
const document = parse(`
716+
{
717+
asyncObject {
718+
field
719+
}
720+
}
721+
`);
722+
723+
const abortController = new AbortController();
724+
725+
const result = execute({
726+
schema,
727+
document,
728+
abortSignal: abortController.signal,
729+
});
730+
731+
abortController.abort();
732+
733+
expect(isExecuted).to.equal(false);
734+
expectJSON(await result).toDeepEqual({
735+
data: { asyncObject: { field: null } },
736+
errors: [
737+
{
738+
message: 'This operation was aborted',
739+
locations: [{ line: 4, column: 11 }],
740+
path: ['asyncObject', 'field'],
741+
},
742+
],
743+
});
744+
expect(isExecuted).to.equal(false);
745+
});
746+
747+
it('exits early on abort mid-resolver', async () => {
748+
const schema = new GraphQLSchema({
749+
query: new GraphQLObjectType({
750+
name: 'Query',
751+
fields: {
752+
asyncField: {
753+
type: GraphQLString,
754+
async resolve(_parent, _args, _context, _info, abortSignal) {
755+
await resolveOnNextTick();
756+
abortSignal?.throwIfAborted();
757+
},
758+
},
759+
},
760+
}),
761+
});
762+
763+
const document = parse(`
764+
{
765+
asyncField
766+
}
767+
`);
768+
769+
const abortController = new AbortController();
770+
771+
const result = execute({
772+
schema,
773+
document,
774+
abortSignal: abortController.signal,
775+
});
776+
777+
abortController.abort();
778+
779+
expectJSON(await result).toDeepEqual({
780+
data: { asyncField: null },
781+
errors: [
782+
{
783+
message: 'This operation was aborted',
784+
locations: [{ line: 3, column: 9 }],
785+
path: ['asyncField'],
786+
},
787+
],
788+
});
789+
});
790+
791+
it('exits early on abort mid-nested resolver', async () => {
792+
const syncObjectType = new GraphQLObjectType({
793+
name: 'SyncObject',
794+
fields: {
795+
asyncField: {
796+
type: GraphQLString,
797+
async resolve(_parent, _args, _context, _info, abortSignal) {
798+
await resolveOnNextTick();
799+
abortSignal?.throwIfAborted();
800+
},
801+
},
802+
},
803+
});
804+
805+
const schema = new GraphQLSchema({
806+
query: new GraphQLObjectType({
807+
name: 'Query',
808+
fields: {
809+
syncObject: {
810+
type: syncObjectType,
811+
resolve() {
812+
return {};
813+
},
814+
},
815+
},
816+
}),
817+
});
818+
819+
const document = parse(`
820+
{
821+
syncObject {
822+
asyncField
823+
}
824+
}
825+
`);
826+
827+
const abortController = new AbortController();
828+
829+
const result = execute({
830+
schema,
831+
document,
832+
abortSignal: abortController.signal,
833+
});
834+
835+
abortController.abort();
836+
837+
expectJSON(await result).toDeepEqual({
838+
data: { syncObject: { asyncField: null } },
839+
errors: [
840+
{
841+
message: 'This operation was aborted',
842+
locations: [{ line: 4, column: 11 }],
843+
path: ['syncObject', 'asyncField'],
844+
},
845+
],
846+
});
847+
});
848+
849+
it('exits early on error', async () => {
850+
const objectType = new GraphQLObjectType({
851+
name: 'Object',
852+
fields: {
853+
nonNullNestedAsyncField: {
854+
type: new GraphQLNonNull(GraphQLString),
855+
async resolve() {
856+
await resolveOnNextTick();
857+
throw new Error('Oops');
858+
},
859+
},
860+
nestedAsyncField: {
861+
type: GraphQLString,
862+
async resolve(_parent, _args, _context, _info, abortSignal) {
863+
await resolveOnNextTick();
864+
abortSignal?.throwIfAborted();
865+
},
866+
},
867+
},
868+
});
869+
870+
const schema = new GraphQLSchema({
871+
query: new GraphQLObjectType({
872+
name: 'Query',
873+
fields: {
874+
object: {
875+
type: objectType,
876+
resolve() {
877+
return {};
878+
},
879+
},
880+
asyncField: {
881+
type: GraphQLString,
882+
async resolve() {
883+
await resolveOnNextTick();
884+
return 'asyncValue';
885+
},
886+
},
887+
},
888+
}),
889+
});
890+
891+
const document = parse(`
892+
{
893+
object {
894+
nonNullNestedAsyncField
895+
nestedAsyncField
896+
}
897+
asyncField
898+
}
899+
`);
900+
901+
const abortController = new AbortController();
902+
903+
const result = execute({
904+
schema,
905+
document,
906+
abortSignal: abortController.signal,
907+
});
908+
909+
abortController.abort();
910+
911+
expectJSON(await result).toDeepEqual({
912+
data: {
913+
object: null,
914+
asyncField: 'asyncValue',
915+
},
916+
errors: [
917+
{
918+
message: 'This operation was aborted',
919+
locations: [{ line: 5, column: 11 }],
920+
path: ['object', 'nestedAsyncField'],
921+
},
922+
{
923+
message: 'Oops',
924+
locations: [{ line: 4, column: 11 }],
925+
path: ['object', 'nonNullNestedAsyncField'],
926+
},
927+
],
928+
});
929+
});
930+
638931
it('Full response path is included for non-nullable fields', () => {
639932
const A: GraphQLObjectType = new GraphQLObjectType({
640933
name: 'A',

src/execution/__tests__/stream-test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1160,6 +1160,45 @@ describe('Execute: stream directive', () => {
11601160
},
11611161
]);
11621162
});
1163+
it('Handles nested errors thrown by completeValue after initialCount is reached for a non-nullable list', async () => {
1164+
const document = parse(`
1165+
query {
1166+
nonNullFriendList @stream(initialCount: 1) {
1167+
nonNullName
1168+
}
1169+
}
1170+
`);
1171+
const result = await complete(document, {
1172+
nonNullFriendList: () => [
1173+
{ nonNullName: friends[0].name },
1174+
{ nonNullName: new Error('Oops') },
1175+
],
1176+
});
1177+
expectJSON(result).toDeepEqual([
1178+
{
1179+
data: {
1180+
nonNullFriendList: [{ nonNullName: 'Luke' }],
1181+
},
1182+
hasNext: true,
1183+
},
1184+
{
1185+
incremental: [
1186+
{
1187+
items: null,
1188+
path: ['nonNullFriendList', 1],
1189+
errors: [
1190+
{
1191+
message: 'Oops',
1192+
locations: [{ line: 4, column: 11 }],
1193+
path: ['nonNullFriendList', 1, 'nonNullName'],
1194+
},
1195+
],
1196+
},
1197+
],
1198+
hasNext: false,
1199+
},
1200+
]);
1201+
});
11631202
it('Handles nested errors thrown by completeValue after initialCount is reached from async iterable', async () => {
11641203
const document = parse(`
11651204
query {
@@ -1214,6 +1253,47 @@ describe('Execute: stream directive', () => {
12141253
},
12151254
]);
12161255
});
1256+
it('Handles nested errors thrown by completeValue after initialCount is reached from async iterable for a non-nullable list', async () => {
1257+
const document = parse(`
1258+
query {
1259+
nonNullFriendList @stream(initialCount: 1) {
1260+
nonNullName
1261+
}
1262+
}
1263+
`);
1264+
const result = await complete(document, {
1265+
async *nonNullFriendList() {
1266+
yield await Promise.resolve({ nonNullName: friends[0].name });
1267+
yield await Promise.resolve({
1268+
nonNullName: () => new Error('Oops'),
1269+
}); /* c8 ignore start */
1270+
} /* c8 ignore stop */,
1271+
});
1272+
expectJSON(result).toDeepEqual([
1273+
{
1274+
data: {
1275+
nonNullFriendList: [{ nonNullName: 'Luke' }],
1276+
},
1277+
hasNext: true,
1278+
},
1279+
{
1280+
incremental: [
1281+
{
1282+
items: null,
1283+
path: ['nonNullFriendList', 1],
1284+
errors: [
1285+
{
1286+
message: 'Oops',
1287+
locations: [{ line: 4, column: 11 }],
1288+
path: ['nonNullFriendList', 1, 'nonNullName'],
1289+
},
1290+
],
1291+
},
1292+
],
1293+
hasNext: false,
1294+
},
1295+
]);
1296+
});
12171297
it('Handles nested async errors thrown by completeValue after initialCount is reached', async () => {
12181298
const document = parse(`
12191299
query {

0 commit comments

Comments
 (0)