Skip to content

Commit b719efb

Browse files
committed
Merge pull request #211 from optimizely/ll/cache-abstraction
Caching Abstraction
2 parents 1444ce6 + 4fb6b8c commit b719efb

File tree

7 files changed

+412
-67
lines changed

7 files changed

+412
-67
lines changed

docs/package.json

-2
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,6 @@
2323
"nuclear-js": "^1.0.5",
2424
"webpack": "^1.9.11",
2525
"webpack-dev-server": "^1.9.0",
26-
"grunt-concurrent": "^1.0.0",
27-
"grunt-contrib-connect": "^0.10.1",
2826
"remarkable": "^1.6.0",
2927
"front-matter": "^1.0.0",
3028
"glob": "^5.0.10",

src/reactor.js

+2
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

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

src/reactor/fns.js

+30-64
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 || isDirtyCacheEntry(reactorState, cacheEntry)
338+
if (isCacheMiss) {
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

+2-1
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)