Skip to content

Commit a02c3ce

Browse files
committed
Add serverWillStop lifecycle hook; call stop() on signals by default
Fixes #4273. This PR adds a serverWillStop plugin lifecycle hook. The `serverWillStop` hook is on an object optionally returned from a `serverWillStart` hook, similar to `executionDidStart`/`executionDidEnd`. ApolloServerPluginOperationRegistry uses this to stop its agent. The code that installs SIGINT and SIGTERM handlers unless disabled with `handleSignals: false` is hoisted from EngineReportingAgent to ApolloServer itself and renamed to `stopOnTerminationSignals` as a new ApolloServer option. The new implementation also skips installing the signals handlers by default if NODE_ENV=test or if you don't appear to be running in Node (and we update some tests that explicitly set other NODE_ENVs to set handleSignals: false). The main effect on existing code is that on one of these signals, any SubscriptionServer and ApolloGateway will be stopped in addition to any EngineReportingAgent.
1 parent 3f2f414 commit a02c3ce

File tree

14 files changed

+142
-56
lines changed

14 files changed

+142
-56
lines changed

docs/source/api/apollo-server.md

+13-5
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,17 @@ new ApolloServer({
163163
size of 30MiB, which is generally sufficient unless the server is processing
164164
a high number of unique operations.
165165

166+
* `stopOnTerminationSignals`: `boolean`
167+
168+
By default (when running in Node and when the `NODE_ENV` environment variable does not equal `test`),
169+
ApolloServer listens for the `SIGINT` and `SIGTERM` signals and calls `await this.stop()` on
170+
itself when it is received, and then re-sends the signal to itself so that process shutdown can continue.
171+
Set this to false to disable this behavior, or to true to enable this behavior even when `NODE_ENV` is
172+
`test`. You can manually invoke `stop()` in other contexts if you'd
173+
like. Note that `stop()` does not run synchronously so it cannot work usefully in an `process.on('exit')`
174+
handler.
175+
176+
166177
#### Returns
167178

168179
`ApolloServer`
@@ -447,11 +458,8 @@ addMockFunctionsToSchema({
447458
448459
* `handleSignals`: boolean
449460
450-
By default, EngineReportingAgent listens for the 'SIGINT' and 'SIGTERM'
451-
signals, stops, sends a final report, and re-sends the signal to
452-
itself. Set this to false to disable. You can manually invoke 'stop()' and
453-
'sendReport()' on other signals if you'd like. Note that 'sendReport()'
454-
does not run synchronously so it cannot work usefully in an 'exit' handler.
461+
For backwards compatibility only; specifying `new ApolloServer({engine: {handleSignals: false}})` is
462+
equivalent to specifying `new ApolloServer({stopOnTerminationSignals: false})`.
455463
456464
* `rewriteError`: (err: GraphQLError) => GraphQLError | null
457465

packages/apollo-engine-reporting/src/agent.ts

+3-28
Original file line numberDiff line numberDiff line change
@@ -290,11 +290,8 @@ export interface EngineReportingOptions<TContext> {
290290
*/
291291
privateHeaders?: Array<String> | boolean;
292292
/**
293-
* By default, EngineReportingAgent listens for the 'SIGINT' and 'SIGTERM'
294-
* signals, stops, sends a final report, and re-sends the signal to
295-
* itself. Set this to false to disable. You can manually invoke 'stop()' and
296-
* 'sendReport()' on other signals if you'd like. Note that 'sendReport()'
297-
* does not run synchronously so it cannot work usefully in an 'exit' handler.
293+
* For backwards compatibility only; specifying `new ApolloServer({engine: {handleSignals: false}})` is
294+
* equivalent to specifying `new ApolloServer({stopOnTerminationSignals: false})`.
298295
*/
299296
handleSignals?: boolean;
300297
/**
@@ -445,8 +442,6 @@ export class EngineReportingAgent<TContext = any> {
445442
private stopped: boolean = false;
446443
private signatureCache: InMemoryLRUCache<string>;
447444

448-
private signalHandlers = new Map<NodeJS.Signals, NodeJS.SignalsListener>();
449-
450445
private currentSchemaReporter?: SchemaReporter;
451446
private readonly bootId: string;
452447
private lastSeenExecutableSchemaToId?: {
@@ -529,21 +524,6 @@ export class EngineReportingAgent<TContext = any> {
529524
);
530525
}
531526

532-
if (this.options.handleSignals !== false) {
533-
const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM'];
534-
signals.forEach(signal => {
535-
// Note: Node only started sending signal names to signal events with
536-
// Node v10 so we can't use that feature here.
537-
const handler: NodeJS.SignalsListener = async () => {
538-
this.stop();
539-
await this.sendAllReportsAndReportErrors();
540-
process.kill(process.pid, signal);
541-
};
542-
process.once(signal, handler);
543-
this.signalHandlers.set(signal, handler);
544-
});
545-
}
546-
547527
if (this.options.endpointUrl) {
548528
this.logger.warn(
549529
'[deprecated] The `endpointUrl` option within `engine` has been renamed to `tracesEndpointUrl`.',
@@ -847,11 +827,6 @@ export class EngineReportingAgent<TContext = any> {
847827
// size, and stop buffering new traces. You may still manually send a last
848828
// report by calling sendReport().
849829
public stop() {
850-
// Clean up signal handlers so they don't accrue indefinitely.
851-
this.signalHandlers.forEach((handler, signal) => {
852-
process.removeListener(signal, handler);
853-
});
854-
855830
if (this.reportTimer) {
856831
clearInterval(this.reportTimer);
857832
this.reportTimer = undefined;
@@ -930,7 +905,7 @@ export class EngineReportingAgent<TContext = any> {
930905
return generatedSignature;
931906
}
932907

933-
private async sendAllReportsAndReportErrors(): Promise<void> {
908+
public async sendAllReportsAndReportErrors(): Promise<void> {
934909
await Promise.all(
935910
Object.keys(this.reportDataByExecutableSchemaId).map(executableSchemaId =>
936911
this.sendReportAndReportErrors(executableSchemaId),

packages/apollo-server-core/src/ApolloServer.ts

+49-11
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
import {
3232
ApolloServerPlugin,
3333
GraphQLServiceContext,
34+
GraphQLServerListener,
3435
} from 'apollo-server-plugin-base';
3536
import runtimeSupportsUploads from './utils/runtimeSupportsUploads';
3637

@@ -76,13 +77,14 @@ import {
7677
import { Headers } from 'apollo-server-env';
7778
import { buildServiceDefinition } from '@apollographql/apollo-tools';
7879
import { plugin as pluginTracing } from "apollo-tracing";
79-
import { Logger, SchemaHash } from "apollo-server-types";
80+
import { Logger, SchemaHash, ValueOrPromise } from "apollo-server-types";
8081
import {
8182
plugin as pluginCacheControl,
8283
CacheControlExtensionOptions,
8384
} from 'apollo-cache-control';
8485
import { getEngineApiKey, getEngineGraphVariant } from "apollo-engine-reporting/dist/agent";
8586
import { cloneObject } from "./runHttpQuery";
87+
import isNodeLike from './utils/isNodeLike';
8688

8789
const NoIntrospection = (context: ValidationContext) => ({
8890
Field(node: FieldDefinitionNode) {
@@ -149,7 +151,7 @@ export class ApolloServerBase {
149151
private config: Config;
150152
/** @deprecated: This is undefined for servers operating as gateways, and will be removed in a future release **/
151153
protected schema?: GraphQLSchema;
152-
private toDispose = new Set<() => void>();
154+
private toDispose = new Set<() => ValueOrPromise<void>>();
153155
private experimental_approximateDocumentStoreMiB:
154156
Config['experimental_approximateDocumentStoreMiB'];
155157

@@ -177,6 +179,7 @@ export class ApolloServerBase {
177179
gateway,
178180
cacheControl,
179181
experimental_approximateDocumentStoreMiB,
182+
stopOnTerminationSignals,
180183
...requestOptions
181184
} = config;
182185

@@ -385,6 +388,32 @@ export class ApolloServerBase {
385388
// is populated accordingly.
386389
this.ensurePluginInstantiation(plugins);
387390

391+
// We handle signals if it was explicitly requested, or if we're in Node,
392+
// not in a test, and it wasn't explicitly turned off. (For backwards
393+
// compatibility, we check both 'stopOnTerminationSignals' and
394+
// 'engine.handleSignals'.)
395+
if (
396+
typeof stopOnTerminationSignals === 'boolean'
397+
? stopOnTerminationSignals
398+
: typeof this.config.engine === 'object' &&
399+
typeof this.config.engine.handleSignals === 'boolean'
400+
? this.config.engine.handleSignals
401+
: isNodeLike && process.env.NODE_ENV !== 'test'
402+
) {
403+
const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM'];
404+
signals.forEach((signal) => {
405+
// Note: Node only started sending signal names to signal events with
406+
// Node v10 so we can't use that feature here.
407+
const handler: NodeJS.SignalsListener = async () => {
408+
await this.stop();
409+
process.kill(process.pid, signal);
410+
};
411+
process.once(signal, handler);
412+
this.toDispose.add(() => {
413+
process.removeListener(signal, handler);
414+
});
415+
});
416+
}
388417
}
389418

390419
// used by integrations to synchronize the path with subscriptions, some
@@ -585,24 +614,33 @@ export class ApolloServerBase {
585614
if (this.requestOptions.persistedQueries?.cache) {
586615
service.persistedQueries = {
587616
cache: this.requestOptions.persistedQueries.cache,
588-
}
617+
};
589618
}
590619

591-
await Promise.all(
592-
this.plugins.map(
593-
plugin =>
594-
plugin.serverWillStart &&
595-
plugin.serverWillStart(service),
596-
),
620+
const serverListeners = (
621+
await Promise.all(
622+
this.plugins.map(
623+
(plugin) => plugin.serverWillStart && plugin.serverWillStart(service),
624+
),
625+
)
626+
).filter(
627+
(maybeServerListener): maybeServerListener is GraphQLServerListener =>
628+
typeof maybeServerListener === 'object' &&
629+
!!maybeServerListener.serverWillStop,
597630
);
631+
this.toDispose.add(async () => {
632+
await Promise.all(
633+
serverListeners.map(({ serverWillStop }) => serverWillStop?.()),
634+
);
635+
});
598636
}
599637

600638
public async stop() {
601-
this.toDispose.forEach(dispose => dispose());
639+
await Promise.all([...this.toDispose].map(dispose => dispose()));
602640
if (this.subscriptionServer) await this.subscriptionServer.close();
603641
if (this.engineReportingAgent) {
604642
this.engineReportingAgent.stop();
605-
await this.engineReportingAgent.sendAllReports();
643+
await this.engineReportingAgent.sendAllReportsAndReportErrors();
606644
}
607645
}
608646

packages/apollo-server-core/src/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ export interface Config extends BaseConfig {
124124
playground?: PlaygroundConfig;
125125
gateway?: GraphQLService;
126126
experimental_approximateDocumentStoreMiB?: number;
127+
stopOnTerminationSignals?: boolean;
127128
}
128129

129130
export interface FileUploadOptions {

packages/apollo-server-core/src/utils/pluginTestHarness.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
import {
1818
ApolloServerPlugin,
1919
GraphQLRequestExecutionListener,
20+
GraphQLServerListener,
2021
} from 'apollo-server-plugin-base';
2122
import { InMemoryLRUCache } from 'apollo-server-caching';
2223
import { Dispatcher } from './dispatcher';
@@ -98,16 +99,19 @@ export default async function pluginTestHarness<TContext>({
9899
}
99100

100101
const schemaHash = generateSchemaHash(schema);
102+
let serverListener: GraphQLServerListener | undefined;
101103
if (typeof pluginInstance.serverWillStart === 'function') {
102-
pluginInstance.serverWillStart({
104+
const maybeServerListener = await pluginInstance.serverWillStart({
103105
logger: logger || console,
104106
schema,
105107
schemaHash,
106108
engine: {},
107109
});
110+
if (maybeServerListener && maybeServerListener.serverWillStop) {
111+
serverListener = maybeServerListener;
112+
}
108113
}
109114

110-
111115
const requestContext: GraphQLRequestContext<TContext> = {
112116
logger: logger || console,
113117
schema,
@@ -188,5 +192,7 @@ export default async function pluginTestHarness<TContext>({
188192
requestContext as GraphQLRequestContextWillSendResponse<TContext>,
189193
);
190194

195+
await serverListener?.serverWillStop?.();
196+
191197
return requestContext as GraphQLRequestContextWillSendResponse<TContext>;
192198
}

packages/apollo-server-express/src/__tests__/ApolloServer.test.ts

+2-4
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ describe('apollo-server-express', () => {
6262
serverOptions: ApolloServerExpressConfig,
6363
options: Partial<ServerRegistration> = {},
6464
) {
65-
server = new ApolloServer(serverOptions);
65+
server = new ApolloServer({stopOnTerminationSignals: false, ...serverOptions});
6666
app = express();
6767

6868
server.applyMiddleware({ ...options, app });
@@ -184,13 +184,12 @@ describe('apollo-server-express', () => {
184184
});
185185

186186
it('renders GraphQL playground using request original url', async () => {
187-
const nodeEnv = process.env.NODE_ENV;
188-
delete process.env.NODE_ENV;
189187
const samplePath = '/innerSamplePath';
190188

191189
const rewiredServer = new ApolloServer({
192190
typeDefs,
193191
resolvers,
192+
playground: true,
194193
});
195194
const innerApp = express();
196195
rewiredServer.applyMiddleware({ app: innerApp });
@@ -218,7 +217,6 @@ describe('apollo-server-express', () => {
218217
},
219218
},
220219
(error, response, body) => {
221-
process.env.NODE_ENV = nodeEnv;
222220
if (error) {
223221
reject(error);
224222
} else {

packages/apollo-server-fastify/src/__tests__/ApolloServer.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ describe('apollo-server-fastify', () => {
6464
options: Partial<ServerRegistration> = {},
6565
mockDecorators: boolean = false,
6666
) {
67-
server = new ApolloServer(serverOptions);
67+
server = new ApolloServer({ stopOnTerminationSignals: false, ...serverOptions });
6868
app = fastify();
6969

7070
if (mockDecorators) {

packages/apollo-server-hapi/src/__tests__/ApolloServer.test.ts

+5
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ const port = 0;
155155
server = new ApolloServer({
156156
typeDefs,
157157
resolvers,
158+
stopOnTerminationSignals: false,
158159
});
159160
app = new Server({ port });
160161

@@ -514,6 +515,7 @@ const port = 0;
514515
server = new ApolloServer({
515516
typeDefs,
516517
resolvers,
518+
stopOnTerminationSignals: false,
517519
context: () => {
518520
throw new AuthenticationError('valid result');
519521
},
@@ -562,6 +564,7 @@ const port = 0;
562564
},
563565
},
564566
},
567+
stopOnTerminationSignals: false,
565568
});
566569

567570
app = new Server({ port });
@@ -609,6 +612,7 @@ const port = 0;
609612
},
610613
},
611614
},
615+
stopOnTerminationSignals: false,
612616
});
613617

614618
app = new Server({ port });
@@ -653,6 +657,7 @@ const port = 0;
653657
},
654658
},
655659
},
660+
stopOnTerminationSignals: false,
656661
});
657662

658663
app = new Server({ port });

0 commit comments

Comments
 (0)