-
Notifications
You must be signed in to change notification settings - Fork 82
Feature: Onyx Cache #76
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 13 commits
a343ef1
a7ee841
d23b8eb
a6df3db
231fe00
2f76d80
1046786
273afe1
47739e4
f4a523f
6b659d7
f7feafc
16d42ac
5b11545
0c51923
22ad916
d0c62c5
1b35161
b33e696
4af3abf
ceb4acf
5435063
dc2a540
34cb2aa
dfe362d
f14e5d7
2494a55
ee177e2
55086b4
afd91ca
9231475
4e57857
a27841d
1ff60fc
4eed726
7b35a24
d6f153c
27c0ba8
5254568
77b07e1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,6 +3,7 @@ import AsyncStorage from '@react-native-community/async-storage'; | |
import Str from 'expensify-common/lib/str'; | ||
import lodashMerge from 'lodash/merge'; | ||
import {registerLogger, logInfo, logAlert} from './Logger'; | ||
import cache from './OnyxCache'; | ||
|
||
// Keeps track of the last connectionID that was used so we can keep incrementing it | ||
let lastConnectionID = 0; | ||
|
@@ -34,17 +35,42 @@ let defaultKeyStates = {}; | |
* @returns {Promise<*>} | ||
*/ | ||
function get(key) { | ||
return AsyncStorage.getItem(key) | ||
.then(val => JSON.parse(val)) | ||
.catch(err => logInfo(`Unable to get item from persistent storage. Key: ${key} Error: ${err}`)); | ||
// When we already have the value in cache - resolve right away | ||
if (cache.hasCacheForKey(key)) { | ||
return Promise.resolve(cache.getValue(key)); | ||
} | ||
|
||
/* Otherwise setup a task to retrieve it. | ||
This ensures concurrent usages would not start the same task */ | ||
return cache.resolveTask(`get:${key}`, () => AsyncStorage.getItem(key) | ||
.then((val) => { | ||
// Add values to cache and return parsed result | ||
const parsed = JSON.parse(val); | ||
kidroca marked this conversation as resolved.
Show resolved
Hide resolved
|
||
cache.update(key, parsed); | ||
return parsed; | ||
}) | ||
.catch(err => logInfo(`Unable to get item from persistent storage. Key: ${key} Error: ${err}`))); | ||
} | ||
|
||
/** | ||
* Returns current key names stored in persisted storage | ||
* @returns {Promise<string[]>} | ||
*/ | ||
function getAllKeys() { | ||
return AsyncStorage.getAllKeys(); | ||
// When we've already read stored keys, resolve right away | ||
const storedKeys = cache.getAllKeys(); | ||
if (storedKeys.length > 0) { | ||
return Promise.resolve(storedKeys); | ||
} | ||
|
||
/* Otherwise setup a task to retrieve them. | ||
This ensures concurrent usages would not start the same task */ | ||
return cache.resolveTask('getAllKeys', () => AsyncStorage.getAllKeys() | ||
.then((keys) => { | ||
// Add values to cache and return original result | ||
_.each(keys, key => cache.update(key)); | ||
return keys; | ||
})); | ||
} | ||
|
||
/** | ||
|
@@ -377,8 +403,13 @@ function disconnect(connectionID, keyToRemoveFromEvictionBlocklist) { | |
* @return {Promise} | ||
*/ | ||
function remove(key) { | ||
return AsyncStorage.removeItem(key) | ||
.then(() => keyChanged(key, null)); | ||
// Remove from cache | ||
cache.remove(key); | ||
|
||
// Optimistically inform subscribers | ||
keyChanged(key, null); | ||
|
||
return AsyncStorage.removeItem(key); | ||
} | ||
|
||
/** | ||
|
@@ -414,12 +445,28 @@ function evictStorageAndRetry(error, ionMethod, ...args) { | |
* @returns {Promise} | ||
*/ | ||
function set(key, val) { | ||
// Adds the key to cache when it's not available | ||
cache.update(key, val); | ||
kidroca marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
// Optimistically inform subscribers | ||
keyChanged(key, val); | ||
|
||
// Write the thing to persistent storage, which will trigger a storage event for any other tabs open on this domain | ||
return AsyncStorage.setItem(key, JSON.stringify(val)) | ||
.then(() => keyChanged(key, val)) | ||
.catch(error => evictStorageAndRetry(error, set, key, val)); | ||
} | ||
|
||
/** | ||
* AsyncStorage expects array like: [["@MyApp_user", "value_1"], ["@MyApp_key", "value_2"]] | ||
* This method transforms an object like {'@MyApp_user': 'myUserValue', '@MyApp_key': 'myKeyValue'} | ||
* to an array of key-value pairs in the above format | ||
* @param {Record<string, *>} data | ||
* @return {Array<[string, string]>} an array of stringified key - value pairs | ||
*/ | ||
function prepareKeyValuePairsForStorage(data) { | ||
return _.keys(data).map(key => [key, JSON.stringify(data[key])]); | ||
} | ||
|
||
kidroca marked this conversation as resolved.
Show resolved
Hide resolved
|
||
/** | ||
* Sets multiple keys and values. Example | ||
* Onyx.multiSet({'key1': 'a', 'key2': 'b'}); | ||
|
@@ -428,17 +475,15 @@ function set(key, val) { | |
* @returns {Promise} | ||
*/ | ||
function multiSet(data) { | ||
// AsyncStorage expenses the data in an array like: | ||
// [["@MyApp_user", "value_1"], ["@MyApp_key", "value_2"]] | ||
// This method will transform the params from a better JSON format like: | ||
// {'@MyApp_user': 'myUserValue', '@MyApp_key': 'myKeyValue'} | ||
const keyValuePairs = _.reduce(data, (finalArray, val, key) => ([ | ||
...finalArray, | ||
[key, JSON.stringify(val)], | ||
]), []); | ||
const keyValuePairs = prepareKeyValuePairsForStorage(data); | ||
|
||
// Capture (non-stringified) changes to cache | ||
cache.merge(_.pairs(data)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Isn't this basically doing // previous value {loading: true}
Onyx.multiSet(['someKey', {data: 'test'}]); // -> {loading: true, data: 'test'} and the value should be There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, |
||
|
||
// Optimistically inform subscribers | ||
_.each(data, (val, key) => keyChanged(key, val)); | ||
kidroca marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
return AsyncStorage.multiSet(keyValuePairs) | ||
.then(() => _.each(data, (val, key) => keyChanged(key, val))) | ||
.catch(error => evictStorageAndRetry(error, multiSet, data)); | ||
kidroca marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
|
@@ -522,13 +567,11 @@ function initializeWithDefaultKeyStates() { | |
* @returns {Promise<void>} | ||
*/ | ||
function clear() { | ||
let allKeys; | ||
return getAllKeys() | ||
.then(keys => allKeys = keys) | ||
.then(() => AsyncStorage.clear()) | ||
kidroca marked this conversation as resolved.
Show resolved
Hide resolved
|
||
.then(() => { | ||
_.each(allKeys, (key) => { | ||
.then((keys) => { | ||
_.each(keys, (key) => { | ||
keyChanged(key, null); | ||
cache.remove(key); | ||
}); | ||
|
||
initializeWithDefaultKeyStates(); | ||
|
@@ -551,34 +594,31 @@ function mergeCollection(collectionKey, collection) { | |
} | ||
}); | ||
|
||
const existingKeyCollection = {}; | ||
const newCollection = {}; | ||
return getAllKeys() | ||
.then((keys) => { | ||
_.each(collection, (data, dataKey) => { | ||
if (keys.includes(dataKey)) { | ||
existingKeyCollection[dataKey] = data; | ||
} else { | ||
newCollection[dataKey] = data; | ||
} | ||
}); | ||
kidroca marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
const keyValuePairsForExistingCollection = _.reduce(existingKeyCollection, (finalArray, val, key) => ([ | ||
...finalArray, | ||
[key, JSON.stringify(val)], | ||
]), []); | ||
const keyValuePairsForNewCollection = _.reduce(newCollection, (finalArray, val, key) => ([ | ||
...finalArray, | ||
[key, JSON.stringify(val)], | ||
]), []); | ||
.then((persistedKeys) => { | ||
// Split to keys that exist in storage and keys that don't | ||
const [existingKeys, newKeys] = _.chain(collection) | ||
.keys() | ||
.partition(key => persistedKeys.includes(key)) | ||
.value(); | ||
|
||
const existingKeyCollection = _.pick(collection, existingKeys); | ||
const newCollection = _.pick(collection, newKeys); | ||
const keyValuePairsForExistingCollection = prepareKeyValuePairsForStorage(existingKeyCollection); | ||
const keyValuePairsForNewCollection = prepareKeyValuePairsForStorage(newCollection); | ||
|
||
// New keys will be added via multiSet while existing keys will be updated using multiMerge | ||
// This is because setting a key that doesn't exist yet with multiMerge will throw errors | ||
const existingCollectionPromise = AsyncStorage.multiMerge(keyValuePairsForExistingCollection); | ||
const newCollectionPromise = AsyncStorage.multiSet(keyValuePairsForNewCollection); | ||
|
||
// Capture (non-stringified) changes to cache | ||
cache.merge(_.pairs(collection)); | ||
|
||
// Optimistically inform collection subscribers | ||
keysChanged(collectionKey, collection); | ||
kidroca marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
return Promise.all([existingCollectionPromise, newCollectionPromise]) | ||
.then(() => keysChanged(collectionKey, collection)) | ||
.catch(error => evictStorageAndRetry(error, mergeCollection, collection)); | ||
kidroca marked this conversation as resolved.
Show resolved
Hide resolved
|
||
}); | ||
} | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,137 @@ | ||
import _ from 'underscore'; | ||
|
||
const isDefined = _.negate(_.isUndefined); | ||
|
||
/** | ||
* In memory cache providing data by reference | ||
* Encapsulates Onyx cache related functionality | ||
*/ | ||
class OnyxCache { | ||
constructor() { | ||
/** | ||
* @private | ||
* Cache of all the storage keys available in persistent storage | ||
* @type {Set<string>} | ||
*/ | ||
this.storageKeys = new Set(); | ||
|
||
/** | ||
* @private | ||
* A map of cached values | ||
* @type {Record<string, *>} | ||
*/ | ||
this.storageMap = {}; | ||
roryabraham marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
/** | ||
* @private | ||
* Captured pending tasks for already running storage methods | ||
* @type {Record<string, Promise>} | ||
*/ | ||
this.pendingPromises = {}; | ||
|
||
// bind all methods to prevent problems with `this` | ||
_.bindAll( | ||
this, | ||
'getAllKeys', 'getValue', 'hasCacheForKey', 'update', 'remove', 'merge', 'resolveTask', | ||
); | ||
} | ||
|
||
/** | ||
* Get all the storage keys | ||
* @returns {string[]} | ||
*/ | ||
getAllKeys() { | ||
return [...this.storageKeys]; | ||
kidroca marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
/** | ||
* Get a cached value from storage | ||
* @param {string} key | ||
* @returns {*} | ||
*/ | ||
getValue(key) { | ||
return this.storageMap[key]; | ||
} | ||
|
||
/** | ||
* Check whether cache has data for the given key | ||
* @param {string} key | ||
* @returns {boolean} | ||
*/ | ||
hasCacheForKey(key) { | ||
return key in this.storageMap; | ||
kidroca marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
/** | ||
* Update or add data to cache | ||
* @param {string} key | ||
* @param {*} [value] | ||
*/ | ||
update(key, value) { | ||
// Update all storage keys | ||
kidroca marked this conversation as resolved.
Show resolved
Hide resolved
|
||
this.storageKeys.add(key); | ||
|
||
// When value is provided update general cache as well | ||
if (isDefined(value)) { | ||
kidroca marked this conversation as resolved.
Show resolved
Hide resolved
|
||
this.storageMap[key] = value; | ||
} | ||
} | ||
|
||
/** | ||
* Remove data from cache | ||
* @param {string} key | ||
*/ | ||
remove(key) { | ||
this.storageKeys.delete(key); | ||
delete this.storageMap[key]; | ||
} | ||
|
||
/** | ||
* Merge data to cache, any non existing keys will be crated | ||
* @param {Array<[string, Object]>} pairs - array of key value pairs | ||
*/ | ||
merge(pairs) { | ||
kidroca marked this conversation as resolved.
Show resolved
Hide resolved
|
||
_.forEach(pairs, ([key, value]) => { | ||
const currentValue = _.result(this.storageMap, key, {}); | ||
kidroca marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
if (_.isObject(value) || _.isArray(value)) { | ||
const merged = {...currentValue, ...value}; | ||
kidroca marked this conversation as resolved.
Show resolved
Hide resolved
|
||
this.update(key, merged); | ||
return; | ||
} | ||
|
||
throw new Error(`The provided merge value is invalid: ${value}`); | ||
kidroca marked this conversation as resolved.
Show resolved
Hide resolved
|
||
}); | ||
} | ||
|
||
/** | ||
* Use this method to prevents additional calls from the same thing | ||
* When a promise for a given call already exists the caller would | ||
* be hooked to it. Otherwise the task is started and captured so | ||
* that other callers don't have to start over | ||
* Particularly useful for data retrieving methods | ||
* @param {string} taskName | ||
* @param {function(): Promise} startTask - Provide a promise returning function that | ||
* will be invoked if there is no pending promise for this task | ||
* @returns {Promise} | ||
*/ | ||
resolveTask(taskName, startTask) { | ||
// When a task is already running return it right away | ||
if (isDefined(this.pendingPromises[taskName])) { | ||
return this.pendingPromises[taskName]; | ||
} | ||
|
||
// Otherwise start the task and store a reference | ||
this.pendingPromises[taskName] = startTask() | ||
.finally(() => { | ||
// Cleanup after the task is over | ||
delete this.pendingPromises[taskName]; | ||
}); | ||
|
||
return this.pendingPromises[taskName]; | ||
} | ||
} | ||
|
||
const instance = new OnyxCache(); | ||
roryabraham marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
export default instance; |
Uh oh!
There was an error while loading. Please reload this page.