From 1352b6ddc9cefe84378b412f47619372f53af967 Mon Sep 17 00:00:00 2001 From: David Glasser Date: Thu, 27 May 2021 14:51:30 -0700 Subject: [PATCH] Implement `@cacheControl(noDefaultMaxAge: true)` 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. --- docs/source/performance/caching.md | 33 ++- .../__tests__/cacheControlDirective.test.ts | 243 +++++++++++++++++- .../__tests__/cacheControlSupport.ts | 1 + .../__tests__/collectCacheControlHints.ts | 18 +- .../src/plugin/cacheControl/index.ts | 137 ++++++++-- packages/apollo-server-types/src/index.ts | 4 + 6 files changed, 402 insertions(+), 34 deletions(-) diff --git a/docs/source/performance/caching.md b/docs/source/performance/caching.md index 43ea2c0970e..4d0a37eaf1e 100644 --- a/docs/source/performance/caching.md +++ b/docs/source/performance/caching.md @@ -46,6 +46,7 @@ enum CacheControlScope { directive @cacheControl( maxAge: Int scope: CacheControlScope + noDefaultMaxAge: Boolean ) on FIELD_DEFINITION | OBJECT | INTERFACE ``` @@ -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). @@ -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. diff --git a/packages/apollo-server-core/src/plugin/cacheControl/__tests__/cacheControlDirective.test.ts b/packages/apollo-server-core/src/plugin/cacheControl/__tests__/cacheControlDirective.test.ts index 57cf991d7de..4b82eac3883 100644 --- a/packages/apollo-server-core/src/plugin/cacheControl/__tests__/cacheControlDirective.test.ts +++ b/packages/apollo-server-core/src/plugin/cacheControl/__tests__/cacheControlDirective.test.ts @@ -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 () => { @@ -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); + }); }); diff --git a/packages/apollo-server-core/src/plugin/cacheControl/__tests__/cacheControlSupport.ts b/packages/apollo-server-core/src/plugin/cacheControl/__tests__/cacheControlSupport.ts index fe550c07538..e1ff6464a11 100644 --- a/packages/apollo-server-core/src/plugin/cacheControl/__tests__/cacheControlSupport.ts +++ b/packages/apollo-server-core/src/plugin/cacheControl/__tests__/cacheControlSupport.ts @@ -15,6 +15,7 @@ export function augmentTypeDefsWithCacheControlSupport(typeDefs: string) { directive @cacheControl( maxAge: Int scope: CacheControlScope + noDefaultMaxAge: Boolean ) on FIELD_DEFINITION | OBJECT | INTERFACE ` + typeDefs ); diff --git a/packages/apollo-server-core/src/plugin/cacheControl/__tests__/collectCacheControlHints.ts b/packages/apollo-server-core/src/plugin/cacheControl/__tests__/collectCacheControlHints.ts index 3e91c130344..d6d017eeaf2 100644 --- a/packages/apollo-server-core/src/plugin/cacheControl/__tests__/collectCacheControlHints.ts +++ b/packages/apollo-server-core/src/plugin/cacheControl/__tests__/collectCacheControlHints.ts @@ -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> { +): Promise<{ + hints: Map; + policyIfCacheable: Required | null; +}> { const cacheHints = new Map(); const pluginInstance = ApolloServerPluginCacheControl({ ...options, @@ -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 +): Promise> { + return (await collectCacheControlHintsAndPolicyIfCacheable(...args)).hints; } diff --git a/packages/apollo-server-core/src/plugin/cacheControl/index.ts b/packages/apollo-server-core/src/plugin/cacheControl/index.ts index 9dc26373be5..68b9b169e2f 100644 --- a/packages/apollo-server-core/src/plugin/cacheControl/index.ts +++ b/packages/apollo-server-core/src/plugin/cacheControl/index.ts @@ -1,4 +1,8 @@ -import type { CacheHint, CachePolicy } from 'apollo-server-types'; +import type { + CacheFieldHint, + CacheHint, + CachePolicy, +} from 'apollo-server-types'; import { CacheScope } from 'apollo-server-types'; import { DirectiveNode, @@ -7,6 +11,7 @@ import { GraphQLField, GraphQLInterfaceType, GraphQLObjectType, + ResponsePath, responsePathAsArray, } from 'graphql'; import { newCachePolicy } from '../../cachePolicy'; @@ -48,7 +53,7 @@ export function ApolloServerPluginCacheControl( const typeCacheHintCache = new Map(); const fieldCacheHintCache = new Map< GraphQLField, - CacheHint + CacheFieldHint >(); function memoizedCacheHintFromType(t: GraphQLCompositeType): CacheHint { @@ -63,7 +68,7 @@ export function ApolloServerPluginCacheControl( function memoizedCacheHintFromField( field: GraphQLField, - ): CacheHint { + ): CacheFieldHint { const cachedHint = fieldCacheHintCache.get(field); if (cachedHint) { return cachedHint; @@ -83,6 +88,10 @@ export function ApolloServerPluginCacheControl( const calculateHttpHeaders = options.calculateHttpHeaders ?? true; const { __testing__cacheHints } = options; + const effectiveRootPaths = new Set(); + // The actual root is an effective root path. + effectiveRootPaths.add(undefined); + return { executionDidStart: () => { // Did something set the overall cache policy before we've even @@ -127,25 +136,50 @@ export function ApolloServerPluginCacheControl( // Look for hints on the field itself (on its parent type), taking // precedence over previously calculated hints. - fieldPolicy.replace( - memoizedCacheHintFromField( - info.parentType.getFields()[info.fieldName], - ), + const fieldHint = memoizedCacheHintFromField( + info.parentType.getFields()[info.fieldName], ); - // If this resolver returns an object or is a root field and we haven't - // seen an explicit maxAge hint, set the maxAge to 0 (uncached) or the - // default if specified in the constructor. (Non-object fields by - // default are assumed to inherit their cacheability from their parents. - // But on the other hand, while root non-object fields can get explicit - // hints from their definition on the Query/Mutation object, if that - // doesn't exist then there's no parent field that would assign the - // default maxAge, so we do it here.) + let noDefaultMaxAge = false; if ( + fieldHint.noDefaultMaxAge && + fieldPolicy.maxAge === undefined + ) { + noDefaultMaxAge = true; + // If this is field on root, or on a series of other fields + // that have no maxAge due to defaultMaxAge, then remember + // that this too is an effective root path. + if (effectiveRootPaths.has(skipListIndexes(info.path.prev))) { + effectiveRootPaths.add(info.path); + } + // Handle `@cacheControl(noDefaultMaxAge: true, scope: PRIVATE)`. + if (fieldHint.scope) { + fieldPolicy.replace({ scope: fieldHint.scope }); + } + } else { + fieldPolicy.replace(fieldHint); + } + + // If this resolver returns an object or is a root field and we + // haven't seen an explicit maxAge hint, set the maxAge to 0 + // (uncached) or the default if specified in the constructor. + // (Non-object fields by default are assumed to inherit their + // cacheability from their parents. But on the other hand, while + // root non-object fields can get explicit hints from their + // definition on the Query/Mutation object, if that doesn't exist + // then there's no parent field that would assign the default + // maxAge, so we do it here.) + // + // You can disable this on a field by writing + // `@cacheControl(noDefaultMaxAge: true)` on it. If you do this, + // then its children will be treated like root paths, since there + // is no parent maxAge to inherit. + if ( + fieldPolicy.maxAge === undefined && + !noDefaultMaxAge && (targetType instanceof GraphQLObjectType || targetType instanceof GraphQLInterfaceType || - !info.path.prev) && - fieldPolicy.maxAge === undefined + effectiveRootPaths.has(skipListIndexes(info.path.prev))) ) { fieldPolicy.restrict({ maxAge: defaultMaxAge }); } @@ -162,6 +196,14 @@ export function ApolloServerPluginCacheControl( // once, we don't need to "undo" the effect on overallCachePolicy // of a static hint that gets refined by a dynamic hint. return () => { + // If the field had `@cacheControl(noDefaultMaxAge: true)` but + // then we dynamically set a hint, then it's no longer an + // effective root path, and its scalar children get to act + // like normal scalars by inheriting its maxAge. + if (noDefaultMaxAge && fieldPolicy.maxAge !== undefined) { + effectiveRootPaths.delete(info.path); + } + if (__testing__cacheHints && isRestricted(fieldPolicy)) { const path = responsePathAsArray(info.path).join('.'); if (__testing__cacheHints.has(path)) { @@ -209,7 +251,8 @@ export function ApolloServerPluginCacheControl( function cacheHintFromDirectives( directives: ReadonlyArray | undefined, -): CacheHint | undefined { + allowNoDefaultMaxAge: boolean, +): CacheFieldHint | undefined { if (!directives) return undefined; const cacheControlDirective = directives.find( @@ -225,6 +268,27 @@ function cacheHintFromDirectives( const scopeArgument = cacheControlDirective.arguments.find( (argument) => argument.name.value === 'scope', ); + const noDefaultMaxAgeArgument = cacheControlDirective.arguments.find( + (argument) => argument.name.value === 'noDefaultMaxAge', + ); + + const scope = + scopeArgument && + scopeArgument.value && + scopeArgument.value.kind === 'EnumValue' + ? (scopeArgument.value.value as CacheScope) + : undefined; + + if ( + allowNoDefaultMaxAge && + noDefaultMaxAgeArgument && + noDefaultMaxAgeArgument.value && + noDefaultMaxAgeArgument.value.kind === 'BooleanValue' && + noDefaultMaxAgeArgument.value.value + ) { + // We ignore maxAge if it is also specified. + return { noDefaultMaxAge: true, scope }; + } // TODO: Add proper typechecking of arguments return { @@ -234,25 +298,20 @@ function cacheHintFromDirectives( maxAgeArgument.value.kind === 'IntValue' ? parseInt(maxAgeArgument.value.value) : undefined, - scope: - scopeArgument && - scopeArgument.value && - scopeArgument.value.kind === 'EnumValue' - ? (scopeArgument.value.value as CacheScope) - : undefined, + scope, }; } function cacheHintFromType(t: GraphQLCompositeType): CacheHint { if (t.astNode) { - const hint = cacheHintFromDirectives(t.astNode.directives); + const hint = cacheHintFromDirectives(t.astNode.directives, false); if (hint) { return hint; } } if (t.extensionASTNodes) { for (const node of t.extensionASTNodes) { - const hint = cacheHintFromDirectives(node.directives); + const hint = cacheHintFromDirectives(node.directives, false); if (hint) { return hint; } @@ -261,9 +320,11 @@ function cacheHintFromType(t: GraphQLCompositeType): CacheHint { return {}; } -function cacheHintFromField(field: GraphQLField): CacheHint { +function cacheHintFromField( + field: GraphQLField, +): CacheFieldHint { if (field.astNode) { - const hint = cacheHintFromDirectives(field.astNode.directives); + const hint = cacheHintFromDirectives(field.astNode.directives, true); if (hint) { return hint; } @@ -275,6 +336,26 @@ function isRestricted(hint: CacheHint) { return hint.maxAge !== undefined || hint.scope !== undefined; } +// Returns the closest ancestor of `path` that doesn't end in a list index. +function skipListIndexes( + path: ResponsePath | undefined, +): ResponsePath | undefined { + if (!path) { + return undefined; + } + if (typeof path.key === 'string') { + return path; + } + if (!path.prev) { + throw Error( + `First element of path ${responsePathAsArray(path).join( + '.', + )} is an array?`, + ); + } + return skipListIndexes(path.prev); +} + // This plugin does nothing, but it ensures that ApolloServer won't try // to add a default ApolloServerPluginCacheControl. export function ApolloServerPluginCacheControlDisabled(): InternalApolloServerPlugin { diff --git a/packages/apollo-server-types/src/index.ts b/packages/apollo-server-types/src/index.ts index 6e5e1c6c243..e3ae2130c7e 100644 --- a/packages/apollo-server-types/src/index.ts +++ b/packages/apollo-server-types/src/index.ts @@ -244,6 +244,10 @@ export interface CacheHint { scope?: CacheScope; } +export interface CacheFieldHint extends CacheHint { + noDefaultMaxAge?: boolean; +} + export enum CacheScope { Public = 'PUBLIC', Private = 'PRIVATE',