Skip to content

Commit 37e32c6

Browse files
committed
Generalize apollo-server graceful shutdown to all integrations
Previously, the batteries-included `apollo-server` package had a special override of `stop()` which drains the HTTP server before letting the actual Apollo Server `stop()` machinery begin. This meant that `apollo-server` followed this nice shutdown lifecycle: - Stop listening for new connections - Close all idle connections and start closing connections as they go idle - Wait a grace period for all connections to close and force-close any remaining ones - Transition ApolloServer to the stopping state, where no operations will run - Run stop hooks (eg send final usage report) This was great... but only `apollo-server` worked this way, because only `apollo-server` has full knowledge and control over its HTTP server. This PR adds a server draining step to the ApolloServer lifecycle and plugin interface, and provides a built-in plugin which drains a Node `http.Server` using the logic of the first three steps above. `apollo-server`'s behavior is now just to automatically install the plugin. Specifically: - Add a new 'phase' called `draining` that fits between `started` and `stopping`. Like `started`, operations can still execute during `draining`. Like `stopping`, any concurrent call to `stop()` will just block until the first `stop()` call finishes rather than starting a second shutdown process. - Add a new `drainServer` plugin hook (on the object returned by `serverWillStart`). Invoke all `drainServer` hooks in parallel during the `draining` phase. - Make calling `stop()` when `start()` has not yet completed successfully into an error. That behavior was previously undefined. Note that as of #5639, the automatic `stop()` call from signal handlers can't happen before `start()` succeeds. - Add `ApolloServerPluginDrainHttpServer` to `apollo-server-core`. This plugin implements `drainServer` using the `Stopper` class that was previously in the `apollo-server` package. The default grace period is 10 seconds. - Clean up integration tests to just use `stop()` with the plugin instead of separately stopping the HTTP server. Note that for Fastify specifically we also call `app.close` although there is some weirdness here around both `app.close` and our Stopper closing the same server. A comment describes the weirdness; perhaps Fastify experts can improve this later. - The Hapi web framework has built in logic that is similar to our Stopper, so `apollo-server-hapi` exports `ApolloServerPluginStopHapiServer` which should be used instead of the other plugin with Hapi. - Fix some test issues (eg, have FakeTimers only mock out Date.now instead of setImmediate, drop an erroneous `const` which made an `app` not get cleaned up, etc). Fixes #5074.
1 parent 9b3467c commit 37e32c6

File tree

22 files changed

+445
-220
lines changed

22 files changed

+445
-220
lines changed

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

Lines changed: 94 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -80,19 +80,36 @@ export type SchemaDerivedData = {
8080
};
8181

8282
type ServerState =
83-
| { phase: 'initialized'; schemaManager: SchemaManager }
83+
| {
84+
phase: 'initialized';
85+
schemaManager: SchemaManager;
86+
}
8487
| {
8588
phase: 'starting';
8689
barrier: Resolvable<void>;
8790
schemaManager: SchemaManager;
8891
}
89-
| { phase: 'failed to start'; error: Error }
92+
| {
93+
phase: 'failed to start';
94+
error: Error;
95+
}
9096
| {
9197
phase: 'started';
9298
schemaManager: SchemaManager;
9399
}
94-
| { phase: 'stopping'; barrier: Resolvable<void> }
95-
| { phase: 'stopped'; stopError: Error | null };
100+
| {
101+
phase: 'draining';
102+
schemaManager: SchemaManager;
103+
barrier: Resolvable<void>;
104+
}
105+
| {
106+
phase: 'stopping';
107+
barrier: Resolvable<void>;
108+
}
109+
| {
110+
phase: 'stopped';
111+
stopError: Error | null;
112+
};
96113

97114
// Throw this in places that should be unreachable (because all other cases have
98115
// been handled, reducing the type of the argument to `never`). TypeScript will
@@ -121,6 +138,7 @@ export class ApolloServerBase<
121138
private state: ServerState;
122139
private toDispose = new Set<() => Promise<void>>();
123140
private toDisposeLast = new Set<() => Promise<void>>();
141+
private drainServers: (() => Promise<void>) | null = null;
124142
private experimental_approximateDocumentStoreMiB: Config['experimental_approximateDocumentStoreMiB'];
125143
private stopOnTerminationSignals: boolean;
126144
private landingPage: LandingPage | null = null;
@@ -137,13 +155,15 @@ export class ApolloServerBase<
137155
typeDefs,
138156
parseOptions = {},
139157
introspection,
140-
mocks,
141-
mockEntireSchema,
142158
plugins,
143159
gateway,
144-
experimental_approximateDocumentStoreMiB,
145-
stopOnTerminationSignals,
146160
apollo,
161+
stopOnTerminationSignals,
162+
// These next options aren't used in this function but they don't belong in
163+
// requestOptions.
164+
mocks,
165+
mockEntireSchema,
166+
experimental_approximateDocumentStoreMiB,
147167
...requestOptions
148168
} = config;
149169

@@ -425,6 +445,17 @@ export class ApolloServerBase<
425445
});
426446
}
427447

448+
const drainServerCallbacks = taggedServerListeners.flatMap((l) =>
449+
l.serverListener.drainServer ? [l.serverListener.drainServer] : [],
450+
);
451+
if (drainServerCallbacks.length) {
452+
this.drainServers = async () => {
453+
await Promise.all(
454+
drainServerCallbacks.map((drainServer) => drainServer()),
455+
);
456+
};
457+
}
458+
428459
// Find the renderLandingPage callback, if one is provided. If the user
429460
// installed ApolloServerPluginLandingPageDisabled then there may be none
430461
// found. On the other hand, if the user installed a landingPage plugin,
@@ -537,6 +568,7 @@ export class ApolloServerBase<
537568
'This data graph is missing a valid configuration. More details may be available in the server logs.',
538569
);
539570
case 'started':
571+
case 'draining': // We continue to run operations while draining.
540572
return this.state.schemaManager.getSchemaDerivedData();
541573
case 'stopping':
542574
throw new Error(
@@ -559,7 +591,7 @@ export class ApolloServerBase<
559591
}
560592

561593
protected assertStarted(methodName: string) {
562-
if (this.state.phase !== 'started') {
594+
if (this.state.phase !== 'started' && this.state.phase !== 'draining') {
563595
throw new Error(
564596
'You must `await server.start()` before calling `server.' +
565597
methodName +
@@ -648,45 +680,67 @@ export class ApolloServerBase<
648680
};
649681
}
650682

651-
/**
652-
* XXX: Note that stop() was designed to be called after start() has finished,
653-
* and should not be called concurrently with start() or before start(), or
654-
* else unexpected behavior may occur (e.g. some dependencies may not be
655-
* stopped).
656-
*/
657683
public async stop() {
658-
// Calling stop more than once should have the same result as the first time.
659-
if (this.state.phase === 'stopped') {
660-
if (this.state.stopError) {
661-
throw this.state.stopError;
662-
}
663-
return;
664-
}
684+
switch (this.state.phase) {
685+
case 'initialized':
686+
case 'starting':
687+
case 'failed to start':
688+
throw Error(
689+
'apolloServer.stop() should only be called after `await apolloServer.start()` has succeeded',
690+
);
665691

666-
// Two parallel calls to stop; just wait for the other one to finish and
667-
// do whatever it did.
668-
if (this.state.phase === 'stopping') {
669-
await this.state.barrier;
670-
// The cast here is because TS doesn't understand that this.state can
671-
// change during the await
672-
// (https://github.com/microsoft/TypeScript/issues/9998).
673-
const state = this.state as ServerState;
674-
if (state.phase !== 'stopped') {
675-
throw Error(`Surprising post-stopping state ${state.phase}`);
676-
}
677-
if (state.stopError) {
678-
throw state.stopError;
692+
// Calling stop more than once should have the same result as the first time.
693+
case 'stopped':
694+
if (this.state.stopError) {
695+
throw this.state.stopError;
696+
}
697+
return;
698+
699+
// Two parallel calls to stop; just wait for the other one to finish and
700+
// do whatever it did.
701+
case 'stopping':
702+
case 'draining': {
703+
await this.state.barrier;
704+
// The cast here is because TS doesn't understand that this.state can
705+
// change during the await
706+
// (https://github.com/microsoft/TypeScript/issues/9998).
707+
const state = this.state as ServerState;
708+
if (state.phase !== 'stopped') {
709+
throw Error(`Surprising post-stopping state ${state.phase}`);
710+
}
711+
if (state.stopError) {
712+
throw state.stopError;
713+
}
714+
return;
679715
}
680-
return;
716+
717+
case 'started':
718+
// This is handled by the rest of the function.
719+
break;
720+
721+
default:
722+
throw new UnreachableCaseError(this.state);
681723
}
682724

683-
// Commit to stopping, actually stop, and update the phase.
725+
const barrier = resolvable();
726+
727+
// Commit to stopping and start draining servers.
728+
this.state = {
729+
phase: 'draining',
730+
schemaManager: this.state.schemaManager,
731+
barrier,
732+
};
733+
734+
await this.drainServers?.();
735+
736+
// Servers are drained. Prevent further operations from starting and call
737+
// stop handlers.
684738
this.state = { phase: 'stopping', barrier: resolvable() };
685739
try {
686740
// We run shutdown handlers in two phases because we don't want to turn
687-
// off our signal listeners until we've done the important parts of shutdown
688-
// like running serverWillStop handlers. (We can make this more generic later
689-
// if it's helpful.)
741+
// off our signal listeners (ie, allow signals to kill the process) until
742+
// we've done the important parts of shutdown like running serverWillStop
743+
// handlers. (We can make this more generic later if it's helpful.)
690744
await Promise.all([...this.toDispose].map((dispose) => dispose()));
691745
await Promise.all([...this.toDisposeLast].map((dispose) => dispose()));
692746
} catch (stopError) {

packages/apollo-server-core/src/__tests__/ApolloServerBase.test.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -49,16 +49,17 @@ describe('ApolloServerBase construction', () => {
4949
warn,
5050
error: jest.fn(),
5151
};
52-
expect(() =>
53-
new ApolloServerBase({
54-
typeDefs,
55-
resolvers,
56-
apollo: {
57-
graphVariant: 'foo',
58-
key: 'service:real:key',
59-
},
60-
logger,
61-
}).stop(),
52+
expect(
53+
() =>
54+
new ApolloServerBase({
55+
typeDefs,
56+
resolvers,
57+
apollo: {
58+
graphVariant: 'foo',
59+
key: 'service:real:key',
60+
},
61+
logger,
62+
}),
6263
).not.toThrow();
6364
expect(warn).toHaveBeenCalledTimes(1);
6465
expect(warn.mock.calls[0][0]).toMatch(

packages/apollo-server/src/__tests__/stoppable/server.js renamed to packages/apollo-server-core/src/plugin/drainHttpServer/__tests__/stoppable/server.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
const http = require('http');
2-
const { Stopper } = require('../../../dist/stoppable.js');
2+
const {
3+
Stopper,
4+
} = require('../../../../../dist/plugin/drainHttpServer/stoppable.js');
35

46
const grace = Number(process.argv[2] || Infinity);
57
let stopper;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type http from 'http';
2+
import type { ApolloServerPlugin } from 'apollo-server-plugin-base';
3+
import { Stopper } from './stoppable';
4+
5+
// FIXME docs in code
6+
// FIXME write docs
7+
export interface ApolloServerPluginDrainHttpServerOptions {
8+
httpServer: http.Server;
9+
// Defaults to 10_000
10+
stopGracePeriodMillis?: number;
11+
}
12+
13+
export function ApolloServerPluginDrainHttpServer(
14+
options: ApolloServerPluginDrainHttpServerOptions,
15+
): ApolloServerPlugin {
16+
const stopper = new Stopper(options.httpServer);
17+
return {
18+
async serverWillStart() {
19+
return {
20+
async drainServer() {
21+
await stopper.stop(options.stopGracePeriodMillis ?? 10_000);
22+
},
23+
};
24+
},
25+
};
26+
}

packages/apollo-server-core/src/plugin/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,18 @@ export function ApolloServerPluginCacheControlDisabled(): ApolloServerPlugin {
8080
}
8181
//#endregion
8282

83+
//#region Drain HTTP server
84+
import type { ApolloServerPluginDrainHttpServerOptions } from './drainHttpServer';
85+
export type { ApolloServerPluginDrainHttpServerOptions } from './drainHttpServer';
86+
export function ApolloServerPluginDrainHttpServer(
87+
options: ApolloServerPluginDrainHttpServerOptions,
88+
): ApolloServerPlugin {
89+
return require('./drainHttpServer').ApolloServerPluginDrainHttpServer(
90+
options,
91+
);
92+
}
93+
//#endregion
94+
8395
//#region LandingPage
8496
import type { InternalApolloServerPlugin } from '../internalPlugin';
8597
export function ApolloServerPluginLandingPageDisabled(): ApolloServerPlugin {

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

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
gql,
99
AuthenticationError,
1010
ApolloServerPluginCacheControlDisabled,
11+
ApolloServerPluginDrainHttpServer,
1112
} from 'apollo-server-core';
1213
import {
1314
ApolloServer,
@@ -34,24 +35,34 @@ const resolvers = {
3435
};
3536

3637
describe('apollo-server-express', () => {
37-
let server: ApolloServer;
38-
let httpServer: http.Server;
38+
let serverToCleanUp: ApolloServer | null = null;
3939
testApolloServer(
4040
async (config: ApolloServerExpressConfig, options) => {
41-
server = new ApolloServer(config);
41+
serverToCleanUp = null;
42+
const app = express();
43+
const httpServer = http.createServer(app);
44+
const server = new ApolloServer({
45+
...config,
46+
plugins: [
47+
...(config.plugins ?? []),
48+
ApolloServerPluginDrainHttpServer({
49+
httpServer: httpServer,
50+
}),
51+
],
52+
});
4253
if (!options?.suppressStartCall) {
4354
await server.start();
55+
serverToCleanUp = server;
4456
}
45-
const app = express();
4657
server.applyMiddleware({ app, path: options?.graphqlPath });
47-
httpServer = await new Promise<http.Server>((resolve) => {
48-
const s: http.Server = app.listen({ port: 0 }, () => resolve(s));
58+
await new Promise((resolve) => {
59+
httpServer.once('listening', resolve);
60+
httpServer.listen({ port: 0 });
4961
});
5062
return createServerInfo(server, httpServer);
5163
},
5264
async () => {
53-
if (httpServer?.listening) await httpServer.close();
54-
if (server) await server.stop();
65+
await serverToCleanUp?.stop();
5566
},
5667
);
5768
});

0 commit comments

Comments
 (0)