Skip to content

Commit

Permalink
Implement @cacheControl(noDefaultMaxAge: true)
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(noDefaultMaxAge: true)`. If this is specified on a root
or object-returning or interface-returning field that 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 scalar fields all of whose ancestors have no `maxAge` due to this
feature are now treated similarly to scalar root fields.

One use case for this could be in federation: `buildFederatedSchema`
could add this directive to all `@external` fields.

This addresses concerns from #4162 and #3559.
  • Loading branch information
glasser committed Jun 2, 2021
1 parent 8bc07a0 commit aa36fc9
Show file tree
Hide file tree
Showing 5 changed files with 410 additions and 36 deletions.
33 changes: 32 additions & 1 deletion docs/source/performance/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ enum CacheControlScope {
directive @cacheControl(
maxAge: Int
scope: CacheControlScope
noDefaultMaxAge: Boolean
) on FIELD_DEFINITION | OBJECT | INTERFACE
```

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). |
| `noDefaultMaxAge` | Do not apply the [default `maxAge`](#default-maxage) to this field, but do apply it to its children. |

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 @@ -143,14 +145,43 @@ By default, the following schema fields have a `maxAge` of `0` (meaning their va

* All **root fields** (i.e., the fields of the `Query` and `Mutation` objects)
* Fields that return an object or interface type
* Scalar fields where no ancestor field in the operation had specified `maxAge`, because they add had `@cacheControl(noDefaultMaxAge: true)` and did not set a `maxAge` in a different way.

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**.

If you don't want a field that falls into one of those categories to affect the response's overall cache policy, you can set `@cacheControl(noDefaultMaxAge: true)` on the field. (This cannot be specified on types or via `setCacheHint`.) In this case the field is treated similarly to a scalar field: if the field's type doesn't have `@cacheControl(maxAge)` set on it and the field's resolver doesn't call `info.setCacheHint({maxAge})`, the overall cache policy will not be affected by this field. When this happens to a non-scalar field, children that are scalars are treated as if they are root fields. For example, given the following schema:

```graphql
type Query {
foo(setMaxAgeDynamically: Boolean): Foo @cacheControl(noDefaultMaxAge: true)
defaultFoo: Foo
intermediate: Intermediate @cacheControl(maxAge: 40)
}
type Foo {
uncachedField: String
cachedField: String @cacheControl(maxAge: 30)
}
type Intermediate {
foo: Foo @cacheControl(noDefaultMaxAge: true)
}
```

Assume that `Query.foo` calls `info.setCacheHint({maxAge: 60})` if its `setMaxAgeDynamically` argument is provided. Then the following queries will have the given `maxAge` values:

| Query | `maxAge` | Explanation |
|-------|----------|-------------|
|`{foo{cachedField}}`|30|`foo` has `noDefaultMaxAge` so it does not affect the policy; `cachedField` sets it to 30.|
|`{foo{uncachedField}}`|0|`foo` has `noDefaultMaxAge` so it does not affect the policy; `uncachedField` is the child of a field with no `maxAge` so it defaults to `maxAge` 0.|
|`{defaultFoo{cachedField}}`|0|`foo` is a root field (and an object-typed field) with no `maxAge` or `noDefaultMaxAge` so it defaults to 0.|
|`{foo(setMaxAgeDynamically: true){uncachedField}}`|60|`foo` sets its `maxAge` to 60 dynamically; this means `uncachedField` can follow the normal scalar field rules and not affect `maxAge`.|
|`{intermediate{foo{uncachedField}}}`|40|`intermediate` sets its `maxAge` to 40. `Intermediate.foo` has `noDefaultMaxAge` so it does not affect the cache policy. `Foo.uncachedField` is a scalar; while its parent field (`foo`) has `noDefaultMaxAge`, its grandparent does have a `maxAge`, so it is treated like a normal scalar field rather than the special case of a root-like scalar field.|


#### 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` (instead of `0`) that's applied to every root or object-typed or interface-typed field that doesn't specify a different value and doesn't specify `noDefaultMaxAge`.

> 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
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { buildSchemaWithCacheControlSupport } from './cacheControlSupport';
import {
buildSchemaWithCacheControlSupport,
makeExecutableSchemaWithCacheControlSupport,
} from './cacheControlSupport';

import { CacheScope } from 'apollo-server-types';
import { collectCacheControlHints } from './collectCacheControlHints';
import {
collectCacheControlHints,
collectCacheControlHintsAndPolicyIfCacheable,
} from './collectCacheControlHints';

describe('@cacheControl directives', () => {
it('should set maxAge: 0 and no scope for a field without cache hints', async () => {
Expand Down Expand Up @@ -270,4 +276,237 @@ describe('@cacheControl directives', () => {
new Map([['droid', { maxAge: 60, scope: CacheScope.Private }]]),
);
});

it('noDefaultMaxAge works', async () => {
const schema = makeExecutableSchemaWithCacheControlSupport({
typeDefs: `
type Query {
droid(setMaxAgeDynamically: Boolean): Droid @cacheControl(noDefaultMaxAge: true)
droids: [Droid] @cacheControl(noDefaultMaxAge: true)
}
type Droid {
uncachedField: String
cachedField: String @cacheControl(maxAge: 30)
}
`,
resolvers: {
Query: {
droid: (
_parent,
{ setMaxAgeDynamically },
_context,
{ cacheControl },
) => {
if (setMaxAgeDynamically) {
cacheControl.setCacheHint({ maxAge: 60 });
}
return {};
},
droids: () => [{}, {}],
},
},
});

{
const { hints, policyIfCacheable } =
await collectCacheControlHintsAndPolicyIfCacheable(
schema,
'{ droid { cachedField } }',
{},
);

expect(hints).toStrictEqual(
new Map([['droid.cachedField', { maxAge: 30 }]]),
);
expect(policyIfCacheable).toStrictEqual({
maxAge: 30,
scope: CacheScope.Public,
});
}

{
const { hints, policyIfCacheable } =
await collectCacheControlHintsAndPolicyIfCacheable(
schema,
'{ droid { uncachedField cachedField } }',
{},
);

expect(hints).toStrictEqual(
new Map([
['droid.cachedField', { maxAge: 30 }],
['droid.uncachedField', { maxAge: 0 }],
]),
);
expect(policyIfCacheable).toBeNull();
}

{
const { hints, policyIfCacheable } =
await collectCacheControlHintsAndPolicyIfCacheable(
schema,
'{ droid(setMaxAgeDynamically: true) { uncachedField cachedField } }',
{},
);

expect(hints).toStrictEqual(
new Map([
['droid', { maxAge: 60 }],
['droid.cachedField', { maxAge: 30 }],
// We do *not* get a hint on uncachedField because it's a scalar whose
// parent has a hint, even though that hint was a dynamic hint layered
// on top of noDefaultMaxAge.
]),
);
expect(policyIfCacheable).toStrictEqual({
maxAge: 30,
scope: CacheScope.Public,
});
}

{
const { hints, policyIfCacheable } =
await collectCacheControlHintsAndPolicyIfCacheable(
schema,
'{ droids { uncachedField cachedField } }',
{},
);

expect(hints).toStrictEqual(
new Map([
['droids.0.cachedField', { maxAge: 30 }],
['droids.0.uncachedField', { maxAge: 0 }],
['droids.1.cachedField', { maxAge: 30 }],
['droids.1.uncachedField', { maxAge: 0 }],
]),
);
expect(policyIfCacheable).toBeNull();
}
});

it('noDefaultMaxAge docs examples', async () => {
const schema = makeExecutableSchemaWithCacheControlSupport({
typeDefs: `
type Query {
foo(setMaxAgeDynamically: Boolean): Foo @cacheControl(noDefaultMaxAge: true)
intermediate: Intermediate @cacheControl(maxAge: 40)
defaultFoo: Foo
}
type Foo {
uncachedField: String
cachedField: String @cacheControl(maxAge: 30)
}
type Intermediate {
foo: Foo @cacheControl(noDefaultMaxAge: true)
}
`,
resolvers: {
Query: {
foo: (
_parent,
{ setMaxAgeDynamically },
_context,
{ cacheControl },
) => {
if (setMaxAgeDynamically) {
cacheControl.setCacheHint({ maxAge: 60 });
}
return {};
},
defaultFoo: () => ({}),
},
},
});

async function expectMaxAge(operation: string, maxAge: number | undefined) {
expect(
(
await collectCacheControlHintsAndPolicyIfCacheable(
schema,
operation,
{},
)
).policyIfCacheable?.maxAge,
).toBe(maxAge);
}

await expectMaxAge('{foo{cachedField}}', 30);
await expectMaxAge('{foo{uncachedField}}', undefined);
await expectMaxAge('{defaultFoo{cachedField}}', undefined);
await expectMaxAge('{foo(setMaxAgeDynamically:true){uncachedField}}', 60);
await expectMaxAge('{intermediate{foo{uncachedField}}}', 40);
});

it('noDefaultMaxAge can be combined with scope', async () => {
const schema = makeExecutableSchemaWithCacheControlSupport({
typeDefs: `
type Query {
foo: Foo @cacheControl(noDefaultMaxAge: true, scope: PRIVATE)
}
type Foo {
bar: String @cacheControl(maxAge: 5)
}
`,
resolvers: { Query: { foo: () => ({}) } },
});

const { hints, policyIfCacheable } =
await collectCacheControlHintsAndPolicyIfCacheable(
schema,
'{ foo { bar } }',
{},
);

expect(hints).toStrictEqual(
new Map([
['foo', { scope: CacheScope.Private }],
['foo.bar', { maxAge: 5 }],
]),
);
expect(policyIfCacheable).toStrictEqual({
maxAge: 5,
scope: CacheScope.Private,
});
});

it('scalars can inherit from grandparents', async () => {
const schema = makeExecutableSchemaWithCacheControlSupport({
typeDefs: `
type Query {
foo: Foo @cacheControl(maxAge: 5)
}
type Foo {
bar: Bar @cacheControl(noDefaultMaxAge: true)
defaultBar: Bar
}
type Bar {
scalar: String
cachedScalar: String @cacheControl(maxAge: 2)
}
`,
resolvers: {
Query: { foo: () => ({}) },
Foo: { bar: () => ({}), defaultBar: () => ({}) },
},
});

async function expectMaxAge(operation: string, maxAge: number | undefined) {
expect(
(
await collectCacheControlHintsAndPolicyIfCacheable(
schema,
operation,
{},
)
).policyIfCacheable?.maxAge,
).toBe(maxAge);
}

await expectMaxAge('{foo{defaultBar{scalar}}}', undefined);
await expectMaxAge('{foo{defaultBar{cachedScalar}}}', undefined);
await expectMaxAge('{foo{bar{scalar}}}', 5);
await expectMaxAge('{foo{bar{cachedScalar}}}', 2);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export function augmentTypeDefsWithCacheControlSupport(typeDefs: string) {
directive @cacheControl(
maxAge: Int
scope: CacheControlScope
noDefaultMaxAge: Boolean
) on FIELD_DEFINITION | OBJECT | INTERFACE
` + typeDefs
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@ import {
} from '../';
import pluginTestHarness from '../../../utils/pluginTestHarness';

export async function collectCacheControlHints(
export async function collectCacheControlHintsAndPolicyIfCacheable(
schema: GraphQLSchema,
source: string,
options: ApolloServerPluginCacheControlOptions = {},
): Promise<Map<string, CacheHint>> {
): Promise<{
hints: Map<string, CacheHint>;
policyIfCacheable: Required<CacheHint> | null;
}> {
const cacheHints = new Map<string, CacheHint>();
const pluginInstance = ApolloServerPluginCacheControl({
...options,
Expand All @@ -34,5 +37,14 @@ export async function collectCacheControlHints(

expect(requestContext.response.errors).toBeUndefined();

return cacheHints;
return {
hints: cacheHints,
policyIfCacheable: requestContext.overallCachePolicy.policyIfCacheable(),
};
}

export async function collectCacheControlHints(
...args: Parameters<typeof collectCacheControlHintsAndPolicyIfCacheable>
): Promise<Map<string, CacheHint>> {
return (await collectCacheControlHintsAndPolicyIfCacheable(...args)).hints;
}
Loading

0 comments on commit aa36fc9

Please sign in to comment.