Skip to content

Commit ff2de06

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; `handleSignals` is added as a new ApolloServer option. The only 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 a2dd85f commit ff2de06

File tree

7 files changed

+81
-49
lines changed

7 files changed

+81
-49
lines changed

docs/source/api/apollo-server.md

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

166+
* `handleSignals`: `boolean`
167+
168+
By default, ApolloServer listens for the `SIGINT` and `SIGTERM` signals and calls `await this.stop()` on
169+
itself when it is received, and then re-sends the signal to itself. Set this to false to disable
170+
this behavior. You can manually invoke `stop()` in other contexts if you'd like. Note that `stop()` does
171+
not run synchronously so it cannot work usefully in an `exit` handler.
172+
173+
166174
#### Returns
167175

168176
`ApolloServer`
@@ -447,11 +455,8 @@ addMockFunctionsToSchema({
447455
448456
* `handleSignals`: boolean
449457
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.
458+
For backwards compatibility only; specifying `new ApolloServer({engine: {handleSignals: false}})` is
459+
equivalent to specifying `new ApolloServer({handleSignals: false})`.
455460
456461
* `rewriteError`: (err: GraphQLError) => GraphQLError | null
457462

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({handleSignals: 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

+43-11
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
import {
2828
ApolloServerPlugin,
2929
GraphQLServiceContext,
30+
GraphQLServerListener,
3031
} from 'apollo-server-plugin-base';
3132
import runtimeSupportsUploads from './utils/runtimeSupportsUploads';
3233

@@ -72,13 +73,14 @@ import {
7273
import { Headers } from 'apollo-server-env';
7374
import { buildServiceDefinition } from '@apollographql/apollo-tools';
7475
import { plugin as pluginTracing } from "apollo-tracing";
75-
import { Logger, SchemaHash } from "apollo-server-types";
76+
import { Logger, SchemaHash, ValueOrPromise } from "apollo-server-types";
7677
import {
7778
plugin as pluginCacheControl,
7879
CacheControlExtensionOptions,
7980
} from 'apollo-cache-control';
8081
import { getEngineApiKey, getEngineGraphVariant } from "apollo-engine-reporting/dist/agent";
8182
import { cloneObject } from "./runHttpQuery";
83+
import { Dispatcher } from './utils/dispatcher';
8284

8385
const NoIntrospection = (context: ValidationContext) => ({
8486
Field(node: FieldDefinitionNode) {
@@ -145,7 +147,7 @@ export class ApolloServerBase {
145147
private config: Config;
146148
/** @deprecated: This is undefined for servers operating as gateways, and will be removed in a future release **/
147149
protected schema?: GraphQLSchema;
148-
private toDispose = new Set<() => void>();
150+
private toDispose = new Set<() => ValueOrPromise<void>>();
149151
private experimental_approximateDocumentStoreMiB:
150152
Config['experimental_approximateDocumentStoreMiB'];
151153

@@ -173,6 +175,7 @@ export class ApolloServerBase {
173175
gateway,
174176
cacheControl,
175177
experimental_approximateDocumentStoreMiB,
178+
handleSignals,
176179
...requestOptions
177180
} = config;
178181

@@ -381,6 +384,26 @@ export class ApolloServerBase {
381384
// is populated accordingly.
382385
this.ensurePluginInstantiation(plugins);
383386

387+
if (
388+
handleSignals !== false &&
389+
// For backwards compatibility.
390+
(typeof this.config.engine !== 'object' ||
391+
this.config.engine.handleSignals !== false)
392+
) {
393+
const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM'];
394+
signals.forEach((signal) => {
395+
// Note: Node only started sending signal names to signal events with
396+
// Node v10 so we can't use that feature here.
397+
const handler: NodeJS.SignalsListener = async () => {
398+
await this.stop();
399+
process.kill(process.pid, signal);
400+
};
401+
process.once(signal, handler);
402+
this.toDispose.add(() => {
403+
process.removeListener(signal, handler);
404+
});
405+
});
406+
}
384407
}
385408

386409
// used by integrations to synchronize the path with subscriptions, some
@@ -581,24 +604,33 @@ export class ApolloServerBase {
581604
if (this.requestOptions.persistedQueries?.cache) {
582605
service.persistedQueries = {
583606
cache: this.requestOptions.persistedQueries.cache,
584-
}
607+
};
585608
}
586609

587-
await Promise.all(
588-
this.plugins.map(
589-
plugin =>
590-
plugin.serverWillStart &&
591-
plugin.serverWillStart(service),
592-
),
610+
const serverListeners = (
611+
await Promise.all(
612+
this.plugins.map(
613+
(plugin) => plugin.serverWillStart && plugin.serverWillStart(service),
614+
),
615+
)
616+
).filter(
617+
(maybeServerListener): maybeServerListener is GraphQLServerListener =>
618+
typeof maybeServerListener === 'object' &&
619+
!!maybeServerListener.serverWillStop,
593620
);
621+
this.toDispose.add(async () => {
622+
await Promise.all(
623+
serverListeners.map(({ serverWillStop }) => serverWillStop?.()),
624+
);
625+
});
594626
}
595627

596628
public async stop() {
597-
this.toDispose.forEach(dispose => dispose());
629+
await Promise.all([...this.toDispose].map(dispose => dispose()));
598630
if (this.subscriptionServer) await this.subscriptionServer.close();
599631
if (this.engineReportingAgent) {
600632
this.engineReportingAgent.stop();
601-
await this.engineReportingAgent.sendAllReports();
633+
await this.engineReportingAgent.sendAllReportsAndReportErrors();
602634
}
603635
}
604636

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+
handleSignals?: 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 maybeListener = await pluginInstance.serverWillStart({
103105
logger: logger || console,
104106
schema,
105107
schemaHash,
106108
engine: {},
107109
});
110+
if (maybeListener && maybeListener.serverWillStop) {
111+
serverListener = maybeListener;
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-plugin-base/src/index.ts

+8-2
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,19 @@ export {
5555

5656
export interface ApolloServerPlugin<
5757
TContext extends BaseContext = BaseContext
58-
> {
59-
serverWillStart?(service: GraphQLServiceContext): ValueOrPromise<void>;
58+
> extends AnyFunctionMap {
59+
serverWillStart?(
60+
service: GraphQLServiceContext,
61+
): ValueOrPromise<GraphQLServerListener | void>;
6062
requestDidStart?(
6163
requestContext: GraphQLRequestContext<TContext>,
6264
): GraphQLRequestListener<TContext> | void;
6365
}
6466

67+
export interface GraphQLServerListener {
68+
serverWillStop?(): ValueOrPromise<void>;
69+
}
70+
6571
export type GraphQLRequestListenerParsingDidEnd = (err?: Error) => void;
6672
export type GraphQLRequestListenerValidationDidEnd =
6773
((err?: ReadonlyArray<Error>) => void);

packages/apollo-server-plugin-operation-registry/src/ApolloServerPluginOperationRegistry.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
GraphQLServiceContext,
66
GraphQLRequestListener,
77
GraphQLRequestContext,
8+
GraphQLServerListener,
89
} from 'apollo-server-plugin-base';
910
import {
1011
/**
@@ -111,7 +112,7 @@ for observability purposes, but all operations will be permitted.`,
111112
schema,
112113
schemaHash,
113114
engine,
114-
}: GraphQLServiceContext): Promise<void> {
115+
}: GraphQLServiceContext): Promise<GraphQLServerListener> {
115116
logger.debug('Initializing operation registry plugin.');
116117

117118
assert.ok(schema instanceof GraphQLSchema);
@@ -142,6 +143,12 @@ for observability purposes, but all operations will be permitted.`,
142143
});
143144

144145
await agent.start();
146+
147+
return {
148+
serverWillStop() {
149+
agent.stop();
150+
},
151+
};
145152
},
146153

147154
requestDidStart(): GraphQLRequestListener<any> {

0 commit comments

Comments
 (0)