@samitouri / QOS-React / commits / ae57b125c7

[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 });