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

Add options to cache.gc to enable deleting nonessential/recomputable result caching data #8421

Merged
merged 10 commits into from
Jul 6, 2021
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@
- Add expected/received `variables` to `No more mocked responses...` error messages generated by `MockLink`. <br/>
[@markneub](github.com/markneub) in [#8340](https://github.com/apollographql/apollo-client/pull/8340)

- The `InMemoryCache` version of the `cache.gc` method now supports additional options for removing non-essential (recomputable) result caching data. <br/>
[@benjamn](https://github.com/benjamn) in [#8421](https://github.com/apollographql/apollo-client/pull/8421)

### Documentation
TBD

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
{
"name": "apollo-client",
"path": "./dist/apollo-client.cjs.min.js",
"maxSize": "24.4 kB"
"maxSize": "24.6 kB"
}
],
"peerDependencies": {
Expand Down
102 changes: 102 additions & 0 deletions scripts/memory/tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,108 @@ describe("garbage collection", () => {
}));
});

itAsync("should release cache.storeReader if requested via cache.gc", (resolve, reject) => {
const expectedKeys = {
__proto__: null,
StoreReader1: true,
ObjectCanon1: true,
StoreReader2: true,
ObjectCanon2: true,
StoreReader3: false,
ObjectCanon3: false,
};

const registry = makeRegistry(key => {
// Referring to client here should keep the client itself alive
// until after the ObservableQuery is (or should have been)
// collected. Collecting the ObservableQuery just because the whole
// client instance was collected is not interesting.
assert.strictEqual(client instanceof ApolloClient, true);
if (key in expectedKeys) {
assert.strictEqual(expectedKeys[key], true, key);
}
delete expectedKeys[key];
if (Object.keys(expectedKeys).every(key => !expectedKeys[key])) {
setTimeout(resolve, 100);
}
}, reject);

const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
local() {
return "hello";
},
},
},
},
});

const client = new ApolloClient({ cache });

(function () {
const query = gql`query { local }`;
const obsQuery = client.watchQuery({ query });

function register(suffix) {
const reader = cache["storeReader"];
registry.register(reader, "StoreReader" + suffix);
registry.register(reader.canon, "ObjectCanon" + suffix);
}

register(1);

const sub = obsQuery.subscribe({
next(result) {
assert.deepStrictEqual(result.data, {
local: "hello",
});

assert.strictEqual(
cache.readQuery({ query }),
result.data,
);

assert.deepStrictEqual(cache.gc(), []);

// Nothing changes because we merely called cache.gc().
assert.strictEqual(
cache.readQuery({ query }),
result.data,
);

assert.deepStrictEqual(cache.gc({
// Now reset the result cache but preserve reader.canon, so the
// results will be === even though they have to be recomputed.
resetResultCache: true,
resetResultIdentities: false,
}), []);

register(2);

const dataAfterResetWithSameCanon = cache.readQuery({ query });
assert.strictEqual(dataAfterResetWithSameCanon, result.data);

assert.deepStrictEqual(cache.gc({
// Finally, do a full reset of the result caching system, including
// discarding reader.canon, so === result identity is lost.
resetResultCache: true,
resetResultIdentities: true,
}), []);

register(3);

const dataAfterFullReset = cache.readQuery({ query });
assert.notStrictEqual(dataAfterFullReset, result.data);
assert.deepStrictEqual(dataAfterFullReset, result.data);

sub.unsubscribe();
},
});
})();
});

itAsync("should collect ObservableQuery after tear-down", (resolve, reject) => {
const expectedKeys = new Set([
"ObservableQuery",
Expand Down
11 changes: 11 additions & 0 deletions src/cache/inmemory/__tests__/__snapshots__/cache.ts.snap
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Cache cache.restore replaces cache.{store{Reader,Writer},maybeBroadcastWatch} 1`] = `
Object {
"ROOT_QUERY": Object {
"__typename": "Query",
"a": "ay",
"b": "bee",
"c": "see",
},
}
`;

exports[`Cache writeFragment will write some deeply nested data into the store at any id (1/2) 1`] = `
Object {
"__META": Object {
Expand Down
66 changes: 65 additions & 1 deletion src/cache/inmemory/__tests__/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import { InMemoryCache, InMemoryCacheConfig } from '../inMemoryCache';

jest.mock('optimism');
import { wrap } from 'optimism';
import { StoreReader } from '../readFromStore';
import { StoreWriter } from '../writeToStore';
import { ObjectCanon } from '../object-canon';

disableFragmentWarnings();

Expand Down Expand Up @@ -1330,7 +1333,57 @@ describe('Cache', () => {
);
});

describe('batch', () => {
describe('cache.restore', () => {
it('replaces cache.{store{Reader,Writer},maybeBroadcastWatch}', () => {
const cache = new InMemoryCache;
const query = gql`query { a b c }`;

const originalReader = cache["storeReader"];
expect(originalReader).toBeInstanceOf(StoreReader);

const originalWriter = cache["storeWriter"];
expect(originalWriter).toBeInstanceOf(StoreWriter);

const originalMBW = cache["maybeBroadcastWatch"];
expect(typeof originalMBW).toBe("function");

const originalCanon = originalReader.canon;
expect(originalCanon).toBeInstanceOf(ObjectCanon);

cache.writeQuery({
query,
data: {
a: "ay",
b: "bee",
c: "see",
},
});

const snapshot = cache.extract();
expect(snapshot).toMatchSnapshot();

cache.restore({});
expect(cache.extract()).toEqual({});
expect(cache.readQuery({ query })).toBe(null);

cache.restore(snapshot);
expect(cache.extract()).toEqual(snapshot);
expect(cache.readQuery({ query })).toEqual({
a: "ay",
b: "bee",
c: "see",
});

expect(originalReader).not.toBe(cache["storeReader"]);
expect(originalWriter).not.toBe(cache["storeWriter"]);
expect(originalMBW).not.toBe(cache["maybeBroadcastWatch"]);
// The cache.storeReader.canon is preserved by default, but can be dropped
// by passing resetResultIdentities:true to cache.gc.
expect(originalCanon).toBe(cache["storeReader"].canon);
});
});

describe('cache.batch', () => {
const last = <E>(array: E[]) => array[array.length - 1];

function watch(cache: InMemoryCache, query: DocumentNode) {
Expand Down Expand Up @@ -2603,6 +2656,17 @@ describe("InMemoryCache#modify", () => {

expect(aResults).toEqual([a123, a124]);
expect(bResults).toEqual([b321, b322]);

// Check that resetting the result cache does not trigger additional watch
// notifications.
expect(cache.gc({
resetResultCache: true,
})).toEqual([]);
expect(aResults).toEqual([a123, a124]);
expect(bResults).toEqual([b321, b322]);
cache["broadcastWatches"]();
expect(aResults).toEqual([a123, a124]);
expect(bResults).toEqual([b321, b322]);
});

it("should handle argument-determined field identities", () => {
Expand Down
46 changes: 43 additions & 3 deletions src/cache/inmemory/__tests__/entityStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ApolloCache } from '../../core/cache';
import { Cache } from '../../core/types/Cache';
import { Reference, makeReference, isReference } from '../../../utilities/graphql/storeUtils';
import { MissingFieldError } from '../..';
import { TypedDocumentNode } from '@graphql-typed-document-node/core';

describe('EntityStore', () => {
it('should support result caching if so configured', () => {
Expand Down Expand Up @@ -56,7 +57,17 @@ describe('EntityStore', () => {
},
});

const query = gql`
const query: TypedDocumentNode<{
book: {
__typename: string;
title: string;
isbn: string;
author: {
__typename: string;
name: string;
};
};
}> = gql`
query {
book {
title
Expand Down Expand Up @@ -159,11 +170,16 @@ describe('EntityStore', () => {
},
});

const resultBeforeGC = cache.readQuery({ query });

expect(cache.gc().sort()).toEqual([
'Author:Ray Bradbury',
'Book:9781451673319',
]);

const resultAfterGC = cache.readQuery({ query });
expect(resultBeforeGC).toBe(resultAfterGC);

expect(cache.extract()).toEqual({
ROOT_QUERY: {
__typename: "Query",
Expand All @@ -184,8 +200,32 @@ describe('EntityStore', () => {
},
});

// Nothing left to garbage collect.
expect(cache.gc()).toEqual([]);
// Nothing left to collect, but let's also reset the result cache to
// demonstrate that the recomputed cache results are unchanged.
const originalReader = cache["storeReader"];
expect(cache.gc({
resetResultCache: true,
})).toEqual([]);
expect(cache["storeReader"]).not.toBe(originalReader);
const resultAfterResetResultCache = cache.readQuery({ query });
expect(resultAfterResetResultCache).toBe(resultBeforeGC);
expect(resultAfterResetResultCache).toBe(resultAfterGC);

// Now discard cache.storeReader.canon as well.
expect(cache.gc({
resetResultCache: true,
resetResultIdentities: true,
})).toEqual([]);

const resultAfterFullGC = cache.readQuery({ query });
expect(resultAfterFullGC).toEqual(resultBeforeGC);
expect(resultAfterFullGC).toEqual(resultAfterGC);
// These !== relations are triggered by passing resetResultIdentities:true
// to cache.gc, above.
expect(resultAfterFullGC).not.toBe(resultBeforeGC);
expect(resultAfterFullGC).not.toBe(resultAfterGC);
// Result caching immediately begins working again after the intial reset.
expect(cache.readQuery({ query })).toBe(resultAfterFullGC);

// Go back to the pre-GC snapshot.
cache.restore(snapshot);
Expand Down
4 changes: 2 additions & 2 deletions src/cache/inmemory/__tests__/readFromStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2025,7 +2025,7 @@ describe('reading from the store', () => {
},
});

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

const query = gql`
query {
Expand Down Expand Up @@ -2081,7 +2081,7 @@ describe('reading from the store', () => {
},
});

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

const fragment = gql`
fragment CountFragment on Query {
Expand Down
15 changes: 10 additions & 5 deletions src/cache/inmemory/entityStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,11 +507,20 @@ export type FieldValueGetter = EntityStore["getFieldValue"];
class CacheGroup {
private d: OptimisticDependencyFunction<string> | null = null;

// Used by the EntityStore#makeCacheKey method to compute cache keys
// specific to this CacheGroup.
public keyMaker: Trie<object>;

constructor(
public readonly caching: boolean,
private parent: CacheGroup | null = null,
) {
this.d = caching ? dep<string>() : null;
this.resetCaching();
}

public resetCaching() {
this.d = this.caching ? dep<string>() : null;
this.keyMaker = new Trie(canUseWeakMap);
}

public depend(dataId: string, storeFieldName: string) {
Expand Down Expand Up @@ -547,10 +556,6 @@ class CacheGroup {
);
}
}

// Used by the EntityStore#makeCacheKey method to compute cache keys
// specific to this CacheGroup.
public readonly keyMaker = new Trie<object>(canUseWeakMap);
}

function makeDepKey(dataId: string, storeFieldName: string) {
Expand Down