Skip to content

Commit

Permalink
Implement @cacheControl(inheritMaxAge: true) (#5247)
Browse files Browse the repository at this point in the history
Previously, the cache control logic treats root fields and fields that
return object or interface types which don't declare `maxAge` specially:
they are treated as uncachable (`maxAge` 0) by default. You can change
that 0 to a different number with the `defaultMaxAge` option, but you
can't just make them work like scalars and not affect the cache policy
at all.

This PR introduces a new argument to the directive:
`@cacheControl(inheritMaxAge: true)`. If this is specified on an field returning
a composite type (object, interface, or union) or on the composite type itself,
and it does not specify its `maxAge` in some other way (on the return value's
type or via `setCacheHint`), then the field is just ignored for the sake of
calculating cache policy, instead of defaulting to `defaultMaxAge`.

Note that this does *not* affect root fields (scalar or composite). You still
need to make sure that every root field is declared as cachable to have a
cachable operation. This just lets you say that a nested composite form is "part
of" its parent for the purposes of caching, just like nested scalar fields are
by default.

The behavior described above (and looking on the field's return type for
`@cacheControl` in the first place) previously applied to fields returning
object and interface types (possibly nested in some layers of not-null and/or
list-of). This PR makes things more consistent by treating the third composite
type (unions) in the same way.

One use case for this could be in federation: `buildFederatedSchema` could add
this directive to all `@external` fields, since their values are typically
provided directly in the arguments to the `Query._entities` field.

This addresses concerns from #4162 and #3559.
  • Loading branch information
glasser committed Jun 3, 2021
1 parent 8bc07a0 commit fa87f2c
Show file tree
Hide file tree
Showing 8 changed files with 434 additions and 112 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ The version headers in this history reflect the versions of Apollo Server itself
- `apollo-server-lambda`: The handler returned by `createHandler` can now only be called as an async function returning a `Promise` (it no longer optionally accepts a callback as the third argument). All current Lambda Node runtimes support this invocation mode (so `exports.handler = server.createHandler()` will keep working without any changes), but if you've written your own handler which calls the handler returned by `createHandler` with a callback, you'll need to handle its `Promise` return value instead.
- `apollo-server-lambda`: This package is now implemented as a wrapper around `apollo-server-express`. `createHandler`'s argument now has different options: `expressGetMiddlewareOptions` which includes things like `cors` that is passed through to `apollo-server-express`'s `getMiddleware`, and `expressAppFromMiddleware` which lets you customize HTTP processing. The `context` function now receives an `express: { req, res }` option in addition to `event` and `context`.
- The `tracing` option to `new ApolloServer` has been removed, and the `apollo-server-tracing` package has been deprecated and is no longer being published. This package implemented an inefficient JSON format for execution traces returned on the `tracing` GraphQL response extension; it was only consumed by the deprecated `engineproxy` and Playground. If you really need this format, the old version of `apollo-server-tracing` should still work (`new ApolloServer({plugins: [require('apollo-server-tracing').plugin()]})`).
- The `cacheControl` option to `new ApolloServer` has been removed. The functionality provided by `cacheControl: true` or `cacheControl: {stripFormattedExtensions: false}` (which included a `cacheControl` extension in the GraphQL response, for use by the deprecated `engineproxy`) has been entirely removed. By default, Apollo Server continues to calculate an overall cache policy and to set the `Cache-Control` HTTP header, but this is now implemented directly inside `apollo-server-core` rather than a separate `apollo-cache-control` package (this package has been deprecated and is no longer being published). Tweaking cache control settings like `defaultMaxAge` is now done via the newly exported `ApolloServerPluginCacheControl` plugin rather than as a top-level constructor option. This follows the same pattern as the other built-in plugins like usage reporting. The `CacheHint` and `CacheScope` types are now exported from `apollo-server-types`. The `info.cacheControl.cacheHint` object now has additional methods `replace`, `restrict`, and `policyIfCacheable`, and its fields update when those methods or `setCacheHint` are called. These methods also exist on `requestContext.overallCachePolicy`, which is always defined and which should not be overwritten (use `replace` instead). There is also a new function `info.cacheControl.cacheHintFromType` available. `@cacheControl` directives on type extensions are no longer ignored.
- The `cacheControl` option to `new ApolloServer` has been removed. The functionality provided by `cacheControl: true` or `cacheControl: {stripFormattedExtensions: false}` (which included a `cacheControl` extension in the GraphQL response, for use by the deprecated `engineproxy`) has been entirely removed. By default, Apollo Server continues to calculate an overall cache policy and to set the `Cache-Control` HTTP header, but this is now implemented directly inside `apollo-server-core` rather than a separate `apollo-cache-control` package (this package has been deprecated and is no longer being published). Tweaking cache control settings like `defaultMaxAge` is now done via the newly exported `ApolloServerPluginCacheControl` plugin rather than as a top-level constructor option. This follows the same pattern as the other built-in plugins like usage reporting. The `CacheHint` and `CacheScope` types are now exported from `apollo-server-types`. The `info.cacheControl.cacheHint` object now has additional methods `replace`, `restrict`, and `policyIfCacheable`, and its fields update when those methods or `setCacheHint` are called. These methods also exist on `requestContext.overallCachePolicy`, which is always defined and which should not be overwritten (use `replace` instead). There is also a new function `info.cacheControl.cacheHintFromType` available. `@cacheControl` directives on type extensions are no longer ignored. Fields returning union types are now treated similarly to fields returning object and interface types (`@cacheControl` directives on the type are honored, the default `maxAge` is applied to them). New feature: `@cacheControl(inheritMaxAge: true)` when applied to a composite type or a field returning a composite type means that the default `maxAge` is not applied to that field (unless it is a root field).
- When using a non-serverless framework integration (Express, Fastify, Hapi, Koa, Micro, or Cloudflare), you now *must* `await server.start()` before attaching the server to your framework. (This method was introduced in v2.22 but was optional before Apollo Server 3.) This does not apply to the batteries-included `apollo-server` or to serverless framework integrations.
- Top-level exports have changed. E.g.,

Expand Down
47 changes: 40 additions & 7 deletions docs/source/performance/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ enum CacheControlScope {
directive @cacheControl(
maxAge: Int
scope: CacheControlScope
) on FIELD_DEFINITION | OBJECT | INTERFACE
inheritMaxAge: Boolean
) on FIELD_DEFINITION | OBJECT | INTERFACE | UNION
```

The `@cacheControl` directive accepts the following arguments:
Expand All @@ -55,6 +56,7 @@ The `@cacheControl` directive accepts the following arguments:
|------|-------------|
| `maxAge` | The maximum amount of time the field's cached value is valid, in seconds. The default value is `0`, but you can [set a different default](#setting-the-default-maxage). |
| `scope` | If `PRIVATE`, the field's value is specific to a single user. The default value is `PUBLIC`. See also [Identifying users for `PRIVATE` responses](#identifying-users-for-private-responses). |
| `inheritMaxAge` | If `true`, inherits the `maxAge` from its parent field. This means that non-root fields returning objects, interfaces, or unions that do not specify `maxAge` in some other way does not have the [default `maxAge`](#setting-the-default-maxage) applied. Do not combine this with `maxAge` in the same directive. |

Use `@cacheControl` for fields that should always be cached with the same settings. If caching settings might change at runtime, instead use the [dynamic method](#in-your-resolvers-dynamic).

Expand Down Expand Up @@ -137,20 +139,51 @@ The `setCacheHint` method accepts an object with the same fields as [the `@cache

The `cacheControl` object also has a `cacheHint` field which returns the field's current hint. This object also has a few other helpful methods, such as `info.cacheControl.cacheHint.restrict({ maxAge, scope })` which is similar to `setCacheHint` but it will never make `maxAge` larger or change `scope` from `PRIVATE` to `PUBLIC`. There is also a function `info.cacheControl.cacheHintFromType()` which takes an object type from a GraphQL AST and returns a cache hint which can be passed to `setCacheHint` or `restrict`; it may be useful for implementing resolvers that return unions or interfaces.

### Default `maxAge`
### Root and composite-type fields are not cachable by default

By default, the following schema fields have a `maxAge` of `0` (meaning their values are _not_ cached unless you specify otherwise):
The general philosophy behind `@cacheControl` is that we should only consider a response to be cachable if we have been told that each piece of it is cachable; we never assume that anything is cachable by default. However, we don't want you to have to specify cache hints for every single field in your entire schema. Ideally, you would specify a cache hint on every field whose resolver reads from a data source such as a database or REST API, based on how long you'd like to cache that particular read operation; fields whose resolvers just read in-memory data fetched by a parent resolver (including the default resolver) don't have a particularly interesting cache policy.

* All **root fields** (i.e., the fields of the `Query` and `Mutation` objects)
* Fields that return an object or interface type
So we follow the following heuristic. By default, the following schema fields have a `maxAge` of `0` (meaning their values are _not_ cached unless you specify otherwise):

Scalar fields inherit their default cache behavior (including `maxAge`) from their parent object type. This enables you to define cache behavior for _most_ scalars at the [type level](#type-level-definitions), while overriding that behavior in individual cases at the [field level](#field-level-definitions).
* All **root fields** (i.e., the fields of the `Query` and `Mutation` objects). Because their parent objects have no data, we guess that they are likely to be doing some sort of non-trivial read operation.
* Fields that **return a composite type** (object, interface, or union), possibly wrapped inside one or more layers of "list of" and "non-null". Our heuristic assumes that these fields (fields with their own sub-fields) are likely to involve a non-trivial read operation, whereas scalar fields are more likely to contain data read in a parent resolver.

Non-root scalar fields inherit their default cache behavior (including `maxAge`) from their parent object type. This enables you to define cache behavior for _most_ scalars at the [type level](#type-level-definitions), while overriding that behavior in individual cases at the [field level](#field-level-definitions).

As a result of these defaults, **no schema fields are cached by default**.

These heuristics aren't always correct. If a (non-root) scalar field does actually perform a read operation with a different cachability from its parent, you can specify a cache hint on it to override the default assumption that non-root scalar fields inherit their parent's cache policy. And if a field returns an object type just as a way of organizing data and not because it's performing a read operation, you can set `@cacheControl(inheritMaxAge: true)` on the field or its return type; in this case, the default `maxAge` of 0 will not be applied. (Setting `@cacheControl(inheritMaxAge: true)` on a root field has no effect. `inheritMaxAge: true` cannot be specified via `info.cacheControl`.) Note that if you specify `@cacheControl(inheritMaxAge: true)` on a type, you may still specify `maxAge` on a field returning that type, which will take effect; and you can specify `maxAge` via `info.cacheControl` even on fields/types with `inheritMaxAge: true`.

For example, given the following schema:

```graphql
type Query {
foo: Foo
cachedFoo: Foo @cacheControl(maxAge: 60)
intermediate: Intermediate @cacheControl(maxAge: 40)
}
type Foo {
inheritingField: String
cachedField: String @cacheControl(maxAge: 30)
}
type Intermediate {
foo: Foo @cacheControl(inheritMaxAge: true)
}
```

Then the following queries will have the given `maxAge` values:

| Query | `maxAge` | Explanation |
|-------|----------|-------------|
|`{foo{cachedField}}`|0|is a root field (and an object-typed field) with no `maxAge` and it does not set `maxAge` dynamically, so it defaults to 0. It does not matter that `cachedField` has a `maxAge`.|
|`{cachedFoo{inheritingField}}`|60|`cachedFoo` has a `maxAge` of 60; this means `inheritingField` can follow the normal scalar field rules and not affect `maxAge`.|
|`{cachedFoo{cachedField}}`|30|`cachedFoo` has a `maxAge` of 60 and `cachedField` has a `maxAge` of 30; we take the most restrictive value, 30.|
|`{intermediate{foo{inheritingField}}}`|40|`intermediate` sets its `maxAge` to 40. `Intermediate.foo` has `inheritMaxAge` so it does not affect the cache policy. `Foo.uncachedField` is a scalar so it inherits the `maxAge` from its parent, and thus indirectly from its grandparent: 40.|


#### Setting the default `maxAge`

You can set a default `maxAge` (instead of `0`) that's applied to every field that doesn't specify a different value.
You can set a default `maxAge` that's applied to the fields that would otherwise receive the default `maxAge` of `0`. That is: fields that don't explicitly specify `maxAge` via `@cacheControl` on the field or the type they return or via `info.cacheControl`, and which are either root fields, or fields that return a composite (object, interface, or union) type and do not have `@cacheControl(inheritMaxAge: true)`.

> You should identify and address all exceptions to your default `maxAge` before you enable it in production, but this is a great way to get started with cache control.

Expand Down
48 changes: 24 additions & 24 deletions package-lock.json

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

Loading

0 comments on commit fa87f2c

Please sign in to comment.