[simple-cache-provider] Use LRU cache eviction (#12851)
* [simple-cache-provider] Use LRU cache eviction Max size is hard-coded to 500. In the future, we should make this configurable per resource. * Evict PAGE_SIZE records from cache when max limit is reached
Andrew Clark committed
May 30, 2018 at 13:12 UTC
ae57b125c798418fb6846094cfa911530206e074
2 files changed
+153
-31
packages/simple-cache-provider/src/SimpleCacheProvider.js
+116
-31
@@ -17,43 +17,60 @@ const Pending = 1;
17
const Resolved = 2;
18
const Rejected = 3;
19
20
-type EmptyRecord = {|
20
+type EmptyRecord<K, V> = {|
21
status: 0,
22
suspender: null,
23
+ key: K,
24
value: null,
25
error: null,
26
+ next: Record<K, V> | null,
27
+ previous: Record<K, V> | null,
28
|};
29
27
-type PendingRecord<V> = {|
30
+type PendingRecord<K, V> = {|
31
status: 1,
29
- suspender: Promise<V>,
32
+ suspender: Promise<K, V>,
33
+ key: K,
34
value: null,
35
error: null,
36
+ next: Record<K, V> | null,
37
+ previous: Record<K, V> | null,
38
|};
39
34
-type ResolvedRecord<V> = {|
40
+type ResolvedRecord<K, V> = {|
41
status: 2,
42
suspender: null,
43
+ key: K,
44
value: V,
45
error: null,
46
+ next: Record<K, V> | null,
47
+ previous: Record<K, V> | null,
48
|};
49
41
-type RejectedRecord = {|
50
+type RejectedRecord<K, V> = {|
51
status: 3,
52
suspender: null,
53
+ key: K,
54
value: null,
55
error: Error,
56
+ next: Record<K, V> | null,
57
+ previous: Record<K, V> | null,
58
|};
59
48
-type Record<V> =
49
- | EmptyRecord
50
- | PendingRecord<V>
51
- | ResolvedRecord<V>
52
- | RejectedRecord;
60
+type Record<K, V> =
61
+ | EmptyRecord<K, V>
62
+ | PendingRecord<K, V>
63
+ | ResolvedRecord<K, V>
64
+ | RejectedRecord<K, V>;
65
+
66
+type RecordCache<K, V> = {|
67
+ map: Map<K, Record<K, V>>,
68
+ head: Record<K, V> | null,
69
+ tail: Record<K, V> | null,
70
+|};
71
54
-type RecordCache<K, V> = Map<K, Record<V>>;
72
// TODO: How do you express this type with Flow?
56
-type ResourceCache = Map<any, RecordCache<any, any>>;
73
+type ResourceMap = Map<any, RecordCache<any, any>>;
74
type Cache = {
75
invalidate(): void,
76
read<K, V, A>(
@@ -86,10 +103,35 @@ if (__DEV__) {
103
value.$$typeof === CACHE_TYPE;
104
}
105
106
+// TODO: Make this configurable per resource
107
+const MAX_SIZE = 500;
108
+const PAGE_SIZE = 50;
109
+
110
+function createRecord<K, V>(key: K): EmptyRecord<K, V> {
111
+ return {
112
+ status: Empty,
113
+ suspender: null,
114
+ key,
115
+ value: null,
116
+ error: null,
117
+ next: null,
118
+ previous: null,
119
+ };
120
+}
121
+
122
+function createRecordCache<K, V>(): RecordCache<K, V> {
123
+ return {
124
+ map: new Map(),
125
+ head: null,
126
+ tail: null,
127
+ size: 0,
128
+ };
129
+}
130
+
131
export function createCache(invalidator: () => mixed): Cache {
90
- const resourceCache: ResourceCache = new Map();
132
+ const resourceMap: ResourceMap = new Map();
133
92
- function getRecord<K, V>(resourceType: any, key: K): Record<V> {
134
+ function accessRecord<K, V>(resourceType: any, key: K): Record<V> {
135
if (__DEV__) {
136
warning(
137
typeof resourceType !== 'string' && typeof resourceType !== 'number',
@@ -100,25 +142,68 @@ export function createCache(invalidator: () => mixed): Cache {
142
);
143
}
144
103
- let recordCache = resourceCache.get(resourceType);
104
- if (recordCache !== undefined) {
105
- const record = recordCache.get(key);
106
- if (record !== undefined) {
107
- return record;
145
+ let recordCache = resourceMap.get(resourceType);
146
+ if (recordCache === undefined) {
147
+ recordCache = createRecordCache();
148
+ resourceMap.set(resourceType, recordCache);
149
+ }
150
+ const map = recordCache.map;
151
+
152
+ let record = map.get(key);
153
+ if (record === undefined) {
154
+ // This record does not already exist. Create a new one.
155
+ record = createRecord(key);
156
+ map.set(key, record);
157
+ if (recordCache.size >= MAX_SIZE) {
158
+ // The cache is already at maximum capacity. Remove PAGE_SIZE least
159
+ // recently used records.
160
+ // TODO: We assume the max capcity is greater than zero. Otherwise warn.
161
+ const tail = recordCache.tail;
162
+ if (tail !== null) {
163
+ let newTail = tail;
164
+ for (let i = 0; i < PAGE_SIZE && newTail !== null; i++) {
165
+ recordCache.size -= 1;
166
+ map.delete(newTail.key);
167
+ newTail = newTail.previous;
168
+ }
169
+ recordCache.tail = newTail;
170
+ if (newTail !== null) {
171
+ newTail.next = null;
172
+ }
173
+ }
174
+ }
175
+ } else {
176
+ // This record is already cached. Remove it from its current position in
177
+ // the list. We'll add it to the front below.
178
+ const previous = record.previous;
179
+ const next = record.next;
180
+ if (previous !== null) {
181
+ previous.next = next;
182
+ } else {
183
+ recordCache.head = next;
184
+ }
185
+ if (next !== null) {
186
+ next.previous = previous;
187
+ } else {
188
+ recordCache.tail = previous;
189
}
190
+ recordCache.size -= 1;
191
+ }
192
+
193
+ // Add the record to the front of the list.
194
+ const head = recordCache.head;
195
+ const newHead = record;
196
+ recordCache.head = newHead;
197
+ newHead.previous = null;
198
+ newHead.next = head;
199
+ if (head !== null) {
200
+ head.previous = newHead;
201
} else {
110
- recordCache = new Map();
111
- resourceCache.set(resourceType, recordCache);
202
+ recordCache.tail = newHead;
203
}
204
+ recordCache.size += 1;
205
114
- const record = {
115
- status: Empty,
116
- suspender: null,
117
- value: null,
118
- error: null,
119
- };
120
- recordCache.set(key, record);
121
- return record;
206
+ return newHead;
207
}
208
209
function load<V>(emptyRecord: EmptyRecord, suspender: Promise<V>) {
@@ -154,7 +239,7 @@ export function createCache(invalidator: () => mixed): Cache {
239
miss: A => Promise<V>,
240
missArg: A,
241
): void {
157
- const record: Record<V> = getRecord(resourceType, key);
242
+ const record: Record<V> = accessRecord(resourceType, key);
243
switch (record.status) {
244
case Empty:
245
// Warm the cache.
@@ -178,7 +263,7 @@ export function createCache(invalidator: () => mixed): Cache {
263
miss: A => Promise<V>,
264
missArg: A,
265
): V {
181
- const record: Record<V> = getRecord(resourceType, key);
266
+ const record: Record<V> = accessRecord(resourceType, key);
267
switch (record.status) {
268
case Empty:
269
// Load the requested resource.
packages/simple-cache-provider/src/__tests__/SimpleCacheProvider-test.js
+37
@@ -189,4 +189,41 @@ describe('SimpleCacheProvider', () => {
189
fn();
190
}
191
});
192
+
193
+ it('stays within maximum capacity by evicting the least recently used record', async () => {
194
+ const {createCache, createResource} = SimpleCacheProvider;
195
+
196
+ function loadIntegerString(int) {
197
+ return Promise.resolve(int + '');
198
+ }
199
+ const IntegerStringResource = createResource(loadIntegerString);
200
+ const cache = createCache();
201
+
202
+ // TODO: This is hard-coded to a maximum size of 500. Make this configurable
203
+ // per resource.
204
+ for (let n = 1; n <= 500; n++) {
205
+ IntegerStringResource.preload(cache, n);
206
+ }
207
+
208
+ // Access 1, 2, and 3 again. The least recently used integer is now 4.
209
+ IntegerStringResource.preload(cache, 3);
210
+ IntegerStringResource.preload(cache, 2);
211
+ IntegerStringResource.preload(cache, 1);
212
+
213
+ // Evict older integers from the cache by adding new ones.
214
+ IntegerStringResource.preload(cache, 501);
215
+ IntegerStringResource.preload(cache, 502);
216
+ IntegerStringResource.preload(cache, 503);
217
+
218
+ await Promise.resolve();
219
+
220
+ // 1, 2, and 3 should be in the cache. 4, 5, and 6 should have been evicted.
221
+ expect(IntegerStringResource.read(cache, 1)).toEqual('1');
222
+ expect(IntegerStringResource.read(cache, 2)).toEqual('2');
223
+ expect(IntegerStringResource.read(cache, 3)).toEqual('3');
224
+
225
+ expect(() => IntegerStringResource.read(cache, 4)).toThrow(Promise);
226
+ expect(() => IntegerStringResource.read(cache, 5)).toThrow(Promise);
227
+ expect(() => IntegerStringResource.read(cache, 6)).toThrow(Promise);
228
+ });
229
});