Skip to content

Commit 94b7472

Browse files
benjamnsofianhn
andauthored
Depend on existence of enclosing entity object when reading from cache. (#8147)
Co-authored-by: Sofian Hnaide <[email protected]>
1 parent 40fca2e commit 94b7472

File tree

6 files changed

+242
-7
lines changed

6 files changed

+242
-7
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@
6161
- A `resultCacheMaxSize` option may be passed to the `InMemoryCache` constructor to limit the number of result objects that will be retained in memory (to speed up repeated reads), and calling `cache.reset()` now releases all such memory. <br/>
6262
[@SofianHn](https://github.com/SofianHn) in [#8701](https://github.com/apollographql/apollo-client/pull/8701)
6363

64+
- Fully remove result cache entries from LRU dependency system when the corresponding entities are removed from `InMemoryCache` by eviction, or by any other means. <br/>
65+
[@sofianhn](https://github.com/sofianhn) and [@benjamn](https://github.com/benjamn) in [#8147](https://github.com/apollographql/apollo-client/pull/8147)
66+
6467
### Documentation
6568
TBD
6669

package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@
8181
"fast-json-stable-stringify": "^2.0.0",
8282
"graphql-tag": "^2.12.3",
8383
"hoist-non-react-statics": "^3.3.2",
84-
"optimism": "^0.16.0",
84+
"optimism": "^0.16.1",
8585
"prop-types": "^15.7.2",
8686
"symbol-observable": "^2.0.0",
8787
"ts-invariant": "^0.7.3",

src/__tests__/resultCacheCleaning.ts

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { makeExecutableSchema } from "graphql-tools";
2+
3+
import { ApolloClient, Resolvers, gql } from "../core";
4+
import { InMemoryCache, NormalizedCacheObject } from "../cache";
5+
import { SchemaLink } from "../link/schema";
6+
7+
describe("resultCache cleaning", () => {
8+
const fragments = gql`
9+
fragment user on User {
10+
id
11+
name
12+
}
13+
14+
fragment reaction on Reaction {
15+
id
16+
type
17+
author {
18+
...user
19+
}
20+
}
21+
22+
fragment message on Message {
23+
id
24+
author {
25+
...user
26+
}
27+
reactions {
28+
...reaction
29+
}
30+
viewedBy {
31+
...user
32+
}
33+
}
34+
`;
35+
36+
const query = gql`
37+
query getChat($id: ID!) {
38+
chat(id: $id) {
39+
id
40+
name
41+
members {
42+
...user
43+
}
44+
messages {
45+
...message
46+
}
47+
}
48+
}
49+
${{ ...fragments }}
50+
`;
51+
52+
function uuid(label: string) {
53+
return () =>
54+
`${label}-${Math.random()
55+
.toString(16)
56+
.substr(2)}`;
57+
}
58+
59+
function emptyList(len: number) {
60+
return new Array(len).fill(true);
61+
}
62+
63+
const typeDefs = gql`
64+
type Query {
65+
chat(id: ID!): Chat!
66+
}
67+
68+
type Chat {
69+
id: ID!
70+
name: String!
71+
messages: [Message!]!
72+
members: [User!]!
73+
}
74+
75+
type Message {
76+
id: ID!
77+
author: User!
78+
reactions: [Reaction!]!
79+
viewedBy: [User!]!
80+
content: String!
81+
}
82+
83+
type User {
84+
id: ID!
85+
name: String!
86+
}
87+
88+
type Reaction {
89+
id: ID!
90+
type: String!
91+
author: User!
92+
}
93+
`;
94+
95+
const resolvers: Resolvers = {
96+
Query: {
97+
chat(_, { id }) {
98+
return id;
99+
},
100+
},
101+
Chat: {
102+
id(id) {
103+
return id;
104+
},
105+
name(id) {
106+
return id;
107+
},
108+
messages() {
109+
return emptyList(10);
110+
},
111+
members() {
112+
return emptyList(10);
113+
},
114+
},
115+
Message: {
116+
id: uuid("Message"),
117+
author() {
118+
return { foo: true };
119+
},
120+
reactions() {
121+
return emptyList(10);
122+
},
123+
viewedBy() {
124+
return emptyList(10);
125+
},
126+
content: uuid("Message-Content"),
127+
},
128+
User: {
129+
id: uuid("User"),
130+
name: uuid("User.name"),
131+
},
132+
Reaction: {
133+
id: uuid("Reaction"),
134+
type: uuid("Reaction.type"),
135+
author() {
136+
return { foo: true };
137+
},
138+
},
139+
};
140+
141+
let client: ApolloClient<NormalizedCacheObject>;
142+
143+
beforeEach(() => {
144+
client = new ApolloClient({
145+
cache: new InMemoryCache,
146+
link: new SchemaLink({
147+
schema: makeExecutableSchema({
148+
typeDefs,
149+
resolvers,
150+
}),
151+
}),
152+
});
153+
});
154+
155+
afterEach(() => {
156+
const storeReader = (client.cache as InMemoryCache)["storeReader"];
157+
expect(storeReader["executeSubSelectedArray"].size).toBeGreaterThan(0);
158+
expect(storeReader["executeSelectionSet"].size).toBeGreaterThan(0);
159+
client.cache.evict({
160+
id: "ROOT_QUERY",
161+
});
162+
client.cache.gc();
163+
expect(storeReader["executeSubSelectedArray"].size).toEqual(0);
164+
expect(storeReader["executeSelectionSet"].size).toEqual(0);
165+
});
166+
167+
it(`empties all result caches after eviction - query`, async () => {
168+
await client.query({
169+
query,
170+
variables: { id: 1 },
171+
});
172+
});
173+
174+
it(`empties all result caches after eviction - watchQuery`, async () => {
175+
return new Promise<void>((r) => {
176+
const observable = client.watchQuery({
177+
query,
178+
variables: { id: 1 },
179+
});
180+
const unsubscribe = observable.subscribe(() => {
181+
unsubscribe.unsubscribe();
182+
r();
183+
});
184+
});
185+
});
186+
});

src/cache/inmemory/entityStore.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -534,7 +534,17 @@ class CacheGroup {
534534

535535
public dirty(dataId: string, storeFieldName: string) {
536536
if (this.d) {
537-
this.d.dirty(makeDepKey(dataId, storeFieldName));
537+
this.d.dirty(
538+
makeDepKey(dataId, storeFieldName),
539+
// When storeFieldName === "__exists", that means the entity identified
540+
// by dataId has either disappeared from the cache or was newly added,
541+
// so the result caching system would do well to "forget everything it
542+
// knows" about that object. To achieve that kind of invalidation, we
543+
// not only dirty the associated result cache entry, but also remove it
544+
// completely from the dependency graph. For the optimism implmentation
545+
// details, see https://github.com/benjamn/optimism/pull/195.
546+
storeFieldName === "__exists" ? "forget" : "setDirty",
547+
);
538548
}
539549
}
540550

@@ -550,6 +560,23 @@ function makeDepKey(dataId: string, storeFieldName: string) {
550560
return storeFieldName + '#' + dataId;
551561
}
552562

563+
export function maybeDependOnExistenceOfEntity(
564+
store: NormalizedCache,
565+
entityId: string,
566+
) {
567+
if (supportsResultCaching(store)) {
568+
// We use this pseudo-field __exists elsewhere in the EntityStore code to
569+
// represent changes in the existence of the entity object identified by
570+
// entityId. This dependency gets reliably dirtied whenever an object with
571+
// this ID is deleted (or newly created) within this group, so any result
572+
// cache entries (for example, StoreReader#executeSelectionSet results) that
573+
// depend on __exists for this entityId will get dirtied as well, leading to
574+
// the eventual recomputation (instead of reuse) of those result objects the
575+
// next time someone reads them from the cache.
576+
store.group.depend(entityId, "__exists");
577+
}
578+
}
579+
553580
export namespace EntityStore {
554581
// Refer to this class as EntityStore.Root outside this namespace.
555582
export class Root extends EntityStore {

src/cache/inmemory/readFromStore.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import {
3131
NormalizedCache,
3232
ReadMergeModifyContext,
3333
} from './types';
34-
import { supportsResultCaching } from './entityStore';
34+
import { maybeDependOnExistenceOfEntity, supportsResultCaching } from './entityStore';
3535
import { getTypenameFromStoreObject } from './helpers';
3636
import { Policies } from './policies';
3737
import { InMemoryCache } from './inMemoryCache';
@@ -70,12 +70,14 @@ function missingFromInvariant(
7070
type ExecSelectionSetOptions = {
7171
selectionSet: SelectionSetNode;
7272
objectOrReference: StoreObject | Reference;
73+
enclosingRef: Reference;
7374
context: ReadContext;
7475
};
7576

7677
type ExecSubSelectedArrayOptions = {
7778
field: FieldNode;
7879
array: any[];
80+
enclosingRef: Reference;
7981
context: ReadContext;
8082
};
8183

@@ -157,6 +159,11 @@ export class StoreReader {
157159
return other;
158160
}
159161

162+
maybeDependOnExistenceOfEntity(
163+
options.context.store,
164+
options.enclosingRef.__ref,
165+
);
166+
160167
// Finally, if we didn't find any useful previous results, run the real
161168
// execSelectionSetImpl method with the given options.
162169
return this.execSelectionSetImpl(options);
@@ -179,6 +186,10 @@ export class StoreReader {
179186
});
180187

181188
this.executeSubSelectedArray = wrap((options: ExecSubSelectedArrayOptions) => {
189+
maybeDependOnExistenceOfEntity(
190+
options.context.store,
191+
options.enclosingRef.__ref,
192+
);
182193
return this.execSubSelectedArrayImpl(options);
183194
}, {
184195
max: this.config.resultCacheMaxSize,
@@ -216,9 +227,11 @@ export class StoreReader {
216227
...variables!,
217228
};
218229

230+
const rootRef = makeReference(rootId);
219231
const execResult = this.executeSelectionSet({
220232
selectionSet: getMainDefinition(query).selectionSet,
221-
objectOrReference: makeReference(rootId),
233+
objectOrReference: rootRef,
234+
enclosingRef: rootRef,
222235
context: {
223236
store,
224237
query,
@@ -273,6 +286,7 @@ export class StoreReader {
273286
private execSelectionSetImpl({
274287
selectionSet,
275288
objectOrReference,
289+
enclosingRef,
276290
context,
277291
}: ExecSelectionSetOptions): ExecResult {
278292
if (isReference(objectOrReference) &&
@@ -364,6 +378,7 @@ export class StoreReader {
364378
fieldValue = handleMissing(this.executeSubSelectedArray({
365379
field: selection,
366380
array: fieldValue,
381+
enclosingRef,
367382
context,
368383
}));
369384

@@ -383,6 +398,7 @@ export class StoreReader {
383398
fieldValue = handleMissing(this.executeSelectionSet({
384399
selectionSet: selection.selectionSet,
385400
objectOrReference: fieldValue as StoreObject | Reference,
401+
enclosingRef: isReference(fieldValue) ? fieldValue : enclosingRef,
386402
context,
387403
}));
388404
}
@@ -431,6 +447,7 @@ export class StoreReader {
431447
private execSubSelectedArrayImpl({
432448
field,
433449
array,
450+
enclosingRef,
434451
context,
435452
}: ExecSubSelectedArrayOptions): ExecResult {
436453
let missing: MissingFieldError[] | undefined;
@@ -463,6 +480,7 @@ export class StoreReader {
463480
return handleMissing(this.executeSubSelectedArray({
464481
field,
465482
array: item,
483+
enclosingRef,
466484
context,
467485
}), i);
468486
}
@@ -472,6 +490,7 @@ export class StoreReader {
472490
return handleMissing(this.executeSelectionSet({
473491
selectionSet: field.selectionSet,
474492
objectOrReference: item,
493+
enclosingRef: isReference(item) ? item : enclosingRef,
475494
context,
476495
}), i);
477496
}

0 commit comments

Comments
 (0)