Skip to content

Commit

Permalink
Allow opting out of cache result canonization.
Browse files Browse the repository at this point in the history
Although cache result canonization (#7439) is algorithmically efficient
(linear time for tree-shaped results), it does have a computational cost
for large result trees, so you might want to disable canonization for
exceptionally big queries, if you decide the future performance benefits
of result canonization are not worth the initial cost.

Fortunately, this implementation allows non-canonical results to be
exchaged later for canonical results without recomputing the underlying
results, but merely by canonizing the previous results.

Of course, this reuse happens only when the cache has not been modified
in the meantime (the usual result caching/invalidation story, nothing
new), in which case the StoreReader does its best to reuse as many
subtrees as it can, if it can't reuse the entire result tree.
  • Loading branch information
benjamn committed May 10, 2021
1 parent 09d94cb commit 07de700
Show file tree
Hide file tree
Showing 12 changed files with 263 additions and 37 deletions.
16 changes: 13 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.15.0",
"optimism": "^0.16.0",
"prop-types": "^15.7.2",
"symbol-observable": "^2.0.0",
"ts-invariant": "^0.7.3",
Expand Down
7 changes: 2 additions & 5 deletions src/cache/core/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,10 +142,8 @@ export abstract class ApolloCache<TSerialized> implements DataProxy {
optimistic = !!options.optimistic,
): QueryType | null {
return this.read({
...options,
rootId: options.id || 'ROOT_QUERY',
query: options.query,
variables: options.variables,
returnPartialData: options.returnPartialData,
optimistic,
});
}
Expand All @@ -159,10 +157,9 @@ export abstract class ApolloCache<TSerialized> implements DataProxy {
optimistic = !!options.optimistic,
): FragmentType | null {
return this.read({
...options,
query: this.getFragmentDoc(options.fragment, options.fragmentName),
variables: options.variables,
rootId: options.id,
returnPartialData: options.returnPartialData,
optimistic,
});
}
Expand Down
1 change: 1 addition & 0 deletions src/cache/core/types/Cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export namespace Cache {
previousResult?: any;
optimistic: boolean;
returnPartialData?: boolean;
canonizeResults?: boolean;
}

export interface WriteOptions<TResult = any, TVariables = any>
Expand Down
12 changes: 12 additions & 0 deletions src/cache/core/types/DataProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ export namespace DataProxy {
* readQuery method can be omitted. Defaults to false.
*/
optimistic?: boolean;
/**
* Whether to canonize cache results before returning them. Canonization
* takes some extra time, but it speeds up future deep equality comparisons.
* Defaults to true.
*/
canonizeResults?: boolean;
}

export interface ReadFragmentOptions<TData, TVariables>
Expand All @@ -82,6 +88,12 @@ export namespace DataProxy {
* readQuery method can be omitted. Defaults to false.
*/
optimistic?: boolean;
/**
* Whether to canonize cache results before returning them. Canonization
* takes some extra time, but it speeds up future deep equality comparisons.
* Defaults to true.
*/
canonizeResults?: boolean;
}

export interface WriteOptions<TData> {
Expand Down
114 changes: 114 additions & 0 deletions src/cache/inmemory/__tests__/readFromStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2010,4 +2010,118 @@ describe('reading from the store', () => {
expect(result1.abc).toBe(abc);
expect(result2.abc).toBe(abc);
});

it("readQuery can opt out of canonization", function () {
let count = 0;

const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
count() {
return count++;
},
},
},
},
});

const canon = cache["storeReader"]["canon"];

const query = gql`
query {
count
}
`;

function readQuery(canonizeResults: boolean) {
return cache.readQuery<{
count: number;
}>({
query,
canonizeResults,
});
}

const nonCanonicalQueryResult0 = readQuery(false);
expect(canon.isCanonical(nonCanonicalQueryResult0)).toBe(false);
expect(nonCanonicalQueryResult0).toEqual({ count: 0 });

const canonicalQueryResult0 = readQuery(true);
expect(canon.isCanonical(canonicalQueryResult0)).toBe(true);
// The preservation of { count: 0 } proves the result didn't have to be
// recomputed, but merely canonized.
expect(canonicalQueryResult0).toEqual({ count: 0 });

cache.evict({
fieldName: "count",
});

const canonicalQueryResult1 = readQuery(true);
expect(canon.isCanonical(canonicalQueryResult1)).toBe(true);
expect(canonicalQueryResult1).toEqual({ count: 1 });

const nonCanonicalQueryResult1 = readQuery(false);
// Since we already read a canonical result, we were able to reuse it when
// reading the non-canonical result.
expect(nonCanonicalQueryResult1).toBe(canonicalQueryResult1);
});

it("readFragment can opt out of canonization", function () {
let count = 0;

const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
count() {
return count++;
},
},
},
},
});

const canon = cache["storeReader"]["canon"];

const fragment = gql`
fragment CountFragment on Query {
count
}
`;

function readFragment(canonizeResults: boolean) {
return cache.readFragment<{
count: number;
}>({
id: "ROOT_QUERY",
fragment,
canonizeResults,
});
}

const canonicalFragmentResult1 = readFragment(true);
expect(canon.isCanonical(canonicalFragmentResult1)).toBe(true);
expect(canonicalFragmentResult1).toEqual({ count: 0 });

const nonCanonicalFragmentResult1 = readFragment(false);
// Since we already read a canonical result, we were able to reuse it when
// reading the non-canonical result.
expect(nonCanonicalFragmentResult1).toBe(canonicalFragmentResult1);

cache.evict({
fieldName: "count",
});

const nonCanonicalFragmentResult2 = readFragment(false);
expect(readFragment(false)).toBe(nonCanonicalFragmentResult2);
expect(canon.isCanonical(nonCanonicalFragmentResult2)).toBe(false);
expect(nonCanonicalFragmentResult2).toEqual({ count: 1 });
expect(readFragment(false)).toBe(nonCanonicalFragmentResult2);

const canonicalFragmentResult2 = readFragment(true);
expect(readFragment(true)).toBe(canonicalFragmentResult2);
expect(canon.isCanonical(canonicalFragmentResult2)).toBe(true);
expect(canonicalFragmentResult2).toEqual({ count: 1 });
});
});
8 changes: 2 additions & 6 deletions src/cache/inmemory/inMemoryCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,8 @@ export class InMemoryCache extends ApolloCache<NormalizedCacheObject> {
} = options;
try {
return this.storeReader.diffQueryAgainstStore<T>({
...options,
store: options.optimistic ? this.optimisticData : this.data,
query: options.query,
variables: options.variables,
rootId: options.rootId,
config: this.config,
returnPartialData,
}).result || null;
Expand Down Expand Up @@ -223,11 +221,9 @@ export class InMemoryCache extends ApolloCache<NormalizedCacheObject> {

public diff<T>(options: Cache.DiffOptions): Cache.DiffResult<T> {
return this.storeReader.diffQueryAgainstStore({
...options,
store: options.optimistic ? this.optimisticData : this.data,
rootId: options.id || "ROOT_QUERY",
query: options.query,
variables: options.variables,
returnPartialData: options.returnPartialData,
config: this.config,
});
}
Expand Down
4 changes: 4 additions & 0 deletions src/cache/inmemory/object-canon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ export class ObjectCanon {
keys?: SortedKeysInfo;
}>(canUseWeakMap);

public isCanonical(value: any): boolean {
return isObjectOrArray(value) && this.known.has(value);
}

// Make the ObjectCanon assume this value has already been
// canonicalized.
public pass<T>(value: T): T extends object ? Pass<T> : T;
Expand Down
Loading

0 comments on commit 07de700

Please sign in to comment.