Skip to content

Commit 4f341cf

Browse files
authored
Merge pull request #76 from kidroca/kidroca/onyx-cache
Feature: Onyx Cache
2 parents 0cdf133 + 77b07e1 commit 4f341cf

File tree

8 files changed

+1067
-124
lines changed

8 files changed

+1067
-124
lines changed

lib/Onyx.js

Lines changed: 151 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import AsyncStorage from '@react-native-community/async-storage';
33
import Str from 'expensify-common/lib/str';
44
import lodashMerge from 'lodash/merge';
55
import {registerLogger, logInfo, logAlert} from './Logger';
6+
import cache from './OnyxCache';
67

78
// Keeps track of the last connectionID that was used so we can keep incrementing it
89
let lastConnectionID = 0;
@@ -34,17 +35,56 @@ let defaultKeyStates = {};
3435
* @returns {Promise<*>}
3536
*/
3637
function get(key) {
37-
return AsyncStorage.getItem(key)
38-
.then(val => JSON.parse(val))
38+
// When we already have the value in cache - resolve right away
39+
if (cache.hasCacheForKey(key)) {
40+
return Promise.resolve(cache.getValue(key));
41+
}
42+
43+
const taskName = `get:${key}`;
44+
45+
// When a value retrieving task for this key is still running hook to it
46+
if (cache.hasPendingTask(taskName)) {
47+
return cache.getTaskPromise(taskName);
48+
}
49+
50+
// Otherwise retrieve the value from storage and capture a promise to aid concurrent usages
51+
const promise = AsyncStorage.getItem(key)
52+
.then((val) => {
53+
const parsed = val && JSON.parse(val);
54+
cache.set(key, parsed);
55+
return parsed;
56+
})
3957
.catch(err => logInfo(`Unable to get item from persistent storage. Key: ${key} Error: ${err}`));
58+
59+
return cache.captureTask(taskName, promise);
4060
}
4161

4262
/**
4363
* Returns current key names stored in persisted storage
4464
* @returns {Promise<string[]>}
4565
*/
4666
function getAllKeys() {
47-
return AsyncStorage.getAllKeys();
67+
// When we've already read stored keys, resolve right away
68+
const storedKeys = cache.getAllKeys();
69+
if (storedKeys.length > 0) {
70+
return Promise.resolve(storedKeys);
71+
}
72+
73+
const taskName = 'getAllKeys';
74+
75+
// When a value retrieving task for all keys is still running hook to it
76+
if (cache.hasPendingTask(taskName)) {
77+
return cache.getTaskPromise(taskName);
78+
}
79+
80+
// Otherwise retrieve the keys from storage and capture a promise to aid concurrent usages
81+
const promise = AsyncStorage.getAllKeys()
82+
.then((keys) => {
83+
_.each(keys, key => cache.addKey(key));
84+
return keys;
85+
});
86+
87+
return cache.captureTask(taskName, promise);
4888
}
4989

5090
/**
@@ -58,6 +98,19 @@ function isCollectionKey(key) {
5898
return _.contains(_.values(onyxKeys.COLLECTION), key);
5999
}
60100

101+
/**
102+
* Find the collection a collection item belongs to
103+
* or return null if them item is not a part of a collection
104+
* @param {string} key
105+
* @returns {string|null}
106+
*/
107+
function getCollectionKeyForItem(key) {
108+
return _.chain(onyxKeys.COLLECTION)
109+
.values()
110+
.find(name => key.startsWith(name))
111+
.value();
112+
}
113+
61114
/**
62115
* Checks to see if a given key matches with the
63116
* configured key of our connected subscriber
@@ -350,6 +403,43 @@ function connect(mapping) {
350403
return connectionID;
351404
}
352405

406+
/**
407+
* Remove cache items that are no longer connected through Onyx
408+
* @param {string} key
409+
*/
410+
function cleanCache(key) {
411+
const hasRemainingConnections = _.some(callbackToStateMapping, {key});
412+
413+
// When the key is still used in other places don't remove it from cache
414+
if (hasRemainingConnections) {
415+
return;
416+
}
417+
418+
// When this is a collection - also recursively remove any unused individual items
419+
if (isCollectionKey(key)) {
420+
cache.remove(key);
421+
422+
getAllKeys().then(cachedKeys => _.chain(cachedKeys)
423+
.filter(name => name.startsWith(key))
424+
.forEach(cleanCache));
425+
426+
return;
427+
}
428+
429+
// When this is a collection item - check if the collection is still used
430+
const collectionKey = getCollectionKeyForItem(key);
431+
if (collectionKey) {
432+
// When there's an active subscription for a collection don't remove the item
433+
const hasRemainingConnectionsForCollection = _.some(callbackToStateMapping, {key: collectionKey});
434+
if (hasRemainingConnectionsForCollection) {
435+
return;
436+
}
437+
}
438+
439+
// Otherwise remove the item from cache
440+
cache.remove(key);
441+
}
442+
353443
/**
354444
* Remove the listener for a react component
355445
*
@@ -367,7 +457,11 @@ function disconnect(connectionID, keyToRemoveFromEvictionBlocklist) {
367457
removeFromEvictionBlockList(keyToRemoveFromEvictionBlocklist, connectionID);
368458
}
369459

460+
const key = callbackToStateMapping[connectionID].key;
370461
delete callbackToStateMapping[connectionID];
462+
463+
// When the last subscriber disconnects, drop cache as well
464+
cleanCache(key);
371465
}
372466

373467
/**
@@ -377,8 +471,13 @@ function disconnect(connectionID, keyToRemoveFromEvictionBlocklist) {
377471
* @return {Promise}
378472
*/
379473
function remove(key) {
380-
return AsyncStorage.removeItem(key)
381-
.then(() => keyChanged(key, null));
474+
// Remove from cache
475+
cache.remove(key);
476+
477+
// Optimistically inform subscribers
478+
keyChanged(key, null);
479+
480+
return AsyncStorage.removeItem(key);
382481
}
383482

384483
/**
@@ -414,12 +513,28 @@ function evictStorageAndRetry(error, ionMethod, ...args) {
414513
* @returns {Promise}
415514
*/
416515
function set(key, val) {
516+
// Adds the key to cache when it's not available
517+
cache.set(key, val);
518+
519+
// Optimistically inform subscribers
520+
keyChanged(key, val);
521+
417522
// Write the thing to persistent storage, which will trigger a storage event for any other tabs open on this domain
418523
return AsyncStorage.setItem(key, JSON.stringify(val))
419-
.then(() => keyChanged(key, val))
420524
.catch(error => evictStorageAndRetry(error, set, key, val));
421525
}
422526

527+
/**
528+
* AsyncStorage expects array like: [["@MyApp_user", "value_1"], ["@MyApp_key", "value_2"]]
529+
* This method transforms an object like {'@MyApp_user': 'myUserValue', '@MyApp_key': 'myKeyValue'}
530+
* to an array of key-value pairs in the above format
531+
* @param {Record<string, *>} data
532+
* @return {Array<[string, string]>} an array of stringified key - value pairs
533+
*/
534+
function prepareKeyValuePairsForStorage(data) {
535+
return _.keys(data).map(key => [key, JSON.stringify(data[key])]);
536+
}
537+
423538
/**
424539
* Sets multiple keys and values. Example
425540
* Onyx.multiSet({'key1': 'a', 'key2': 'b'});
@@ -428,17 +543,15 @@ function set(key, val) {
428543
* @returns {Promise}
429544
*/
430545
function multiSet(data) {
431-
// AsyncStorage expenses the data in an array like:
432-
// [["@MyApp_user", "value_1"], ["@MyApp_key", "value_2"]]
433-
// This method will transform the params from a better JSON format like:
434-
// {'@MyApp_user': 'myUserValue', '@MyApp_key': 'myKeyValue'}
435-
const keyValuePairs = _.reduce(data, (finalArray, val, key) => ([
436-
...finalArray,
437-
[key, JSON.stringify(val)],
438-
]), []);
546+
const keyValuePairs = prepareKeyValuePairsForStorage(data);
547+
548+
_.each(data, (val, key) => {
549+
// Update cache and optimistically inform subscribers
550+
cache.set(key, val);
551+
keyChanged(key, val);
552+
});
439553

440554
return AsyncStorage.multiSet(keyValuePairs)
441-
.then(() => _.each(data, (val, key) => keyChanged(key, val)))
442555
.catch(error => evictStorageAndRetry(error, multiSet, data));
443556
}
444557

@@ -522,17 +635,15 @@ function initializeWithDefaultKeyStates() {
522635
* @returns {Promise<void>}
523636
*/
524637
function clear() {
525-
let allKeys;
526638
return getAllKeys()
527-
.then(keys => allKeys = keys)
528-
.then(() => AsyncStorage.clear())
529-
.then(() => {
530-
_.each(allKeys, (key) => {
639+
.then((keys) => {
640+
_.each(keys, (key) => {
531641
keyChanged(key, null);
642+
cache.remove(key);
532643
});
533-
534-
initializeWithDefaultKeyStates();
535-
});
644+
})
645+
.then(AsyncStorage.clear)
646+
.then(initializeWithDefaultKeyStates);
536647
}
537648

538649
/**
@@ -551,34 +662,31 @@ function mergeCollection(collectionKey, collection) {
551662
}
552663
});
553664

554-
const existingKeyCollection = {};
555-
const newCollection = {};
556665
return getAllKeys()
557-
.then((keys) => {
558-
_.each(collection, (data, dataKey) => {
559-
if (keys.includes(dataKey)) {
560-
existingKeyCollection[dataKey] = data;
561-
} else {
562-
newCollection[dataKey] = data;
563-
}
564-
});
565-
566-
const keyValuePairsForExistingCollection = _.reduce(existingKeyCollection, (finalArray, val, key) => ([
567-
...finalArray,
568-
[key, JSON.stringify(val)],
569-
]), []);
570-
const keyValuePairsForNewCollection = _.reduce(newCollection, (finalArray, val, key) => ([
571-
...finalArray,
572-
[key, JSON.stringify(val)],
573-
]), []);
666+
.then((persistedKeys) => {
667+
// Split to keys that exist in storage and keys that don't
668+
const [existingKeys, newKeys] = _.chain(collection)
669+
.keys()
670+
.partition(key => persistedKeys.includes(key))
671+
.value();
672+
673+
const existingKeyCollection = _.pick(collection, existingKeys);
674+
const newCollection = _.pick(collection, newKeys);
675+
const keyValuePairsForExistingCollection = prepareKeyValuePairsForStorage(existingKeyCollection);
676+
const keyValuePairsForNewCollection = prepareKeyValuePairsForStorage(newCollection);
574677

575678
// New keys will be added via multiSet while existing keys will be updated using multiMerge
576679
// This is because setting a key that doesn't exist yet with multiMerge will throw errors
577680
const existingCollectionPromise = AsyncStorage.multiMerge(keyValuePairsForExistingCollection);
578681
const newCollectionPromise = AsyncStorage.multiSet(keyValuePairsForNewCollection);
579682

683+
// Merge original data to cache
684+
cache.merge(collection);
685+
686+
// Optimistically inform collection subscribers
687+
keysChanged(collectionKey, collection);
688+
580689
return Promise.all([existingCollectionPromise, newCollectionPromise])
581-
.then(() => keysChanged(collectionKey, collection))
582690
.catch(error => evictStorageAndRetry(error, mergeCollection, collection));
583691
});
584692
}

0 commit comments

Comments
 (0)