Skip to content
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

Perf: Lru cache #93

Merged
merged 11 commits into from
Aug 6, 2021
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) {
kidroca marked this conversation as resolved.
Show resolved Hide resolved
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