Skip to content

Commit 8bc07a0

Browse files
authored
Make cache policy more useful (#5248)
Previously, all the logic around updating the cache policy with new hints was hardcoded inside the cache control plugin. Also, `overallCachePolicy` undefined was used to represent two distinct states: "we don't know anything about cache policy yet" and "definitely uncacheable". There was a `PolicyUpdater` class that was part of the CC plugin that handled the logic of updating a policy. After this PR, `overallCachePolicy` is always defined. The former case is `maxAge === undefined` and the latter case is `maxAge === 0`. This object also has some helpful methods that don't exist on `CacheHint`: - `restrict` and `replace` applies a `CacheHint` to it; the former can only make the policy more restrictive, whereas the latter overrides the current values of whatever fields are defined in its argument - `cacheablePolicy` either returns null (if we have no information about cacheability or if we know it's not cacheable) or returns a `CacheHint` whose maxAge is positive and whose scope is defined. These make `PolicyUpdater` unnecessary. Inside a resolver, `info.cacheControl.cacheHint` also has these methods! So while you can still do `info.cacheControl.setCacheHint(hint)` (which is identical to `info.cacheControl.cacheHint.replace(hint)`), you also have the ability to do `info.cacheControl.cacheHint.restrict(hint)` if that's what you'd like to do. Additionally, the `maxAge` and `scope` fields on `info.cacheControl.cacheHint` now update if you call `restrict`, `replace`, or `setCacheHint`. Also, resolvers now have access to `info.cacheControl.cacheHintFromType` which it can use to extract cache hints from the schema. (This may be helpful for a resolver that returns an abstract type, such as Federation's `Query._entities`). Also, `extend type @CacheControl(...)` directives are now honored instead of silently ignored. There's also a cache for directive parsing instead of examining the AST in detail every single time a field is resolved. This change is mostly backwards-compatible, unless you accessed `requestContext.overallCachePolicy` directly (eg from a plugin). In that case: - If you relied on `requestContext.overallCachePolicy` being undefined to mean "not cacheable", you should instead see if `requestContext.overallCachePolicy.policyIfCacheable()` is null. - If you assigned a `CacheHint` to `requestContext.overallCachePolicy`, you should pass it to `requestContext.overallCachePolicy.replace()` instead.
1 parent 88a058d commit 8bc07a0

File tree

14 files changed

+378
-211
lines changed

14 files changed

+378
-211
lines changed

CHANGELOG.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ The version headers in this history reflect the versions of Apollo Server itself
2929
- `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.
3030
- `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`.
3131
- 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()]})`).
32-
- 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`.
32+
- 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.
3333
- 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.
3434
- Top-level exports have changed. E.g.,
3535

docs/source/performance/caching.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ const resolvers = {
135135

136136
The `setCacheHint` method accepts an object with the same fields as [the `@cacheControl` directive](#in-your-schema-static).
137137

138-
The `cacheControl` object also has a `cacheHint` field which returns the hint set in the schema, if any. (Calling `info.cacheControl.setCacheHint` does not update `info.cacheControl.cacheHint`.)
138+
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.
139139

140140
### Default `maxAge`
141141

packages/apollo-server-core/src/ApolloServer.ts

+2
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ import {
5555
ApolloServerPluginFrontendGraphQLPlayground,
5656
} from './plugin';
5757
import { InternalPluginId, pluginIsInternal } from './internalPlugin';
58+
import { newCachePolicy } from './cachePolicy';
5859

5960
const NoIntrospection = (context: ValidationContext) => ({
6061
Field(node: FieldDefinitionNode) {
@@ -912,6 +913,7 @@ export class ApolloServerBase {
912913
},
913914
},
914915
debug: options.debug,
916+
overallCachePolicy: newCachePolicy(),
915917
};
916918

917919
return processGraphQLRequest(options, requestCtx);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { CachePolicy, CacheScope } from 'apollo-server-types';
2+
import { newCachePolicy } from '../cachePolicy';
3+
4+
describe('newCachePolicy', () => {
5+
let cachePolicy: CachePolicy;
6+
beforeEach(() => {
7+
cachePolicy = newCachePolicy();
8+
});
9+
10+
it('starts uncacheable', () => {
11+
expect(cachePolicy.maxAge).toBeUndefined();
12+
expect(cachePolicy.scope).toBeUndefined();
13+
});
14+
15+
it('restricting maxAge positive makes restricted', () => {
16+
cachePolicy.restrict({ maxAge: 10 });
17+
});
18+
19+
it('restricting maxAge 0 makes restricted', () => {
20+
cachePolicy.restrict({ maxAge: 0 });
21+
});
22+
23+
it('restricting scope to private makes restricted', () => {
24+
cachePolicy.restrict({ scope: CacheScope.Private });
25+
});
26+
27+
it('returns lowest max age value', () => {
28+
cachePolicy.restrict({ maxAge: 10 });
29+
cachePolicy.restrict({ maxAge: 20 });
30+
31+
expect(cachePolicy.maxAge).toBe(10);
32+
});
33+
34+
it('returns lowest max age value in other order', () => {
35+
cachePolicy.restrict({ maxAge: 20 });
36+
cachePolicy.restrict({ maxAge: 10 });
37+
38+
expect(cachePolicy.maxAge).toBe(10);
39+
});
40+
41+
it('maxAge 0 if any cache hint has a maxAge of 0', () => {
42+
cachePolicy.restrict({ maxAge: 120 });
43+
cachePolicy.restrict({ maxAge: 0 });
44+
cachePolicy.restrict({ maxAge: 20 });
45+
46+
expect(cachePolicy.maxAge).toBe(0);
47+
});
48+
49+
it('returns undefined if first cache hint has a maxAge of 0', () => {
50+
cachePolicy.restrict({ maxAge: 0 });
51+
cachePolicy.restrict({ maxAge: 20 });
52+
53+
expect(cachePolicy.maxAge).toBe(0);
54+
});
55+
56+
it('only restricting maxAge keeps scope undefined', () => {
57+
cachePolicy.restrict({ maxAge: 10 });
58+
59+
expect(cachePolicy.scope).toBeUndefined();
60+
});
61+
62+
it('returns PRIVATE scope if any cache hint has PRIVATE scope', () => {
63+
cachePolicy.restrict({
64+
maxAge: 10,
65+
scope: CacheScope.Public,
66+
});
67+
cachePolicy.restrict({
68+
maxAge: 10,
69+
scope: CacheScope.Private,
70+
});
71+
72+
expect(cachePolicy).toHaveProperty('scope', CacheScope.Private);
73+
});
74+
75+
it('policyIfCacheable', () => {
76+
expect(cachePolicy.policyIfCacheable()).toBeNull();
77+
78+
cachePolicy.restrict({ scope: CacheScope.Private });
79+
expect(cachePolicy.scope).toBe(CacheScope.Private);
80+
expect(cachePolicy.policyIfCacheable()).toBeNull();
81+
82+
cachePolicy.restrict({ maxAge: 10 });
83+
expect(cachePolicy).toMatchObject({
84+
maxAge: 10,
85+
scope: CacheScope.Private,
86+
});
87+
expect(cachePolicy.policyIfCacheable()).toStrictEqual({
88+
maxAge: 10,
89+
scope: CacheScope.Private,
90+
});
91+
92+
cachePolicy.restrict({ maxAge: 0 });
93+
expect(cachePolicy).toMatchObject({
94+
maxAge: 0,
95+
scope: CacheScope.Private,
96+
});
97+
expect(cachePolicy.policyIfCacheable()).toBeNull();
98+
});
99+
100+
it('replace', () => {
101+
cachePolicy.restrict({ maxAge: 10, scope: CacheScope.Private });
102+
cachePolicy.replace({ maxAge: 20, scope: CacheScope.Public });
103+
104+
expect(cachePolicy).toMatchObject({
105+
maxAge: 20,
106+
scope: CacheScope.Public,
107+
});
108+
});
109+
});

packages/apollo-server-core/src/__tests__/runQuery.test.ts

+2
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
} from 'apollo-server-plugin-base';
3030
import { InMemoryLRUCache } from 'apollo-server-caching';
3131
import { generateSchemaHash } from "../utils/schemaHash";
32+
import { newCachePolicy } from '../cachePolicy';
3233

3334
// This is a temporary kludge to ensure we preserve runQuery behavior with the
3435
// GraphQLRequestProcessor refactoring.
@@ -57,6 +58,7 @@ function runQuery(
5758
context: options.context || {},
5859
debug: options.debug,
5960
cache: {} as any,
61+
overallCachePolicy: newCachePolicy(),
6062
...requestContextExtra,
6163
});
6264
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { CacheHint, CachePolicy, CacheScope } from 'apollo-server-types';
2+
3+
export function newCachePolicy(): CachePolicy {
4+
return {
5+
maxAge: undefined,
6+
scope: undefined,
7+
restrict(hint: CacheHint) {
8+
if (
9+
hint.maxAge !== undefined &&
10+
(this.maxAge === undefined || hint.maxAge < this.maxAge)
11+
) {
12+
this.maxAge = hint.maxAge;
13+
}
14+
if (hint.scope !== undefined && this.scope !== CacheScope.Private) {
15+
this.scope = hint.scope;
16+
}
17+
},
18+
replace(hint: CacheHint) {
19+
if (hint.maxAge !== undefined) {
20+
this.maxAge = hint.maxAge;
21+
}
22+
if (hint.scope !== undefined) {
23+
this.scope = hint.scope;
24+
}
25+
},
26+
policyIfCacheable() {
27+
if (this.maxAge === undefined || this.maxAge === 0) {
28+
return null;
29+
}
30+
return { maxAge: this.maxAge, scope: this.scope ?? CacheScope.Public };
31+
},
32+
};
33+
}

packages/apollo-server-core/src/plugin/cacheControl/__tests__/cacheControlDirective.test.ts

+29
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,35 @@ describe('@cacheControl directives', () => {
130130
expect(hints).toStrictEqual(new Map([['droid', { maxAge: 60 }]]));
131131
});
132132

133+
it('should set the specified maxAge for a field from a cache hint on the target type extension', async () => {
134+
const schema = buildSchemaWithCacheControlSupport(`
135+
type Query {
136+
droid(id: ID!): Droid
137+
}
138+
139+
type Droid {
140+
id: ID!
141+
name: String!
142+
}
143+
144+
extend type Droid @cacheControl(maxAge: 60)
145+
`);
146+
147+
const hints = await collectCacheControlHints(
148+
schema,
149+
`
150+
query {
151+
droid(id: 2001) {
152+
name
153+
}
154+
}
155+
`,
156+
{ defaultMaxAge: 10 },
157+
);
158+
159+
expect(hints).toStrictEqual(new Map([['droid', { maxAge: 60 }]]));
160+
});
161+
133162
it('should overwrite the default maxAge when maxAge=0 is specified on the type', async () => {
134163
const schema = buildSchemaWithCacheControlSupport(`
135164
type Query {

packages/apollo-server-core/src/plugin/cacheControl/__tests__/cacheControlPlugin.test.ts

-62
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,10 @@ import { Headers } from 'apollo-server-env';
33
import {
44
CacheHint,
55
CacheScope,
6-
GraphQLRequestContext,
76
} from 'apollo-server-types';
87
import {
98
ApolloServerPluginCacheControl,
109
ApolloServerPluginCacheControlOptions,
11-
PolicyUpdater,
1210
} from '../';
1311
import {
1412
GraphQLRequestContextWillSendResponse,
@@ -114,64 +112,4 @@ describe('plugin', () => {
114112
});
115113
});
116114
});
117-
118-
describe('PolicyUpdater', () => {
119-
let hints: PolicyUpdater;
120-
let requestContext: Pick<GraphQLRequestContext, 'overallCachePolicy'>;
121-
beforeEach(() => {
122-
requestContext = {};
123-
hints = new PolicyUpdater(requestContext);
124-
});
125-
126-
it('returns undefined without cache hints', () => {
127-
expect(requestContext.overallCachePolicy).toBeUndefined();
128-
});
129-
130-
it('returns lowest max age value', () => {
131-
hints.addHint({ maxAge: 10 });
132-
hints.addHint({ maxAge: 20 });
133-
134-
expect(requestContext.overallCachePolicy).toHaveProperty('maxAge', 10);
135-
});
136-
137-
it('returns undefined if any cache hint has a maxAge of 0', () => {
138-
hints.addHint({ maxAge: 120 });
139-
hints.addHint({ maxAge: 0 });
140-
hints.addHint({ maxAge: 20 });
141-
142-
expect(requestContext.overallCachePolicy).toBeUndefined();
143-
});
144-
145-
it('returns undefined if first cache hint has a maxAge of 0', () => {
146-
hints.addHint({ maxAge: 0 });
147-
hints.addHint({ maxAge: 20 });
148-
149-
expect(requestContext.overallCachePolicy).toBeUndefined();
150-
});
151-
152-
it('returns PUBLIC scope by default', () => {
153-
hints.addHint({ maxAge: 10 });
154-
155-
expect(requestContext.overallCachePolicy).toHaveProperty(
156-
'scope',
157-
CacheScope.Public,
158-
);
159-
});
160-
161-
it('returns PRIVATE scope if any cache hint has PRIVATE scope', () => {
162-
hints.addHint({
163-
maxAge: 10,
164-
scope: CacheScope.Public,
165-
});
166-
hints.addHint({
167-
maxAge: 10,
168-
scope: CacheScope.Private,
169-
});
170-
171-
expect(requestContext.overallCachePolicy).toHaveProperty(
172-
'scope',
173-
CacheScope.Private,
174-
);
175-
});
176-
});
177115
});

0 commit comments

Comments
 (0)