Skip to content

Commit 8eafa82

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. (This commit adds new documentation for `stopOnTerminationSignals` to the API reference but does not change the documentation of `EngineReportingOptions.handleSignals` on that page because a later commit in this PR removes that section entirely.)
1 parent 7d5cf18 commit 8eafa82

File tree

14 files changed

+150
-51
lines changed

14 files changed

+150
-51
lines changed

docs/source/api/apollo-server.md

+21
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,27 @@ Provide this function to transform the structure of GraphQL response objects bef
289289
<tr>
290290
<td colspan="2">
291291

292+
**Lifecycle options**
293+
</td>
294+
</tr>
295+
296+
<tr>
297+
<td>
298+
299+
###### `stopOnTerminationSignals`
300+
301+
`Boolean`
302+
</td>
303+
<td>
304+
305+
By default (when running in Node and when the `NODE_ENV` environment variable does not equal `test`), ApolloServer listens for the `SIGINT` and `SIGTERM` signals and calls `await this.stop()` on itself when it is received, and then re-sends the signal to itself so that process shutdown can continue. Set this to false to disable this behavior, or to true to enable this behavior even when `NODE_ENV` is `test`. You can manually invoke `stop()` in other contexts if you'd like. Note that `stop()` does not run synchronously so it cannot work usefully in an `process.on('exit')` handler.
306+
307+
</td>
308+
</tr>
309+
310+
<tr>
311+
<td colspan="2">
312+
292313
**Debugging options**
293314
</td>
294315
</tr>

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)