-
Notifications
You must be signed in to change notification settings - Fork 2.7k
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
Implement InMemoryCache garbage collection and eviction. #5310
Changes from 1 commit
a869634
a5ee594
7d335ba
79125bb
371bcdc
d502278
3f4ff08
d24485f
3b659f8
1ae4757
4b3d78c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
This implementation currently requires calling cache.gc() manually, since the timing of garbage collection is subject to developer taste.
- Loading branch information
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,5 +1,6 @@ | ||
import { NormalizedCache, NormalizedCacheObject, StoreObject } from './types'; | ||
import { wrap, OptimisticWrapperFunction } from 'optimism'; | ||
import { isReference } from './helpers'; | ||
|
||
const hasOwn = Object.prototype.hasOwnProperty; | ||
|
||
|
@@ -31,18 +32,18 @@ export abstract class EntityCache implements NormalizedCache { | |
} | ||
|
||
public abstract addLayer( | ||
id: string, | ||
layerId: string, | ||
replay: (layer: EntityCache) => any, | ||
): EntityCache; | ||
|
||
public abstract removeLayer(id: string): EntityCache; | ||
public abstract removeLayer(layerId: string): EntityCache; | ||
|
||
// Although the EntityCache class is abstract, it contains concrete | ||
// implementations of the various NormalizedCache interface methods that | ||
// are inherited by the Root and Layer subclasses. | ||
|
||
public toObject(): NormalizedCacheObject { | ||
return this.data; | ||
return { ...this.data }; | ||
} | ||
|
||
public get(dataId: string): StoreObject { | ||
|
@@ -53,12 +54,16 @@ export abstract class EntityCache implements NormalizedCache { | |
public set(dataId: string, value: StoreObject): void { | ||
if (!hasOwn.call(this.data, dataId) || value !== this.data[dataId]) { | ||
this.data[dataId] = value; | ||
delete this.refs[dataId]; | ||
if (this.depend) this.depend.dirty(dataId); | ||
} | ||
} | ||
|
||
public delete(dataId: string): void { | ||
this.data[dataId] = void 0; | ||
if (this instanceof Layer) { | ||
this.data[dataId] = void 0; | ||
} else delete this.data[dataId]; | ||
delete this.refs[dataId]; | ||
if (this.depend) this.depend.dirty(dataId); | ||
} | ||
|
||
|
@@ -78,6 +83,78 @@ export abstract class EntityCache implements NormalizedCache { | |
}); | ||
} | ||
} | ||
|
||
private rootIds: { | ||
[rootId: string]: Set<object>; | ||
} = Object.create(null); | ||
|
||
public retain(rootId: string, owner: object): void { | ||
(this.rootIds[rootId] || (this.rootIds[rootId] = new Set<object>())).add(owner); | ||
} | ||
|
||
public release(rootId: string, owner: object): void { | ||
const owners = this.rootIds[rootId]; | ||
if (owners && owners.delete(owner) && !owners.size) { | ||
delete this.rootIds[rootId]; | ||
} | ||
} | ||
|
||
// This method will be overridden in the Layer class to merge root IDs for all | ||
// layers (including the root). | ||
public getRootIdSet() { | ||
benjamn marked this conversation as resolved.
Show resolved
Hide resolved
hwillson marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return new Set(Object.keys(this.rootIds)); | ||
} | ||
|
||
// The goal of garbage collection is to remove IDs from the Root layer of the | ||
// cache that are no longer reachable starting from any IDs that have been | ||
// explicitly retained (see retain and release, above). Returns an array of | ||
// dataId strings that were removed from the cache. | ||
public gc() { | ||
const ids = this.getRootIdSet(); | ||
const snapshot = this.toObject(); | ||
ids.forEach(id => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This method for creating a stack is just so cool There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree!! |
||
if (hasOwn.call(snapshot, id)) { | ||
// Because we are iterating over an ECMAScript Set, the IDs we add here | ||
// will be visited in later iterations of the forEach loop only if they | ||
// were not previously contained by the Set. | ||
Object.keys(this.findChildIds(id)).forEach(ids.add, ids); | ||
// By removing IDs from the snapshot object here, we protect them from | ||
// getting removed from the root cache layer below. | ||
delete snapshot[id]; | ||
} | ||
}); | ||
const idsToRemove = Object.keys(snapshot); | ||
if (idsToRemove.length) { | ||
let root: EntityCache = this; | ||
while (root instanceof Layer) root = root.parent; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, so this works up the layers to delete ids There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The way I think about it:
|
||
idsToRemove.forEach(root.delete, root); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. By the way, this idiom is equivalent to idsToRemove.forEach(id => root.delete(id)) except that it does not allocate a new function object. |
||
} | ||
return idsToRemove; | ||
} | ||
|
||
// Lazily tracks { __ref: <dataId> } strings contained by this.data[dataId]. | ||
private refs: { | ||
[dataId: string]: Record<string, true>; | ||
} = Object.create(null); | ||
|
||
public findChildIds(dataId: string): Record<string, true> { | ||
if (!hasOwn.call(this.refs, dataId)) { | ||
const found = this.refs[dataId] = Object.create(null); | ||
// Use the little-known replacer function API of JSON.stringify to find | ||
// { __ref } objects quickly and without a lot of traversal code. | ||
JSON.stringify(this.data[dataId], (_key, value) => { | ||
if (isReference(value)) { | ||
found[value.__ref] = true; | ||
} else if (value && typeof value === "object") { | ||
// Returning the value allows the traversal to continue, which is | ||
// necessary only when the value could contain other values that might | ||
// be reference objects. | ||
return value; | ||
} | ||
}); | ||
} | ||
return this.refs[dataId]; | ||
} | ||
} | ||
|
||
export namespace EntityCache { | ||
|
@@ -107,14 +184,14 @@ export namespace EntityCache { | |
} | ||
|
||
public addLayer( | ||
id: string, | ||
layerId: string, | ||
replay: (layer: EntityCache) => any, | ||
): EntityCache { | ||
// The replay function will be called in the Layer constructor. | ||
return new Layer(id, this, replay, this.sharedLayerDepend); | ||
return new Layer(layerId, this, replay, this.sharedLayerDepend); | ||
} | ||
|
||
public removeLayer(): Root { | ||
public removeLayer(layerId: string): Root { | ||
// Never remove the root layer. | ||
return this; | ||
} | ||
|
@@ -125,27 +202,27 @@ export namespace EntityCache { | |
// of the EntityCache.Root class. | ||
class Layer extends EntityCache { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is probably out of scope for this PR, but I think it would be quite helpful to write a set of comments on the architecture of the cache and its layers. |
||
constructor( | ||
private id: string, | ||
private parent: EntityCache, | ||
private replay: (layer: EntityCache) => any, | ||
public readonly id: string, | ||
public readonly parent: Layer | EntityCache.Root, | ||
public readonly replay: (layer: EntityCache) => any, | ||
public readonly depend: DependType, | ||
) { | ||
super(); | ||
replay(this); | ||
} | ||
|
||
public addLayer( | ||
id: string, | ||
layerId: string, | ||
replay: (layer: EntityCache) => any, | ||
): EntityCache { | ||
return new Layer(id, this, replay, this.depend); | ||
return new Layer(layerId, this, replay, this.depend); | ||
} | ||
|
||
public removeLayer(id: string): EntityCache { | ||
public removeLayer(layerId: string): EntityCache { | ||
// Remove all instances of the given id, not just the first one. | ||
const parent = this.parent.removeLayer(id); | ||
const parent = this.parent.removeLayer(layerId); | ||
|
||
if (id === this.id) { | ||
if (layerId === this.id) { | ||
// Dirty every ID we're removing. | ||
// TODO Some of these IDs could escape dirtying if value unchanged. | ||
if (this.depend) { | ||
|
@@ -168,6 +245,8 @@ class Layer extends EntityCache { | |
}; | ||
} | ||
|
||
// All the other inherited accessor methods work as-is, but the get method | ||
// needs to fall back to this.parent.get when accessing a missing dataId. | ||
public get(dataId: string): StoreObject { | ||
if (hasOwn.call(this.data, dataId)) { | ||
return super.get(dataId); | ||
|
@@ -183,6 +262,22 @@ class Layer extends EntityCache { | |
} | ||
return this.parent.get(dataId); | ||
} | ||
|
||
// Return a Set<string> of all the ID strings that have been retained by this | ||
// Layer *and* any layers/roots beneath it. | ||
public getRootIdSet(): Set<string> { | ||
const ids = this.parent.getRootIdSet(); | ||
benjamn marked this conversation as resolved.
Show resolved
Hide resolved
|
||
super.getRootIdSet().forEach(ids.add, ids); | ||
return ids; | ||
} | ||
|
||
public findChildIds(dataId: string): Record<string, true> { | ||
const fromParent = this.parent.findChildIds(dataId); | ||
return hasOwn.call(this.data, dataId) ? { | ||
...fromParent, | ||
...super.findChildIds(dataId), | ||
} : fromParent; | ||
} | ||
} | ||
|
||
export function supportsResultCaching(store: any): store is EntityCache { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because the
StoreWriter
class updates entities non-destructively, this shallow copy ofthis.data
is sufficient to provide a complete, immutable snapshot of the cache.