Skip to content

Commit

Permalink
Depend on existence of enclosing entity object when reading from cach…
Browse files Browse the repository at this point in the history
…e. (#8147)

Co-authored-by: Sofian Hnaide <sofian.hnaide@outlook.com>
  • Loading branch information
benjamn and sofianhn authored May 11, 2021
1 parent 40fca2e commit 94b7472
Show file tree
Hide file tree
Showing 6 changed files with 242 additions and 7 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
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");
}
}

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
Original file line number Diff line number Diff line change
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

0 comments on commit 94b7472

Please sign in to comment.