Skip to content

Commit aee3402

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 bbc8a91 commit aee3402

File tree

14 files changed

+151
-52
lines changed

14 files changed

+151
-52
lines changed

docs/source/api/apollo-server.md

+22-1
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,27 @@ Provide this function to transform the structure of GraphQL response objects bef
275275
<tr>
276276
<td colspan="2">
277277

278+
**Lifecycle options**
279+
</td>
280+
</tr>
281+
282+
<tr>
283+
<td>
284+
285+
###### `stopOnTerminationSignals`
286+
287+
`Boolean`
288+
</td>
289+
<td>
290+
291+
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.
292+
293+
</td>
294+
</tr>
295+
296+
<tr>
297+
<td colspan="2">
298+
278299
**Debugging options**
279300
</td>
280301
</tr>
@@ -658,7 +679,7 @@ These are the supported fields of the `engine` object you provide to the [`Apoll
658679
| `requestAgent` | `http.Agent` or `https.Agent` or `false` | An HTTP(S) agent to use for metrics reporting. Can be either an [`http.Agent`](https://nodejs.org/docs/latest-v10.x/api/http.html#http_class_http_agent) or an [`https.Agent`](https://nodejs.org/docs/latest-v10.x/api/https.html#https_class_https_agent). It behaves the same as the `agent` parameter to [`http.request`](https://nodejs.org/docs/latest-v8.x/api/http.html#http_http_request_options_callback). |
659680
| `generateClientInfo` | `Function` | <p>Specify this function to provide Apollo Studio with client details for each processed operation. Apollo Studio uses this information to [segment metrics by client](https://www.apollographql.com/docs/studio/client-awareness/).</p><p>The function is passed a [`GraphQLRequestContext`](https://github.com/apollographql/apollo-server/blob/main/packages/apollo-server-types/src/index.ts#L95-L130) object containing all available information about the request. It should return a [`ClientInfo`](https://github.com/apollographql/apollo-server/blob/main/packages/apollo-engine-reporting/src/agent.ts#L35-L39) object describing the associated GraphQL client.</p><p>By default, Apollo Server attempts to obtain `ClientInfo` fields from the `clientInfo` field of the GraphQL operation's `extensions`.</p><p>For advanced use cases when you already use an opaque string to identify your client (such as an API key, x509 certificate, or team codename), use the `clientReferenceId` field to add a reference to that internal identity. The reference ID is not displayed in Studio, but it is available for cross-correspondence, so names and reference IDs should have a one-to-one relationship.</p><p>**Warning:** If you specify a `clientReferenceId`, Graph Manager will treat the `clientName` as a secondary lookup, so changing a `clientName` may result in an unwanted experience.</p>|
660681
| `calculateSignature` | `Function` | <p>A custom function to use to calculate the "signature" of the schema that operations are running against. This enables Apollo Studio to detect when two non-identical schema strings represent the exact same underlying model.</p><p>For an example, see the [default signature function](https://github.com/apollographql/apollo-tooling/blob/master/packages/apollo-graphql/src/operationId.ts), which sorts types and fields, removes extraneous whitespace, and removes unused definitions.</p> |
661-
| `handleSignals` | `Boolean` | <p>Set to `false` to disable the Apollo Server trace reporting agent's default signal handling behavior.</p><p>By default, the agent listens for `SIGINT` and `SIGTERM`. Upon receiving either signal, the agent stops, sends a final report, and sends the signal back to itself.</p><p>In addition to disabling the default behavior, you can manually invoke [`stop` and `sendReport`](https://github.com/apollographql/apollo-server/blob/main/packages/apollo-engine-reporting/src/agent.ts) on other signals. Note that `sendReport` is asynchronous, so it should not be called in an `exit` handler.</p> |
682+
| `handleSignals` | `Boolean` | <p>For backwards compatibility only; specifying `new ApolloServer({engine: {handleSignals: false}})` is equivalent to specifying `new ApolloServer({stopOnTerminationSignals: false})`</p>|
662683
663684
##### Valid `sendHeaders` object signatures
664685

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)