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

Cache successfully parsed and validated documents for future requests. #2111

Merged
merged 15 commits into from Jan 23, 2019
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -2,6 +2,7 @@

### vNEXT

- Implement an in-memory cache store to save parsed and validated documents and provide performance benefits for successful executions of the same document. [PR #2111](https://github.com/apollographql/apollo-server/pull/2111) (`2.4.0-alpha.0`)
- Switch from `json-stable-stringify` to `fast-json-stable-stringify`. [PR #2065](https://github.com/apollographql/apollo-server/pull/2065)

### v2.3.1
Expand Down
2 changes: 1 addition & 1 deletion packages/apollo-cache-control/package.json
@@ -1,6 +1,6 @@
{
"name": "apollo-cache-control",
"version": "0.4.0",
"version": "0.5.0-alpha.0",
"description": "A GraphQL extension for cache control",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand Down
2 changes: 1 addition & 1 deletion packages/apollo-datasource-rest/package.json
@@ -1,6 +1,6 @@
{
"name": "apollo-datasource-rest",
"version": "0.2.1",
"version": "0.3.0-alpha.0",
"author": "opensource@apollographql.com",
"license": "MIT",
"repository": {
Expand Down
2 changes: 1 addition & 1 deletion packages/apollo-datasource/package.json
@@ -1,6 +1,6 @@
{
"name": "apollo-datasource",
"version": "0.2.1",
"version": "0.3.0-alpha.0",
"author": "opensource@apollographql.com",
"license": "MIT",
"repository": {
Expand Down
2 changes: 1 addition & 1 deletion packages/apollo-engine-reporting/package.json
@@ -1,6 +1,6 @@
{
"name": "apollo-engine-reporting",
"version": "0.2.0",
"version": "0.3.0-alpha.0",
"description": "Send reports about your GraphQL services to Apollo Engine",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand Down
2 changes: 1 addition & 1 deletion packages/apollo-server-azure-functions/package.json
@@ -1,6 +1,6 @@
{
"name": "apollo-server-azure-functions",
"version": "2.3.1",
"version": "2.4.0-alpha.0",
"description": "Production-ready Node.js GraphQL server for Azure Functions",
"keywords": [
"GraphQL",
Expand Down
2 changes: 1 addition & 1 deletion packages/apollo-server-cache-memcached/package.json
@@ -1,6 +1,6 @@
{
"name": "apollo-server-cache-memcached",
"version": "0.2.1",
"version": "0.3.0-alpha.0",
"author": "opensource@apollographql.com",
"license": "MIT",
"repository": {
Expand Down
2 changes: 1 addition & 1 deletion packages/apollo-server-cache-redis/package.json
@@ -1,6 +1,6 @@
{
"name": "apollo-server-cache-redis",
"version": "0.2.1",
"version": "0.3.0-alpha.0",
"author": "opensource@apollographql.com",
"license": "MIT",
"repository": {
Expand Down
2 changes: 1 addition & 1 deletion packages/apollo-server-caching/package.json
@@ -1,6 +1,6 @@
{
"name": "apollo-server-caching",
"version": "0.2.1",
"version": "0.3.0-alpha.0",
"author": "opensource@apollographql.com",
"license": "MIT",
"repository": {
Expand Down
2 changes: 1 addition & 1 deletion packages/apollo-server-cloud-functions/package.json
@@ -1,6 +1,6 @@
{
"name": "apollo-server-cloud-functions",
"version": "2.3.1",
"version": "2.4.0-alpha.0",
"description": "Production-ready Node.js GraphQL server for Google Cloud Functions",
"keywords": [
"GraphQL",
Expand Down
2 changes: 1 addition & 1 deletion packages/apollo-server-cloudflare/package.json
@@ -1,6 +1,6 @@
{
"name": "apollo-server-cloudflare",
"version": "2.3.1",
"version": "2.4.0-alpha.0",
"description": "Production-ready Node.js GraphQL server for Cloudflare workers",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
2 changes: 1 addition & 1 deletion packages/apollo-server-core/package.json
@@ -1,6 +1,6 @@
{
"name": "apollo-server-core",
"version": "2.3.1",
"version": "2.4.0-alpha.0",
"description": "Core engine for Apollo GraphQL server",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
21 changes: 21 additions & 0 deletions packages/apollo-server-core/src/ApolloServer.ts
Expand Up @@ -9,6 +9,7 @@ import {
GraphQLFieldResolver,
ValidationContext,
FieldDefinitionNode,
DocumentNode,
} from 'graphql';
import { GraphQLExtension } from 'graphql-extensions';
import { EngineReportingAgent } from 'apollo-engine-reporting';
Expand Down Expand Up @@ -114,6 +115,11 @@ export class ApolloServerBase {
// the default version is specified in playground.ts
protected playgroundOptions?: PlaygroundRenderPageOptions;

// An optionally defined store that, when enabled (default), will store the
// parsed and validated versions of operations in-memory, allowing subsequent
// parse and validates on the same operation to be executed immediately.
private documentStore?: InMemoryLRUCache<DocumentNode>;
abernix marked this conversation as resolved.
Show resolved Hide resolved

// The constructor should be universal across all environments. All environment specific behavior should be set by adding or overriding methods
constructor(config: Config) {
if (!config) throw new Error('ApolloServer requires options.');
Expand All @@ -136,6 +142,9 @@ export class ApolloServerBase {
...requestOptions
} = config;

// Initialize the document store. This cannot currently be disabled.
this.initializeDocumentStore();

// Plugins will be instantiated if they aren't already, and this.plugins
// is populated accordingly.
this.ensurePluginInstantiation(plugins);
Expand Down Expand Up @@ -486,6 +495,17 @@ export class ApolloServerBase {
});
}

private initializeDocumentStore(): void {
this.documentStore = new InMemoryLRUCache({
// Create ~about~ a 30MiB InMemoryLRUCache. This is less than precise
// since the technique to calculate the size of a DocumentNode is
// only using JSON.stringify on the DocumentNode (and thus doesn't account
// for unicode characters, etc.), but it should do a reasonable job at
// providing a caching document store for most operations.
maxSize: Math.pow(2, 20) * 30,
});
}

// This function is used by the integrations to generate the graphQLOptions
// from an object containing the request and other integration specific
// options
Expand All @@ -509,6 +529,7 @@ export class ApolloServerBase {
return {
schema: this.schema,
plugins: this.plugins,
documentStore: this.documentStore,
extensions: this.extensions,
context,
// Allow overrides from options. Be explicit about a couple of them to
Expand Down
164 changes: 164 additions & 0 deletions packages/apollo-server-core/src/__tests__/runQuery.test.ts
Expand Up @@ -20,6 +20,11 @@ import {
import { processGraphQLRequest, GraphQLRequest } from '../requestPipeline';
import { Request } from 'apollo-server-env';
import { GraphQLOptions, Context as GraphQLContext } from 'apollo-server-core';
import {
ApolloServerPlugin,
GraphQLRequestListener,
} from 'apollo-server-plugin-base';
import { InMemoryLRUCache } from 'apollo-server-caching';

// This is a temporary kludge to ensure we preserve runQuery behavior with the
// GraphQLRequestProcessor refactoring.
Expand Down Expand Up @@ -49,10 +54,12 @@ interface QueryOptions
| 'cacheControl'
| 'context'
| 'debug'
| 'documentStore'
| 'extensions'
| 'fieldResolver'
| 'formatError'
| 'formatResponse'
| 'plugins'
| 'rootValue'
| 'schema'
| 'tracing'
Expand Down Expand Up @@ -444,6 +451,163 @@ describe('runQuery', () => {
});
});

describe('parsing and validation cache', () => {
function createLifecyclePluginMocks() {
const validationDidStart = jest.fn();
const parsingDidStart = jest.fn();

const plugins: ApolloServerPlugin[] = [
{
requestDidStart() {
return {
validationDidStart,
parsingDidStart,
} as GraphQLRequestListener;
},
},
];

return {
plugins,
events: { validationDidStart, parsingDidStart },
};
}

function runRequest({
queryString = '{ testString }',
plugins = [],
documentStore,
}: {
queryString?: string;
plugins?: ApolloServerPlugin[];
documentStore?: QueryOptions['documentStore'];
}) {
return runQuery({
schema,
documentStore,
queryString,
plugins,
request: new MockReq(),
});
}

function forgeLargerTestQuery(
count: number,
prefix: string = 'prefix',
): string {
if (count <= 0) {
count = 1;
}

let query: string = '';

for (let q = 0; q < count; q++) {
query += ` ${prefix}_${count}: testString\n`;
}

return '{\n' + query + '}';
}

it('validates each time when the documentStore is not present', async () => {
expect.assertions(4);

const {
plugins,
events: { parsingDidStart, validationDidStart },
} = createLifecyclePluginMocks();

// The first request will do a parse and validate. (1/1)
await runRequest({ plugins });
expect(parsingDidStart.mock.calls.length).toBe(1);
expect(validationDidStart.mock.calls.length).toBe(1);

// The second request should ALSO do a parse and validate. (2/2)
await runRequest({ plugins });
expect(parsingDidStart.mock.calls.length).toBe(2);
expect(validationDidStart.mock.calls.length).toBe(2);
});

it('caches the DocumentNode in the documentStore when instrumented', async () => {
expect.assertions(4);
const documentStore = new InMemoryLRUCache<DocumentNode>();

const {
plugins,
events: { parsingDidStart, validationDidStart },
} = createLifecyclePluginMocks();

// An uncached request will have 1 parse and 1 validate call.
await runRequest({ plugins, documentStore });
expect(parsingDidStart.mock.calls.length).toBe(1);
expect(validationDidStart.mock.calls.length).toBe(1);

// The second request should still only have a 1 validate and 1 parse.
await runRequest({ plugins, documentStore });
expect(parsingDidStart.mock.calls.length).toBe(1);
expect(validationDidStart.mock.calls.length).toBe(1);

console.log(documentStore);
});

it("the documentStore calculates the DocumentNode's length by its JSON.stringify'd representation", async () => {
expect.assertions(14);
const {
plugins,
events: { parsingDidStart, validationDidStart },
} = createLifecyclePluginMocks();

const queryLarge = forgeLargerTestQuery(3, 'large');
const querySmall1 = forgeLargerTestQuery(1, 'small1');
const querySmall2 = forgeLargerTestQuery(1, 'small2');

// We're going to create a smaller-than-default cache which will be the
// size of the two smaller queries. All three of these queries will never
// fit into this cache, so we'll roll through them all.
const maxSize =
JSON.stringify(parse(querySmall1)).length +
JSON.stringify(parse(querySmall2)).length;

const documentStore = new InMemoryLRUCache<DocumentNode>({ maxSize });

await runRequest({ plugins, documentStore, queryString: querySmall1 });
expect(parsingDidStart.mock.calls.length).toBe(1);
expect(validationDidStart.mock.calls.length).toBe(1);

await runRequest({ plugins, documentStore, queryString: querySmall2 });
expect(parsingDidStart.mock.calls.length).toBe(2);
expect(validationDidStart.mock.calls.length).toBe(2);

// This query should be large enough to evict both of the previous
// from the LRU cache since it's larger than the TOTAL limit of the cache
// (which is capped at the length of small1 + small2) — though this will
// still fit (barely).
await runRequest({ plugins, documentStore, queryString: queryLarge });
expect(parsingDidStart.mock.calls.length).toBe(3);
expect(validationDidStart.mock.calls.length).toBe(3);

// Make sure the large query is still cached (No incr. to parse/validate.)
await runRequest({ plugins, documentStore, queryString: queryLarge });
expect(parsingDidStart.mock.calls.length).toBe(3);
expect(validationDidStart.mock.calls.length).toBe(3);

// This small (and the other) should both trigger parse/validate since
// the cache had to have evicted them both after accommodating the larger.
await runRequest({ plugins, documentStore, queryString: querySmall1 });
expect(parsingDidStart.mock.calls.length).toBe(4);
expect(validationDidStart.mock.calls.length).toBe(4);

await runRequest({ plugins, documentStore, queryString: querySmall2 });
expect(parsingDidStart.mock.calls.length).toBe(5);
expect(validationDidStart.mock.calls.length).toBe(5);

// Finally, make sure that the large query is gone (it should be, after
// the last two have take its spot again.)
await runRequest({ plugins, documentStore, queryString: queryLarge });
expect(parsingDidStart.mock.calls.length).toBe(6);
expect(validationDidStart.mock.calls.length).toBe(6);
});
});

describe('async_hooks', () => {
let asyncHooks: typeof import('async_hooks');
let asyncHook: import('async_hooks').AsyncHook;
Expand Down
3 changes: 2 additions & 1 deletion packages/apollo-server-core/src/graphqlOptions.ts
Expand Up @@ -6,7 +6,7 @@ import {
} from 'graphql';
import { GraphQLExtension } from 'graphql-extensions';
import { CacheControlExtensionOptions } from 'apollo-cache-control';
import { KeyValueCache } from 'apollo-server-caching';
import { KeyValueCache, InMemoryLRUCache } from 'apollo-server-caching';
import { DataSource } from 'apollo-datasource';
import { ApolloServerPlugin } from 'apollo-server-plugin-base';

Expand Down Expand Up @@ -43,6 +43,7 @@ export interface GraphQLServerOptions<
cache?: KeyValueCache;
persistedQueries?: PersistedQueryOptions;
plugins?: ApolloServerPlugin[];
documentStore?: InMemoryLRUCache<DocumentNode>;
}

export type DataSources<TContext> = {
Expand Down