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

Full query response cache plugin #2437

Merged
merged 14 commits into from
Mar 22, 2019
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 @@ -2,6 +2,7 @@

### v2.5.0

- New plugin package `apollo-server-plugin-response-cache` implementing a full query response cache based on `apollo-cache-control` hints. The implementation added a few hooks and context fields; see the PR for details. There is a slight change to `cacheControl` object: previously, `cacheControl.stripFormattedExtensions` defaulted to false if you did not provide a `cacheControl` option object, but defaulted to true if you provided (eg) `cacheControl: {defaultMaxAge: 10}`. Now `stripFormattedExtensions` defaults to false unless explicitly provided as `true`, or if you use the legacy boolean `cacheControl: true`. [PR #2437](https://github.com/apollographql/apollo-server/pull/2437)
- Allow `GraphQLRequestListener` callbacks in plugins to depend on `this`. [PR #2470](https://github.com/apollographql/apollo-server/pull/2470)
- Move shared TypeScript utility types `WithRequired` and `ValueOrPromise` into `apollo-server-env`. [PR #2415](https://github.com/apollographql/apollo-server/pull/2415) [PR #2417](https://github.com/apollographql/apollo-server/pull/2417)

Expand Down
1 change: 1 addition & 0 deletions docs/_config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ sidebar_categories:
- features/mocking
- features/errors
- features/data-sources
- features/caching
- features/subscriptions
- features/metrics
- features/graphql-playground
Expand Down
22 changes: 15 additions & 7 deletions docs/package-lock.json

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

2 changes: 1 addition & 1 deletion docs/source/api/apollo-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ new ApolloServer({

* `tracing`, `cacheControl`: <`Boolean`>

Add tracing or cacheControl meta data to the GraphQL response
If set to true, adds tracing or cacheControl meta data to the GraphQL response. This is primarily intended for use with the deprecated Engine proxy. `cacheControl` can also be set to an object to specify arguments to the `apollo-cache-control` package, including `defaultMaxAge`, `calculateHttpHeaders`, and `stripFormattedExtensions`.

* `formatError`, `formatResponse`: <`Function`>

Expand Down
150 changes: 150 additions & 0 deletions docs/source/features/caching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
---
title: Caching
description: Automatically set HTTP cache headers and save full responses in a cache.
---

Production apps often rely on caching for scalability.

A single GraphQL request consists of running many different resolvers, each of which can have different caching semantics. Some fields may be uncacheable. Some fields may be cacheable for a few seconds, and others for a few hours. Some fields may have values that are the same for all users of your app, and other fields may vary based on the current session.

Apollo Server provides a mechanism for server authors to declare fine-grained cache control parameters on individual GraphQL types and fields, both statically inside your schema using the `@cacheControl` directive and dynamically within your resolvers using the `info.cacheControl.setCacheHint` API.

For each request, Apollo Server combines all the cache hints from all the queried fields and uses it to power several caching features. These features include **HTTP caching headers** for CDNs and browsers, and a GraphQL **full response cache**.


## Defining cache hints

You can define cache hints *statically* in your schema and *dynamically* in your resolvers.

### Adding cache hints statically in your schema

The easiest way to add cache hints is directly in your schema using the `@cacheControl` directive. Apollo Server automatically adds the definition of the `@cacheControl` directive to your schema when you create a new `ApolloServer` object with `typeDefs` and `resolvers`. Hints look like this:

```graphql
type Post @cacheControl(maxAge: 240) {
id: Int!
title: String
author: Author
votes: Int @cacheControl(maxAge: 30)
comments: [Comment]
readByCurrentUser: Boolean! @cacheControl(scope: PRIVATE)
}

type Comment @cacheControl(maxAge: 1000) {
post: Post!
}

type Query {
latestPost: Post @cacheControl(maxAge: 10)
}
```

You can apply `@cacheControl` to an individual field or to a type.

Hints on a field describe the cache policy for that field itself; for example, `Post.votes` can be cached for 30 seconds.

Hints on a type apply to all fields that *return* objects of that type (possibly wrapped in lists and non-null specifiers). For example, the hint `@cacheControl(maxAge: 240)` on `Post` applies to the field `Comment.post`, and the hint `@cacheControl(maxAge:1000)` on `Comment` applies to the field `Post.comments`.

Hints on fields override hints specified on the target type. For example, the hint `@cacheControl(maxAge: 10)` on `Query.latestPost` takes precedence over the hint `@cacheControl(maxAge: 240)` on `Post`.

See [below](#default-maxage) for the semantics of fields which don't have `maxAge` set on them (statically or dynamically).

`@cacheControl` can specify `maxAge` (in seconds, like in an HTTP `Cache-Control` header) and `scope`, which can be `PUBLIC` (the default) or `PRIVATE`.


### Adding cache hints dynamically in your resolvers

If you won't know if a field is cacheable until you've actually resolved it, you can use the dynamic API to set hints in your resolvers:

```javascript
const resolvers = {
Query: {
post: (_, { id }, _, info) => {
info.cacheControl.setCacheHint({ maxAge: 60, scope: 'PRIVATE' });
return find(posts, { id });
}
}
}
```

If you're using TypeScript, you need the following to teach TypeScript that the GraphQL `info` object has a `cacheControl` field:
```javascript
import 'apollo-cache-control';
```


<h3 id="default-maxage">Setting a default `maxAge`</h3>

By default, root fields (ie, fields on `Query` and `Mutation`) and fields returning object and interface types are considered to have a `maxAge` of 0 (ie, uncacheable) if they don't have a static or dynamic cache hint. (Non-root scalar fields inherit their cacheability from their parent, so that in the common case of an object type with a bunch of strings and numbers which all have the same cacheability, you just need to declare the hint on the object type.)

The power of cache hints comes from being able to set them precisely to different values on different types and fields based on your understanding of your implementation's semantics. But when getting started with the cache control API, you might just want to apply the same `maxAge` to most of your resolvers.

You can achieve this by specifying a default max age when you create your `ApolloServer`. This max age will be used instead of 0 for root, object, and interface fields which don't explicitly set `maxAge` via schema hints (including schema hints on the type that they return) or the dynamic API. You can override this for a particular resolver or type by setting `@cacheControl(maxAge: 0)`. For example:

```javascript
const server = new ApolloServer({
// ...
cacheControl: {
defaultMaxAge: 5,
},
}));
```


### The overall cache policy

Apollo Server's cache API lets you declare fine-grained cache hints on specific resolvers. Apollo Server then combines these hints into an overall cache policy for the response. The `maxAge` of this policy is the minimum `maxAge` across all fields in your request. As [described above](#default-maxage), the default `maxAge` of all root fields and non-scalar fields is 0, so the overall cache policy for a response will have `maxAge` 0 (ie, uncacheable) unless all root and non-scalar fields in the response have cache hints (or if `defaultMaxAge` is specified).

If the overall cache policy has a non-zero `maxAge`, its scope is `PRIVATE` if any hints have scope `PRIVATE`, and `PUBLIC` otherwise.


<h2 id="http-cache-headers">Serving HTTP cache headers</h2>

For any response whose overall cache policy has a non-zero `maxAge`, Apollo Server will automatically set the `Cache-Control` HTTP response header to an appropriate value describing the `maxAge` and scope, such as `Cache-Control: max-age=60, private`. If you run your Apollo Server instance behind a [CDN](https://en.wikipedia.org/wiki/Content_delivery_network) or other caching proxy, it can use this header's value to know how to cache your GraphQL responses.

As many CDNs and caching proxies only cache GET requests (not POST requests) and may have a limit on the size of a GET URL, you may find it helpful to use [automatic persisted queries](https://github.com/apollographql/apollo-link-persisted-queries), especially with the `useGETForHashedQueries` option to `apollo-link-persisted-queries`.

If you don't want to set HTTP cache headers, pass `cacheControl: {calculateHttpHeaders: false}` to `new ApolloServer()`.


## Saving full responses to a cache

Apollo Server lets you save cacheable responses to a Redis, Memcached, or in-process cache. Cached responses respect the `maxAge` cache hint.

To use the response cache, you need to install its plugin when you create your `ApolloServer`:

```javascript
import responseCachePlugin from 'apollo-server-plugin-response-cache';
const server = new ApolloServer({
// ...
plugins: [responseCachePlugin()],
});
```

By default, the response cache plugin will use the same cache used by other Apollo Server features, which defaults to an in-memory LRU cache. When running multiple server instances, you’ll want to use a shared cache backend such as Memcached or Redis instead. See [the data sources documentation](./data-sources.html#Using-Memcached-Redis-as-a-cache-storage-backend) for details on how to customize Apollo Server's cache. If you want to use a different cache backed for the response cache than for other Apollo Server caching features, just pass a `KeyValueCache` as the `cache` option to the `responseCachePlugin` function.

If you have data whose response should be cached separately for different users, set `@cacheControl(scope: PRIVATE)` hints on the data, and teach the cache control plugin how to tell your users apart by defining a `sessionId` hook:

```javascript
import responseCachePlugin from 'apollo-server-plugin-response-cache';
const server = new ApolloServer({
// ...
plugins: [responseCachePlugin({
sessionId: (requestContext) => (requestContext.request.http.headers.get('sessionid') || null),
})],
});
```

Responses whose overall cache policy scope is `PRIVATE` are shared only among sessions with the same session ID. Private responses are not cached if the `sessionId` hook is not defined or returns null.

Responses whose overall cache policy scope is `PUBLIC` are shared separately among all sessions with `sessionId` null and among all sessions with non-null `sessionId`. Caching these separately allows you to have different caches for all logged-in users vs all logged-out users, if there is easily cacheable data that should only be visible to logged-in users.

Responses containing GraphQL errors or no data are never cached.

The plugin allows you to define a few more hooks to affect cache behavior for a specific request. All hooks take in a `GraphQLRequestContext`.

- `extraCacheKeyData`: this hook can return any JSON-stringifiable object which is added to the cache key. For example, if your API includes translatable text, this hook can return a string derived from `requestContext.request.http.headers.get('Accept-Language')`.
- `shouldReadFromCache`: if this hook returns false, the plugin will not read responses from the cache.
- `shouldWriteToCache`: if this hook returns false, the plugin will not write responses to the cache.

In addition to the [`Cache-Control` HTTP header](#http-cache-headers), the response cache plugin will also set the `Age` HTTP header to the number of seconds the value has bee sitting in the cache.
2 changes: 1 addition & 1 deletion docs/source/whats-new.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ For more information on automatic persisted queries, check the [APQ section of t

### CDN integration

Apollo Server works well with a Content-Distribution Network to cache full GraphQL query results. Apollo Server provides `cache-control` headers that a CDN uses to determine how long a request should be cached. For subsequent requests, the result will be served directly from the CDN's cache. A CDN paired with Apollo Server's persisted queries is especially powerful, since GraphQL operations can be shortened and sent with a HTTP GET request. To enable caching and a CDN in Apollo Server, follow the [Performance Guide](https://www.apollographql.com/docs/guides/performance.html#cdn).
Apollo Server works well with a Content-Distribution Network to cache full GraphQL query results. Apollo Server provides `cache-control` headers that a CDN uses to determine how long a request should be cached. For subsequent requests, the result will be served directly from the CDN's cache. A CDN paired with Apollo Server's persisted queries is especially powerful, since GraphQL operations can be shortened and sent with a HTTP GET request. Read more about [caching in Apollo Server](./features/caching.html).

### GraphQL errors

Expand Down
9 changes: 9 additions & 0 deletions 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 @@ -58,6 +58,7 @@
"apollo-server-lambda": "file:packages/apollo-server-lambda",
"apollo-server-micro": "file:packages/apollo-server-micro",
"apollo-server-plugin-base": "file:packages/apollo-server-plugin-base",
"apollo-server-plugin-response-cache": "file:packages/apollo-server-plugin-response-cache",
"apollo-server-testing": "file:packages/apollo-server-testing",
"apollo-tracing": "file:packages/apollo-tracing",
"graphql-extensions": "file:packages/graphql-extensions"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ export async function collectCacheControlHints(
): Promise<CacheHint[]> {
enableGraphQLExtensions(schema);

const cacheControlExtension = new CacheControlExtension(options);
// Because this test helper looks at the formatted extensions, we always want
// to include them.
const cacheControlExtension = new CacheControlExtension({
...options,
stripFormattedExtensions: false,
});

const response = await graphql({
schema,
Expand Down
25 changes: 24 additions & 1 deletion packages/apollo-cache-control/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ declare module 'graphql/type/definition' {
}
}

declare module 'apollo-server-core/dist/requestPipelineAPI' {
interface GraphQLRequestContext<TContext> {
// Not readonly: plugins can set it.
overallCachePolicy?: Required<CacheHint> | undefined;
}
}

export class CacheControlExtension<TContext = any>
implements GraphQLExtension<TContext> {
private defaultMaxAge: number;
Expand All @@ -51,6 +58,7 @@ export class CacheControlExtension<TContext = any>
}

private hints: Map<ResponsePath, CacheHint> = new Map();
private overallCachePolicyOverride?: Required<CacheHint>;

willResolveField(
_source: any,
Expand Down Expand Up @@ -123,7 +131,14 @@ export class CacheControlExtension<TContext = any>
}

format(): [string, CacheControlFormat] | undefined {
if (this.options.stripFormattedExtensions) return;
// We should have to explicitly ask to leave the formatted extension in, or
// pass the old-school `cacheControl: true` (as interpreted by
// apollo-server-core/ApolloServer), in order to include the
// engineproxy-aimed extensions. Specifically, we want users of
// apollo-server-plugin-response-cache to be able to specify
// `cacheControl: {defaultMaxAge: 600}` without accidentally turning on the
// extension formatting.
if (this.options.stripFormattedExtensions !== false) return;

return [
'cacheControl',
Expand Down Expand Up @@ -152,7 +167,15 @@ export class CacheControlExtension<TContext = any>
}
}

public overrideOverallCachePolicy(overallCachePolicy: Required<CacheHint>) {
this.overallCachePolicyOverride = overallCachePolicy;
}

computeOverallCachePolicy(): Required<CacheHint> | undefined {
if (this.overallCachePolicyOverride) {
return this.overallCachePolicyOverride;
}

let lowestMaxAge: number | undefined = undefined;
let scope: CacheScope = CacheScope.Public;

Expand Down
Loading