Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

apollo-server: close connections on ApolloServer.stop() #4908

Merged
merged 2 commits into from
Feb 9, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ The version headers in this history reflect the versions of Apollo Server itself

> The changes noted within this `vNEXT` section have not been released yet. New PRs and commits which introduce changes should include an entry in this `vNEXT` section as part of their development. With few exceptions, the format of the entry should follow convention (i.e., prefix with package name, use markdown `backtick formatting` for package names and code, suffix with a link to the change-set à la `[PR #YYY](https://link/pull/YYY)`, etc.). When a release is being prepared, a new header will be (manually) created below and the appropriate changes within that release will be moved into the new section.

- `apollo-server`: Previously, `ApolloServer.stop()` functioned like `net.Server.close()` in that it did not close idle connections or close active connections after a grace period. This meant that trying to `await ApolloServer.stop()` could hang indefinitely if there are open connections. Now, this method closes idle connections, and closes active connections after 10 seconds. The grace period can be adjusted by passing the new `stopGracePeriodMillis` option to `new ApolloServer`, or disabled by passing `Infinity` (though it will still close idle connections). Note that this only applies to the "batteries-included" `ApolloServer` in the `apollo-server` package with its own built-in Express and HTTP servers. [ISsue #4097](https://github.com/apollographql/apollo-server/issues/4097)
- `apollo-server-core`: When used with `ApolloGateway`, `ApolloServer.stop` now invokes `ApolloGateway.stop`. (This makes sense because `ApolloServer` already invokes `ApolloGateway.load` which is what starts the behavior stopped by `ApolloGateway.stop`.) Note that `@apollo/gateway` 0.23 will expect to be stopped in order for natural program shutdown to occur. [Issue #4428](https://github.com/apollographql/apollo-server/issues/4428)
- `apollo-server-core`: Avoid instrumenting schemas for the old `graphql-extensions` library unless extensions are provided. [PR #4893](https://github.com/apollographql/apollo-server/pull/4893) [Issue #4889](https://github.com/apollographql/apollo-server/issues/4889)
- `apollo-server-plugin-response-cache`: The `shouldReadFromCache` and `shouldWriteToCache` hooks were always documented as returning `ValueOrPromise<boolean>` (ie, that they could be either sync or async), but they actually only worked if they returned a bool. Now they can be either sync or async as intended. [PR #4890](http://github.com/apollographql/apollo-server/pull/4890) [Issue #4886](https://github.com/apollographql/apollo-server/issues/4886)
Expand Down
17 changes: 16 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
"@types/qs-middleware": "1.0.1",
"@types/request": "2.48.5",
"@types/request-promise": "4.1.47",
"@types/stoppable": "1.1.0",
"@types/supertest": "2.0.10",
"@types/test-listen": "1.1.0",
"@types/type-is": "1.6.3",
Expand Down
3 changes: 2 additions & 1 deletion packages/apollo-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
"apollo-server-express": "file:../apollo-server-express",
"express": "^4.0.0",
"graphql-subscriptions": "^1.0.0",
"graphql-tools": "^4.0.0"
"graphql-tools": "^4.0.0",
"stoppable": "^1.1.0"
},
"peerDependencies": {
"graphql": "^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0"
Expand Down
74 changes: 74 additions & 0 deletions packages/apollo-server/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createConnection } from 'net';
import request from 'request';
import { createApolloFetch } from 'apollo-fetch';

Expand All @@ -6,6 +7,7 @@ import { gql, ApolloServer } from '../index';
const typeDefs = gql`
type Query {
hello: String
hang: String
}
`;

Expand All @@ -15,6 +17,19 @@ const resolvers = {
},
};

class Barrier {
private resolvePromise!: () => void;
private promise = new Promise<void>((r) => {
this.resolvePromise = r;
});
async wait() {
await this.promise;
}
unblock() {
this.resolvePromise();
}
}

describe('apollo-server', () => {
describe('constructor', () => {
it('accepts typeDefs and resolvers', () => {
Expand Down Expand Up @@ -54,6 +69,65 @@ describe('apollo-server', () => {
expect(fn.mock.calls).toEqual([['a'], ['b'], ['c'], ['d']]);
});

describe('stops even with open HTTP connections', () => {
it('all connections are idle', async () => {
const server = new ApolloServer({
typeDefs,
resolvers,
// Disable killing non-idle connections. This means the test will only
// pass if the fast graceful close of the idle connection works.
stopGracePeriodMillis: Infinity,
});
const { port } = await server.listen({ port: 0 });

// Open a TCP connection to the server, and let it dangle idle
// without starting a request.
const connectionBarrier = new Barrier();
createConnection({ host: 'localhost', port: port as number }, () =>
connectionBarrier.unblock(),
);
await connectionBarrier.wait();

// Stop the server. Before, when this was just net.Server.close, this
// would hang. Now that we use stoppable, the idle connection is immediately
// killed.
await server.stop();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No expects needed since the only expectation is for this to finish, right?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. Both tests fail with Jest timeout if you revert back to calling httpServer.close instead of httpServer.stop.

});

it('a connection with an active HTTP request', async () => {
const gotToHangBarrier = new Barrier();
const hangBarrier = new Barrier();
const server = new ApolloServer({
typeDefs,
resolvers: {
...resolvers,
Query: {
...resolvers.Query,
async hang() {
gotToHangBarrier.unblock();
await hangBarrier.wait(); // never unblocks
},
},
},
// A short grace period, because we're going to actually let this
// strike.
stopGracePeriodMillis: 10,
});
const { url } = await server.listen({ port: 0 });

// Start an HTTP request that won't ever finish. (Ignore the very
// expected error that happens after the server is stopped.)
const apolloFetch = createApolloFetch({ uri: url });
apolloFetch({ query: '{hang}' }).catch(() => {});
await gotToHangBarrier.wait();

// Stop the server. Before, when this was just net.Server.close, this
// would hang. Now that we use stoppable, the idle connection is immediately
// killed.
await server.stop();
});
});

// These tests are duplicates of ones in apollo-server-integration-testsuite
// We don't actually expect Jest to do much here, the purpose of these
// tests is to make sure our typings are correct, and to trigger a
Expand Down
31 changes: 23 additions & 8 deletions packages/apollo-server/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
// Note: express is only used if you use the ApolloServer.listen API to create
// an express app for you instead of applyMiddleware (which you might not even
// use with express). The dependency is unused otherwise, so don't worry if
// you're not using express or your version doesn't quite match up.
// This is the "batteries-included" version of `apollo-server-express`. It
// handles creating the Express app and HTTP server for you (using whatever
// version of `express` its dependency pulls in). If you need to customize the
// Express app or HTTP server at all, you just use `apollo-server-express`
// instead.
import express from 'express';
import http from 'http';
import stoppable from 'stoppable';
import {
ApolloServer as ApolloServerBase,
CorsOptions,
Expand All @@ -23,19 +25,22 @@ export interface ServerInfo {
}

export class ApolloServer extends ApolloServerBase {
private httpServer?: http.Server;
private httpServer?: stoppable.StoppableServer;
private cors?: CorsOptions | boolean;
private onHealthCheck?: (req: express.Request) => Promise<any>;
private stopGracePeriodMillis: number;

constructor(
config: ApolloServerExpressConfig & {
cors?: CorsOptions | boolean;
onHealthCheck?: (req: express.Request) => Promise<any>;
stopGracePeriodMillis?: number;
},
) {
super(config);
this.cors = config && config.cors;
this.onHealthCheck = config && config.onHealthCheck;
this.stopGracePeriodMillis = config?.stopGracePeriodMillis ?? 10000;
}

private createServerInfo(
Expand Down Expand Up @@ -113,13 +118,23 @@ export class ApolloServer extends ApolloServerBase {
});

const httpServer = http.createServer(app);
this.httpServer = httpServer;
// `stoppable` adds a `.stop()` method which:
// - closes the server (ie, stops listening)
// - closes all connections with no active requests
// - continues to close connections when their active request count drops to
// zero
// - in 3 seconds (configurable), closes all remaining active connections
// - calls its callback once there are no remaining active connections
//
// If you don't like this behavior, use apollo-server-express instead of
// apollo-server.
this.httpServer = stoppable(httpServer, this.stopGracePeriodMillis);

if (this.subscriptionServerOptions) {
this.installSubscriptionHandlers(httpServer);
}

await new Promise(resolve => {
await new Promise((resolve) => {
httpServer.once('listening', resolve);
// If the user passed a callback to listen, it'll get called in addition
// to our resolver. They won't have the ability to get the ServerInfo
Expand All @@ -133,7 +148,7 @@ export class ApolloServer extends ApolloServerBase {
public async stop() {
if (this.httpServer) {
const httpServer = this.httpServer;
await new Promise(resolve => httpServer.close(resolve));
await new Promise<void>((resolve) => httpServer.stop(() => resolve()));
this.httpServer = undefined;
}
await super.stop();
Expand Down