Skip to content

Commit

Permalink
Merge pull request #93 from kidroca/kidroca/lru-cache
Browse files Browse the repository at this point in the history
Perf: Lru cache
  • Loading branch information
marcaaron authored Aug 6, 2021
2 parents d73900b + aa7a23a commit d7553b9
Show file tree
Hide file tree
Showing 4 changed files with 157 additions and 193 deletions.
90 changes: 23 additions & 67 deletions lib/Onyx.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,19 +102,6 @@ function isCollectionKey(key) {
return _.contains(_.values(onyxKeys.COLLECTION), key);
}

/**
* Find the collection a collection item belongs to
* or return null if them item is not a part of a collection
* @param {string} key
* @returns {string|null}
*/
function getCollectionKeyForItem(key) {
return _.chain(onyxKeys.COLLECTION)
.values()
.find(name => key.startsWith(name))
.value();
}

/**
* Checks to see if a given key matches with the
* configured key of our connected subscriber
Expand Down Expand Up @@ -369,14 +356,21 @@ function connect(mapping) {
deferredInitTask.promise
.then(() => {
// Check to see if this key is flagged as a safe eviction key and add it to the recentlyAccessedKeys list
if (mapping.withOnyxInstance && !isCollectionKey(mapping.key) && isSafeEvictionKey(mapping.key)) {
// All React components subscribing to a key flagged as a safe eviction
// key must implement the canEvict property.
if (_.isUndefined(mapping.canEvict)) {
// eslint-disable-next-line max-len
throw new Error(`Cannot subscribe to safe eviction key '${mapping.key}' without providing a canEvict value.`);
if (isSafeEvictionKey(mapping.key)) {
// Try to free some cache whenever we connect to a safe eviction key
cache.removeLeastRecentlyUsedKeys();

if (mapping.withOnyxInstance && !isCollectionKey(mapping.key)) {
// All React components subscribing to a key flagged as a safe eviction
// key must implement the canEvict property.
if (_.isUndefined(mapping.canEvict)) {
throw new Error(
`Cannot subscribe to safe eviction key '${mapping.key}' without providing a canEvict value.`
);
}

addLastAccessedKey(mapping.key);
}
addLastAccessedKey(mapping.key);
}
})
.then(getAllKeys)
Expand Down Expand Up @@ -412,48 +406,6 @@ function connect(mapping) {
return connectionID;
}

/**
* Remove cache items that are no longer connected through Onyx
* @param {string} key
*/
function cleanCache(key) {
// Don't remove default keys from cache, they don't take much memory and are accessed frequently
if (_.has(defaultKeyStates, key)) {
return;
}

const hasRemainingConnections = _.some(callbackToStateMapping, {key});

// When the key is still used in other places don't remove it from cache
if (hasRemainingConnections) {
return;
}

// When this is a collection - also recursively remove any unused individual items
if (isCollectionKey(key)) {
cache.drop(key);

getAllKeys().then(cachedKeys => _.chain(cachedKeys)
.filter(name => name.startsWith(key))
.forEach(cleanCache));

return;
}

// When this is a collection item - check if the collection is still used
const collectionKey = getCollectionKeyForItem(key);
if (collectionKey) {
// When there's an active subscription for a collection don't remove the item
const hasRemainingConnectionsForCollection = _.some(callbackToStateMapping, {key: collectionKey});
if (hasRemainingConnectionsForCollection) {
return;
}
}

// Otherwise remove the value from cache
cache.drop(key);
}

/**
* Remove the listener for a react component
*
Expand All @@ -471,11 +423,7 @@ function disconnect(connectionID, keyToRemoveFromEvictionBlocklist) {
removeFromEvictionBlockList(keyToRemoveFromEvictionBlocklist, connectionID);
}

const key = callbackToStateMapping[connectionID].key;
delete callbackToStateMapping[connectionID];

// When the last subscriber disconnects, drop cache as well
cleanCache(key);
}

/**
Expand Down Expand Up @@ -742,13 +690,17 @@ function mergeCollection(collectionKey, collection) {
* @param {function} registerStorageEventListener a callback when a storage event happens.
* This applies to web platforms where the local storage emits storage events
* across all open tabs and allows Onyx to stay in sync across all open tabs.
* @param {Boolean} [options.captureMetrics]
* @param {Number} [options.maxCachedKeysCount=55] Sets how many recent keys should we try to keep in cache
* Setting this to 0 would practically mean no cache
* We try to free cache when we connect to a safe eviction key
* @param {Boolean} [options.captureMetrics] Enables Onyx benchmarking and exposes the get/print/reset functions
*/
function init({
keys,
initialKeyStates,
safeEvictionKeys,
registerStorageEventListener,
maxCachedKeysCount = 55,
captureMetrics = false,
}) {
if (captureMetrics) {
Expand All @@ -757,6 +709,10 @@ function init({
applyDecorators();
}

if (maxCachedKeysCount > 0) {
cache.setRecentKeysLimit(maxCachedKeysCount);
}

// Let Onyx know about all of our keys
onyxKeys = keys;

Expand Down
48 changes: 46 additions & 2 deletions lib/OnyxCache.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ class OnyxCache {
*/
this.storageKeys = new Set();

/**
* @private
* Unique list of keys maintained in access order (most recent at the end)
* @type {Set<string>}
*/
this.recentKeys = new Set();

/**
* @private
* A map of cached values
Expand All @@ -31,11 +38,12 @@ class OnyxCache {
*/
this.pendingPromises = {};

// bind all methods to prevent problems with `this`
// bind all public methods to prevent problems with `this`
_.bindAll(
this,
'getAllKeys', 'getValue', 'hasCacheForKey', 'addKey', 'set', 'drop', 'merge',
'hasPendingTask', 'getTaskPromise', 'captureTask',
'hasPendingTask', 'getTaskPromise', 'captureTask', 'removeLeastRecentlyUsedKeys',
'setRecentKeysLimit'
);
}

Expand All @@ -53,6 +61,7 @@ class OnyxCache {
* @returns {*}
*/
getValue(key) {
this.addToAccessedKeys(key);
return this.storageMap[key];
}

Expand Down Expand Up @@ -83,6 +92,7 @@ class OnyxCache {
*/
set(key, value) {
this.addKey(key);
this.addToAccessedKeys(key);
this.storageMap[key] = value;

return value;
Expand All @@ -106,6 +116,7 @@ class OnyxCache {
const storageKeys = this.getAllKeys();
const mergedKeys = _.keys(data);
this.storageKeys = new Set([...storageKeys, ...mergedKeys]);
_.each(mergedKeys, key => this.addToAccessedKeys(key));
}

/**
Expand Down Expand Up @@ -144,6 +155,39 @@ class OnyxCache {

return this.pendingPromises[taskName];
}

/**
* @private
* Adds a key to the top of the recently accessed keys
* @param {string} key
*/
addToAccessedKeys(key) {
// Removing and re-adding a key ensures it's at the end of the list
this.recentKeys.delete(key);
this.recentKeys.add(key);
}

/**
* Remove keys that don't fall into the range of recently used keys
*/
removeLeastRecentlyUsedKeys() {
if (this.recentKeys.size > this.maxRecentKeysSize) {
// Get the last N keys by doing a negative slice
const recentlyAccessed = [...this.recentKeys].slice(-this.maxRecentKeysSize);
const storageKeys = _.keys(this.storageMap);
const keysToRemove = _.difference(storageKeys, recentlyAccessed);

_.each(keysToRemove, this.drop);
}
}

/**
* Set the recent keys list size
* @param {number} limit
*/
setRecentKeysLimit(limit) {
this.maxRecentKeysSize = limit;
}
}

const instance = new OnyxCache();
Expand Down
18 changes: 10 additions & 8 deletions lib/decorateWithMetrics.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ function decorateWithMetrics(func, alias = func.name) {
methodName: alias,
startTime,
endTime,
duration: endTime - startTime,
args,
});
});
Expand Down Expand Up @@ -78,8 +79,7 @@ function sum(list, prop) {
*/
function getMetrics() {
const summaries = _.chain(stats)
.map((data, methodName) => {
const calls = _.map(data, call => ({...call, duration: call.endTime - call.startTime}));
.map((calls, methodName) => {
const total = sum(calls, 'duration');
const avg = (total / calls.length) || 0;
const max = _.max(calls, 'duration').duration || 0;
Expand Down Expand Up @@ -144,21 +144,23 @@ function toDuration(millis, raw = false) {
* @param {'console'|'csv'|'json'|'string'} [options.format=console] The output format of this function
* `string` is useful when __DEV__ is set to `false` as writing to the console is disabled, but the result of this
* method would still get printed as output
* @param {string[]} [options.methods] Print stats only for these method names
* @returns {string|undefined}
*/
function printMetrics({raw = false, format = 'console'} = {}) {
function printMetrics({raw = false, format = 'console', methods} = {}) {
const {totalTime, summaries, lastCompleteCall} = getMetrics();

const tableSummary = MDTable.factory({
heading: ['method', 'total time spent', 'max', 'min', 'avg', 'time last call completed', 'calls made'],
leftAlignedCols: [0],
});

const methodCallTables = _.chain(summaries)
.filter(method => method.avg > 0)
.sortBy('avg')
.reverse()
.map(({methodName, calls, ...methodStats}) => {
const methodNames = _.isArray(methods) ? methods : _.keys(summaries);

const methodCallTables = _.chain(methodNames)
.filter(methodName => summaries[methodName] && summaries[methodName].avg > 0)
.map((methodName) => {
const {calls, ...methodStats} = summaries[methodName];
tableSummary.addRow(
methodName,
toDuration(methodStats.total, raw),
Expand Down
Loading

0 comments on commit d7553b9

Please sign in to comment.