Skip to content

Commit 90ea034

Browse files
committed
Add caching abstraction
Introduce a functional caching abstraction. The purpose is to provide extensible API with abstractions that are composable. Includes implementations for basic cache strategy (current) and an LRU cache (which wraps BasicCache). A custom caching strategy can be injected via Reactor constructor. All existing caching behavior remains the same (for now).
1 parent ae66da7 commit 90ea034

File tree

5 files changed

+368
-65
lines changed

5 files changed

+368
-65
lines changed

src/reactor.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Immutable from 'immutable'
22
import createReactMixin from './create-react-mixin'
33
import * as fns from './reactor/fns'
4+
import { DefaultCache } from './reactor/cache'
45
import { isKeyPath } from './key-path'
56
import { isGetter } from './getter'
67
import { toJS } from './immutable-helpers'
@@ -27,6 +28,7 @@ class Reactor {
2728
const baseOptions = debug ? DEBUG_OPTIONS : PROD_OPTIONS
2829
const initialReactorState = new ReactorState({
2930
debug: debug,
31+
cache: config.cache || DefaultCache(),
3032
// merge config options with the defaults
3133
options: baseOptions.merge(config.options || {}),
3234
})

src/reactor/cache.js

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
import { List, Map, OrderedMap, Record } from 'immutable'
2+
3+
export const CacheEntry = Record({
4+
value: null,
5+
storeStates: Map(),
6+
dispatchId: null,
7+
})
8+
9+
/*******************************************************************************
10+
* interface PersistentCache {
11+
* has(item)
12+
* lookup(item, notFoundValue)
13+
* hit(item)
14+
* miss(item, entry)
15+
* evict(item)
16+
* asMap()
17+
* }
18+
*******************************************************************************/
19+
20+
/**
21+
* Plain map-based cache
22+
*/
23+
export class BasicCache {
24+
25+
/**
26+
* @param {Immutable.Map} cache
27+
*/
28+
constructor(cache = Map()) {
29+
this.cache = cache;
30+
}
31+
32+
/**
33+
* Retrieve the associated value, if it exists in this cache, otherwise
34+
* returns notFoundValue (or undefined if not provided)
35+
* @param {Object} item
36+
* @param {Object?} notFoundValue
37+
* @return {CacheEntry?}
38+
*/
39+
lookup(item, notFoundValue) {
40+
return this.cache.get(item, notFoundValue)
41+
}
42+
43+
/**
44+
* Checks if this cache contains an associated value
45+
* @param {Object} item
46+
* @return {boolean}
47+
*/
48+
has(item) {
49+
return this.cache.has(item)
50+
}
51+
52+
/**
53+
* Return cached items as map
54+
* @return {Immutable.Map}
55+
*/
56+
asMap() {
57+
return this.cache
58+
}
59+
60+
/**
61+
* Updates this cache when it is determined to contain the associated value
62+
* @param {Object} item
63+
* @return {BasicCache}
64+
*/
65+
hit(item) {
66+
return this;
67+
}
68+
69+
/**
70+
* Updates this cache when it is determined to **not** contain the associated value
71+
* @param {Object} item
72+
* @param {CacheEntry} entry
73+
* @return {BasicCache}
74+
*/
75+
miss(item, entry) {
76+
return new BasicCache(
77+
this.cache.update(item, existingEntry => {
78+
if (existingEntry && existingEntry.dispatchId > entry.dispatchId) {
79+
throw new Error("Refusing to cache older value")
80+
}
81+
return entry
82+
})
83+
)
84+
}
85+
86+
/**
87+
* Removes entry from cache
88+
* @param {Object} item
89+
* @return {BasicCache}
90+
*/
91+
evict(item) {
92+
return new BasicCache(this.cache.remove(item))
93+
}
94+
}
95+
96+
/**
97+
* Implements caching strategy that evicts least-recently-used items in cache
98+
* when an item is being added to a cache that has reached a configured size
99+
* limit.
100+
*/
101+
export class LRUCache {
102+
103+
constructor(limit = 1000, cache = new BasicCache(), lru = OrderedMap(), tick = 0) {
104+
this.limit = limit;
105+
this.cache = cache;
106+
this.lru = lru;
107+
this.tick = tick;
108+
}
109+
110+
/**
111+
* Retrieve the associated value, if it exists in this cache, otherwise
112+
* returns notFoundValue (or undefined if not provided)
113+
* @param {Object} item
114+
* @param {Object?} notFoundValue
115+
* @return {CacheEntry}
116+
*/
117+
lookup(item, notFoundValue) {
118+
return this.cache.lookup(item, notFoundValue)
119+
}
120+
121+
/**
122+
* Checks if this cache contains an associated value
123+
* @param {Object} item
124+
* @return {boolean}
125+
*/
126+
has(item) {
127+
return this.cache.has(item)
128+
}
129+
130+
/**
131+
* Return cached items as map
132+
* @return {Immutable.Map}
133+
*/
134+
asMap() {
135+
return this.cache.asMap()
136+
}
137+
138+
/**
139+
* Updates this cache when it is determined to contain the associated value
140+
* @param {Object} item
141+
* @return {LRUCache}
142+
*/
143+
hit(item) {
144+
const nextTick = this.tick + 1;
145+
146+
const lru = this.cache.lookup(item) ?
147+
this.lru.remove(item).set(item, nextTick) :
148+
this.lru;
149+
150+
return new LRUCache(this.limit, this.cache, lru, nextTick)
151+
}
152+
153+
/**
154+
* Updates this cache when it is determined to **not** contain the associated value
155+
* If cache has reached size limit, the LRU item is evicted.
156+
* @param {Object} item
157+
* @param {CacheEntry} entry
158+
* @return {LRUCache}
159+
*/
160+
miss(item, entry) {
161+
const nextTick = this.tick + 1;
162+
163+
if (this.lru.size >= this.limit) {
164+
// TODO add options to clear multiple items at once
165+
const evictItem = this.has(item) ? item : this.lru.keySeq().first()
166+
167+
return new LRUCache(
168+
this.limit,
169+
this.cache.evict(evictItem).miss(item, entry),
170+
this.lru.remove(evictItem).set(item, nextTick),
171+
nextTick
172+
)
173+
} else {
174+
return new LRUCache(
175+
this.limit,
176+
this.cache.miss(item, entry),
177+
this.lru.set(item, nextTick),
178+
nextTick
179+
)
180+
}
181+
}
182+
183+
/**
184+
* Removes entry from cache
185+
* @param {Object} item
186+
* @return {LRUCache}
187+
*/
188+
evict(item) {
189+
if (!this.cache.has(item)) {
190+
return this;
191+
}
192+
193+
return new LRUCache(
194+
this.limit,
195+
this.cache.evict(item),
196+
this.lru.remove(item),
197+
this.tick + 1
198+
)
199+
}
200+
}
201+
202+
/**
203+
* Returns default cache strategy
204+
* @return {BasicCache}
205+
*/
206+
export function DefaultCache() {
207+
return new BasicCache()
208+
}

src/reactor/fns.js

Lines changed: 30 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import Immutable from 'immutable'
22
import logging from '../logging'
3+
import { CacheEntry } from './cache'
34
import { isImmutableValue } from '../immutable-helpers'
45
import { toImmutable } from '../immutable-helpers'
56
import { fromKeyPath, getStoreDeps, getComputeFn, getDeps, isGetter } from '../getter'
@@ -330,22 +331,21 @@ export function evaluate(reactorState, keyPathOrGetter) {
330331
}
331332

332333
// Must be a Getter
333-
// if the value is cached for this dispatch cycle, return the cached value
334-
if (isCached(reactorState, keyPathOrGetter)) {
335-
// Cache hit
336-
return evaluateResult(
337-
getCachedValue(reactorState, keyPathOrGetter),
338-
reactorState
339-
)
340-
}
341334

342-
// evaluate dependencies
343-
const args = getDeps(keyPathOrGetter).map(dep => evaluate(reactorState, dep).result)
344-
const evaluatedValue = getComputeFn(keyPathOrGetter).apply(null, args)
335+
const cache = reactorState.get('cache')
336+
var cacheEntry = cache.lookup(keyPathOrGetter)
337+
const isCacheMiss = !cacheEntry
338+
if (isCacheMiss || isDirtyCacheEntry(reactorState, cacheEntry)) {
339+
cacheEntry = createCacheEntry(reactorState, keyPathOrGetter)
340+
}
345341

346342
return evaluateResult(
347-
evaluatedValue,
348-
cacheValue(reactorState, keyPathOrGetter, evaluatedValue)
343+
cacheEntry.get('value'),
344+
reactorState.update('cache', cache => {
345+
return isCacheMiss ?
346+
cache.miss(keyPathOrGetter, cacheEntry) :
347+
cache.hit(keyPathOrGetter)
348+
})
349349
)
350350
}
351351

@@ -375,57 +375,31 @@ export function resetDirtyStores(reactorState) {
375375
return reactorState.set('dirtyStores', Immutable.Set())
376376
}
377377

378-
/**
379-
* Currently cache keys are always getters by reference
380-
* @param {Getter} getter
381-
* @return {Getter}
382-
*/
383-
function getCacheKey(getter) {
384-
return getter
385-
}
386-
387378
/**
388379
* @param {ReactorState} reactorState
389-
* @param {Getter|KeyPath} keyPathOrGetter
390-
* @return {Immutable.Map}
380+
* @param {CacheEntry} cacheEntry
381+
* @return {boolean}
391382
*/
392-
function getCacheEntry(reactorState, keyPathOrGetter) {
393-
const key = getCacheKey(keyPathOrGetter)
394-
return reactorState.getIn(['cache', key])
395-
}
383+
function isDirtyCacheEntry(reactorState, cacheEntry) {
384+
const storeStates = cacheEntry.get('storeStates')
396385

397-
/**
398-
* @param {ReactorState} reactorState
399-
* @param {Getter} getter
400-
* @return {Boolean}
401-
*/
402-
function isCached(reactorState, keyPathOrGetter) {
403-
const entry = getCacheEntry(reactorState, keyPathOrGetter)
404-
if (!entry) {
405-
return false
406-
}
407-
408-
const storeStates = entry.get('storeStates')
409-
if (storeStates.size === 0) {
410-
// if there are no store states for this entry then it was never cached before
411-
return false
412-
}
413-
414-
return storeStates.every((stateId, storeId) => {
415-
return reactorState.getIn(['storeStates', storeId]) === stateId
386+
// if there are no store states for this entry then it was never cached before
387+
return !storeStates.size || storeStates.some((stateId, storeId) => {
388+
return reactorState.getIn(['storeStates', storeId]) !== stateId
416389
})
417390
}
418391

419392
/**
420-
* Caches the value of a getter given state, getter, args, value
393+
* Evaluates getter for given reactorState and returns CacheEntry
421394
* @param {ReactorState} reactorState
422395
* @param {Getter} getter
423-
* @param {*} value
424-
* @return {ReactorState}
396+
* @return {CacheEntry}
425397
*/
426-
function cacheValue(reactorState, getter, value) {
427-
const cacheKey = getCacheKey(getter)
428-
const dispatchId = reactorState.get('dispatchId')
398+
function createCacheEntry(reactorState, getter) {
399+
// evaluate dependencies
400+
const args = getDeps(getter).map(dep => evaluate(reactorState, dep).result)
401+
const value = getComputeFn(getter).apply(null, args)
402+
429403
const storeDeps = getStoreDeps(getter)
430404
const storeStates = toImmutable({}).withMutations(map => {
431405
storeDeps.forEach(storeId => {
@@ -434,19 +408,11 @@ function cacheValue(reactorState, getter, value) {
434408
})
435409
})
436410

437-
return reactorState.setIn(['cache', cacheKey], Immutable.Map({
411+
return CacheEntry({
438412
value: value,
439413
storeStates: storeStates,
440-
dispatchId: dispatchId,
441-
}))
442-
}
443-
444-
/**
445-
* Pulls out the cached value for a getter
446-
*/
447-
function getCachedValue(reactorState, getter) {
448-
const key = getCacheKey(getter)
449-
return reactorState.getIn(['cache', key, 'value'])
414+
dispatchId: reactorState.get('dispatchId'),
415+
})
450416
}
451417

452418
/**

src/reactor/records.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Map, Set, Record } from 'immutable'
2+
import { DefaultCache } from './cache'
23

34
export const PROD_OPTIONS = Map({
45
// logs information for each dispatch
@@ -38,7 +39,7 @@ export const ReactorState = Record({
3839
dispatchId: 0,
3940
state: Map(),
4041
stores: Map(),
41-
cache: Map(),
42+
cache: DefaultCache(),
4243
// maintains a mapping of storeId => state id (monotomically increasing integer whenever store state changes)
4344
storeStates: Map(),
4445
dirtyStores: Set(),

0 commit comments

Comments
 (0)