Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Depend on existence of enclosing entity object when reading from cache. #8147

Merged
merged 4 commits into from May 11, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Expand Up @@ -61,6 +61,9 @@
- 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/>
[@SofianHn](https://github.com/SofianHn) in [#8701](https://github.com/apollographql/apollo-client/pull/8701)

- 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/>
[@sofianhn](https://github.com/sofianhn) and [@benjamn](https://github.com/benjamn) in [#8147](https://github.com/apollographql/apollo-client/pull/8147)

### Documentation
TBD

Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Expand Up @@ -81,7 +81,7 @@
"fast-json-stable-stringify": "^2.0.0",
"graphql-tag": "^2.12.3",
"hoist-non-react-statics": "^3.3.2",
"optimism": "^0.16.0",
"optimism": "^0.16.1",
"prop-types": "^15.7.2",
"symbol-observable": "^2.0.0",
"ts-invariant": "^0.7.3",
Expand Down
186 changes: 186 additions & 0 deletions src/__tests__/resultCacheCleaning.ts
@@ -0,0 +1,186 @@
import { makeExecutableSchema } from "graphql-tools";

import { ApolloClient, Resolvers, gql } from "../core";
import { InMemoryCache, NormalizedCacheObject } from "../cache";
import { SchemaLink } from "../link/schema";

describe("resultCache cleaning", () => {
const fragments = gql`
fragment user on User {
id
name
}

fragment reaction on Reaction {
id
type
author {
...user
}
}

fragment message on Message {
id
author {
...user
}
reactions {
...reaction
}
viewedBy {
...user
}
}
`;

const query = gql`
query getChat($id: ID!) {
chat(id: $id) {
id
name
members {
...user
}
messages {
...message
}
}
}
${{ ...fragments }}
`;

function uuid(label: string) {
return () =>
`${label}-${Math.random()
.toString(16)
.substr(2)}`;
}

function emptyList(len: number) {
return new Array(len).fill(true);
}

const typeDefs = gql`
type Query {
chat(id: ID!): Chat!
}

type Chat {
id: ID!
name: String!
messages: [Message!]!
members: [User!]!
}

type Message {
id: ID!
author: User!
reactions: [Reaction!]!
viewedBy: [User!]!
content: String!
}

type User {
id: ID!
name: String!
}

type Reaction {
id: ID!
type: String!
author: User!
}
`;

const resolvers: Resolvers = {
Query: {
chat(_, { id }) {
return id;
},
},
Chat: {
id(id) {
return id;
},
name(id) {
return id;
},
messages() {
return emptyList(10);
},
members() {
return emptyList(10);
},
},
Message: {
id: uuid("Message"),
author() {
return { foo: true };
},
reactions() {
return emptyList(10);
},
viewedBy() {
return emptyList(10);
},
content: uuid("Message-Content"),
},
User: {
id: uuid("User"),
name: uuid("User.name"),
},
Reaction: {
id: uuid("Reaction"),
type: uuid("Reaction.type"),
author() {
return { foo: true };
},
},
};

let client: ApolloClient<NormalizedCacheObject>;

beforeEach(() => {
client = new ApolloClient({
cache: new InMemoryCache,
link: new SchemaLink({
schema: makeExecutableSchema({
typeDefs,
resolvers,
}),
}),
});
});

afterEach(() => {
const storeReader = (client.cache as InMemoryCache)["storeReader"];
expect(storeReader["executeSubSelectedArray"].size).toBeGreaterThan(0);
expect(storeReader["executeSelectionSet"].size).toBeGreaterThan(0);
client.cache.evict({
id: "ROOT_QUERY",
});
client.cache.gc();
expect(storeReader["executeSubSelectedArray"].size).toEqual(0);
expect(storeReader["executeSelectionSet"].size).toEqual(0);
});

it(`empties all result caches after eviction - query`, async () => {
await client.query({
query,
variables: { id: 1 },
});
});

it(`empties all result caches after eviction - watchQuery`, async () => {
return new Promise<void>((r) => {
const observable = client.watchQuery({
query,
variables: { id: 1 },
});
const unsubscribe = observable.subscribe(() => {
unsubscribe.unsubscribe();
r();
});
});
});
});
29 changes: 28 additions & 1 deletion src/cache/inmemory/entityStore.ts
Expand Up @@ -534,7 +534,17 @@ class CacheGroup {

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

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

export function maybeDependOnExistenceOfEntity(
store: NormalizedCache,
entityId: string,
) {
if (supportsResultCaching(store)) {
// We use this pseudo-field __exists elsewhere in the EntityStore code to
// represent changes in the existence of the entity object identified by
// entityId. This dependency gets reliably dirtied whenever an object with
// this ID is deleted (or newly created) within this group, so any result
// cache entries (for example, StoreReader#executeSelectionSet results) that
// depend on __exists for this entityId will get dirtied as well, leading to
// the eventual recomputation (instead of reuse) of those result objects the
// next time someone reads them from the cache.
store.group.depend(entityId, "__exists");
Comment on lines +563 to +576
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, the EntityStore tries to track dependencies at the field level, so cached result objects don't have to be invalidated if none of their fields have been invalidated, but when the entire object is removed (or added for the first time), that's a more significant event than the removal/addition/modification of individual fields, so maybeDependOnExistenceOfEntity enables consumers to listen specifically for __exists invalidations.

}
}

export namespace EntityStore {
// Refer to this class as EntityStore.Root outside this namespace.
export class Root extends EntityStore {
Expand Down
23 changes: 21 additions & 2 deletions src/cache/inmemory/readFromStore.ts
Expand Up @@ -31,7 +31,7 @@ import {
NormalizedCache,
ReadMergeModifyContext,
} from './types';
import { supportsResultCaching } from './entityStore';
import { maybeDependOnExistenceOfEntity, supportsResultCaching } from './entityStore';
import { getTypenameFromStoreObject } from './helpers';
import { Policies } from './policies';
import { InMemoryCache } from './inMemoryCache';
Expand Down Expand Up @@ -70,12 +70,14 @@ function missingFromInvariant(
type ExecSelectionSetOptions = {
selectionSet: SelectionSetNode;
objectOrReference: StoreObject | Reference;
enclosingRef: Reference;
context: ReadContext;
};

type ExecSubSelectedArrayOptions = {
field: FieldNode;
array: any[];
enclosingRef: Reference;
context: ReadContext;
};

Expand Down Expand Up @@ -157,6 +159,11 @@ export class StoreReader {
return other;
}

maybeDependOnExistenceOfEntity(
options.context.store,
options.enclosingRef.__ref,
);

// Finally, if we didn't find any useful previous results, run the real
// execSelectionSetImpl method with the given options.
return this.execSelectionSetImpl(options);
Expand All @@ -179,6 +186,10 @@ export class StoreReader {
});

this.executeSubSelectedArray = wrap((options: ExecSubSelectedArrayOptions) => {
maybeDependOnExistenceOfEntity(
options.context.store,
options.enclosingRef.__ref,
);
return this.execSubSelectedArrayImpl(options);
}, {
max: this.config.resultCacheMaxSize,
Expand Down Expand Up @@ -216,9 +227,11 @@ export class StoreReader {
...variables!,
};

const rootRef = makeReference(rootId);
const execResult = this.executeSelectionSet({
selectionSet: getMainDefinition(query).selectionSet,
objectOrReference: makeReference(rootId),
objectOrReference: rootRef,
enclosingRef: rootRef,
context: {
store,
query,
Expand Down Expand Up @@ -273,6 +286,7 @@ export class StoreReader {
private execSelectionSetImpl({
selectionSet,
objectOrReference,
enclosingRef,
context,
}: ExecSelectionSetOptions): ExecResult {
if (isReference(objectOrReference) &&
Expand Down Expand Up @@ -364,6 +378,7 @@ export class StoreReader {
fieldValue = handleMissing(this.executeSubSelectedArray({
field: selection,
array: fieldValue,
enclosingRef,
context,
}));

Expand All @@ -383,6 +398,7 @@ export class StoreReader {
fieldValue = handleMissing(this.executeSelectionSet({
selectionSet: selection.selectionSet,
objectOrReference: fieldValue as StoreObject | Reference,
enclosingRef: isReference(fieldValue) ? fieldValue : enclosingRef,
context,
}));
}
Expand Down Expand Up @@ -431,6 +447,7 @@ export class StoreReader {
private execSubSelectedArrayImpl({
field,
array,
enclosingRef,
context,
}: ExecSubSelectedArrayOptions): ExecResult {
let missing: MissingFieldError[] | undefined;
Expand Down Expand Up @@ -463,6 +480,7 @@ export class StoreReader {
return handleMissing(this.executeSubSelectedArray({
field,
array: item,
enclosingRef,
context,
}), i);
}
Expand All @@ -472,6 +490,7 @@ export class StoreReader {
return handleMissing(this.executeSelectionSet({
selectionSet: field.selectionSet,
objectOrReference: item,
enclosingRef: isReference(item) ? item : enclosingRef,
context,
}), i);
}
Expand Down