Skip to content

Commit e6338d9

Browse files
committed
Refactor getter caching based on keypath state
The current version of NuclearJS uses a cache key consisting of store states (monotomically incresing ID per store). This has the disadvantage of allowing only a single level of depth when figuring out if a cache entry is stale. This leads to poor performance when the shape of a Reactor's state is more deep than wide, ie a store having multiple responsibilities for state tracking. The implementation is as follows: - Consumer can set the maxCacheDepth when instantiating a reactor - Getters are broken down into the canonical set of keypaths based on the maxCacheDepth - Add a keypath tracker abstraction to maintain the state value of all tracked keypaths - After any state change (`Reactor.__notify`) dirty keypaths are resolved and then based on which keypaths have changed any dependent observers are called
1 parent d91f6d3 commit e6338d9

File tree

11 files changed

+1523
-353
lines changed

11 files changed

+1523
-353
lines changed

src/getter.js

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ function getFlattenedDeps(getter, existing) {
5454

5555
getDeps(getter).forEach(dep => {
5656
if (isKeyPath(dep)) {
57-
set.add(List(dep))
57+
set.add(Immutable.List(dep))
5858
} else if (isGetter(dep)) {
5959
set.union(getFlattenedDeps(dep))
6060
} else {
@@ -66,6 +66,45 @@ function getFlattenedDeps(getter, existing) {
6666
return existing.union(toAdd)
6767
}
6868

69+
/**
70+
* Returns a set of deps that have been flattened and expanded
71+
* expanded ex: ['store1', 'key1'] => [['store1'], ['store1', 'key1']]
72+
*
73+
* Note: returns a keypath as an Immutable.List(['store1', 'key1')
74+
* @param {Getter} getter
75+
* @param {Number} maxDepth
76+
* @return {Immutable.Set}
77+
*/
78+
function getCanonicalKeypathDeps(getter, maxDepth) {
79+
if (maxDepth === undefined) {
80+
throw new Error('Must supply maxDepth argument')
81+
}
82+
83+
const cacheKey = `__storeDeps_${maxDepth}`
84+
if (getter.hasOwnProperty(cacheKey)) {
85+
return getter[cacheKey]
86+
}
87+
88+
const deps = Immutable.Set().withMutations(set => {
89+
getFlattenedDeps(getter).forEach(keypath => {
90+
if (keypath.size <= maxDepth) {
91+
set.add(keypath)
92+
} else {
93+
set.add(keypath.slice(0, maxDepth))
94+
}
95+
})
96+
})
97+
98+
Object.defineProperty(getter, cacheKey, {
99+
enumerable: false,
100+
configurable: false,
101+
writable: false,
102+
value: deps,
103+
})
104+
105+
return deps
106+
}
107+
69108
/**
70109
* @param {KeyPath}
71110
* @return {Getter}
@@ -88,7 +127,6 @@ function getStoreDeps(getter) {
88127
}
89128

90129
const storeDeps = getFlattenedDeps(getter)
91-
.map(keyPath => keyPath.first())
92130
.filter(x => !!x)
93131

94132

@@ -106,6 +144,7 @@ export default {
106144
isGetter,
107145
getComputeFn,
108146
getFlattenedDeps,
147+
getCanonicalKeypathDeps,
109148
getStoreDeps,
110149
getDeps,
111150
fromKeyPath,

src/key-path.js

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,3 @@ export function isKeyPath(toTest) {
1313
)
1414
}
1515

16-
/**
17-
* Checks if two keypaths are equal by value
18-
* @param {KeyPath} a
19-
* @param {KeyPath} a
20-
* @return {Boolean}
21-
*/
22-
export function isEqual(a, b) {
23-
const iA = Immutable.List(a)
24-
const iB = Immutable.List(b)
25-
26-
return Immutable.is(iA, iB)
27-
}

src/reactor.js

Lines changed: 50 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as fns from './reactor/fns'
44
import { DefaultCache } from './reactor/cache'
55
import { ConsoleGroupLogger } from './logging'
66
import { isKeyPath } from './key-path'
7-
import { isGetter } from './getter'
7+
import { isGetter, getCanonicalKeypathDeps } from './getter'
88
import { toJS } from './immutable-helpers'
99
import { extend, toFactory } from './utils'
1010
import {
@@ -60,7 +60,20 @@ class Reactor {
6060
* @return {*}
6161
*/
6262
evaluate(keyPathOrGetter) {
63-
let { result, reactorState } = fns.evaluate(this.reactorState, keyPathOrGetter)
63+
// look through the keypathStates and see if any of the getters dependencies are dirty, if so resolve
64+
// against the previous reactor state
65+
let updatedReactorState = this.reactorState
66+
if (!isKeyPath(keyPathOrGetter)) {
67+
const maxCacheDepth = fns.getOption(updatedReactorState, 'maxCacheDepth')
68+
let res = fns.resolveDirtyKeypathStates(
69+
this.prevReactorState,
70+
this.reactorState,
71+
getCanonicalKeypathDeps(keyPathOrGetter, maxCacheDepth)
72+
)
73+
updatedReactorState = res.reactorState
74+
}
75+
76+
let { result, reactorState } = fns.evaluate(updatedReactorState, keyPathOrGetter)
6477
this.reactorState = reactorState
6578
return result
6679
}
@@ -95,10 +108,10 @@ class Reactor {
95108
handler = getter
96109
getter = []
97110
}
98-
let { observerState, entry } = fns.addObserver(this.observerState, getter, handler)
111+
let { observerState, entry } = fns.addObserver(this.reactorState, this.observerState, getter, handler)
99112
this.observerState = observerState
100113
return () => {
101-
this.observerState = fns.removeObserverByEntry(this.observerState, entry)
114+
this.observerState = fns.removeObserverByEntry(this.reactorState, this.observerState, entry)
102115
}
103116
}
104117

@@ -110,7 +123,7 @@ class Reactor {
110123
throw new Error('Must call unobserve with a Getter')
111124
}
112125

113-
this.observerState = fns.removeObserver(this.observerState, getter, handler)
126+
this.observerState = fns.removeObserver(this.reactorState, this.observerState, getter, handler)
114127
}
115128

116129
/**
@@ -130,6 +143,7 @@ class Reactor {
130143
}
131144

132145
try {
146+
this.prevReactorState = this.reactorState
133147
this.reactorState = fns.dispatch(this.reactorState, actionType, payload)
134148
} catch (e) {
135149
this.__isDispatching = false
@@ -171,6 +185,7 @@ class Reactor {
171185
* @param {Object} stores
172186
*/
173187
registerStores(stores) {
188+
this.prevReactorState = this.reactorState
174189
this.reactorState = fns.registerStores(this.reactorState, stores)
175190
this.__notify()
176191
}
@@ -196,6 +211,7 @@ class Reactor {
196211
* @param {Object} state
197212
*/
198213
loadState(state) {
214+
this.prevReactorState = this.reactorState
199215
this.reactorState = fns.loadState(this.reactorState, state)
200216
this.__notify()
201217
}
@@ -210,6 +226,15 @@ class Reactor {
210226
this.observerState = new ObserverState()
211227
}
212228

229+
/**
230+
* Denotes a new state, via a store registration, dispatch or some other method
231+
* Resolves any outstanding keypath states and sets a new reactorState
232+
* @private
233+
*/
234+
__nextState(newState) {
235+
// TODO(jordan): determine if this is actually needed
236+
}
237+
213238
/**
214239
* Notifies all change observers with the current state
215240
* @private
@@ -220,36 +245,36 @@ class Reactor {
220245
return
221246
}
222247

223-
const dirtyStores = this.reactorState.get('dirtyStores')
224-
if (dirtyStores.size === 0) {
225-
return
226-
}
227-
228248
fns.getLoggerFunction(this.reactorState, 'notifyStart')(this.reactorState, this.observerState)
229249

230-
let observerIdsToNotify = Immutable.Set().withMutations(set => {
231-
// notify all observers
232-
set.union(this.observerState.get('any'))
250+
const keypathsToResolve = this.observerState.get('trackedKeypaths')
251+
const { reactorState, changedKeypaths } = fns.resolveDirtyKeypathStates(
252+
this.prevReactorState,
253+
this.reactorState,
254+
keypathsToResolve,
255+
true // increment all dirty states (this should leave no unknown state in the keypath tracker map):
256+
)
257+
this.reactorState = reactorState
233258

234-
dirtyStores.forEach(id => {
235-
const entries = this.observerState.getIn(['stores', id])
236-
if (!entries) {
237-
return
259+
// get observers to notify based on the keypaths that changed
260+
let observersToNotify = Immutable.Set().withMutations(set => {
261+
changedKeypaths.forEach(keypath => {
262+
const entries = this.observerState.getIn(['keypathToEntries', keypath])
263+
if (entries && entries.size > 0) {
264+
set.union(entries)
238265
}
239-
set.union(entries)
240266
})
241267
})
242268

243-
observerIdsToNotify.forEach((observerId) => {
244-
const entry = this.observerState.getIn(['observersMap', observerId])
245-
if (!entry) {
246-
// don't notify here in the case a handler called unobserve on another observer
269+
observersToNotify.forEach((observer) => {
270+
if (!this.observerState.get('observers').has(observer)) {
271+
// the observer was removed in a hander function
247272
return
248273
}
249274
let didCall = false
250275

251-
const getter = entry.get('getter')
252-
const handler = entry.get('handler')
276+
const getter = observer.get('getter')
277+
const handler = observer.get('handler')
253278

254279
fns.getLoggerFunction(this.reactorState, 'notifyEvaluateStart')(this.reactorState, getter)
255280

@@ -262,18 +287,14 @@ class Reactor {
262287
const prevValue = prevEvaluateResult.result
263288
const currValue = currEvaluateResult.result
264289

290+
// TODO pull some comparator function out of the reactorState
265291
if (!Immutable.is(prevValue, currValue)) {
266292
handler.call(null, currValue)
267293
didCall = true
268294
}
269295
fns.getLoggerFunction(this.reactorState, 'notifyEvaluateEnd')(this.reactorState, getter, didCall, currValue)
270296
})
271297

272-
const nextReactorState = fns.resetDirtyStores(this.reactorState)
273-
274-
this.prevReactorState = nextReactorState
275-
this.reactorState = nextReactorState
276-
277298
fns.getLoggerFunction(this.reactorState, 'notifyEnd')(this.reactorState, this.observerState)
278299
}
279300

src/reactor/cache.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Map, OrderedSet, Record } from 'immutable'
22

33
export const CacheEntry = Record({
44
value: null,
5-
storeStates: Map(),
5+
states: Map(),
66
dispatchId: null,
77
})
88

0 commit comments

Comments
 (0)