Skip to content

Commit 0cdf133

Browse files
authored
Merge pull request #70 from kidroca/kidroca/benchmark
Feature: Benchmark Onyx
2 parents d0a5d12 + b71e911 commit 0cdf133

File tree

6 files changed

+663
-9
lines changed

6 files changed

+663
-9
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,6 @@
66
#
77
node_modules/
88
npm-debug.log
9+
10+
# IDEs
11+
.idea

README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,51 @@ export default withOnyx({
3939
},
4040
})(ReportActionsView);
4141
```
42+
43+
### Benchmarks
44+
45+
Provide the `captureMetrics` boolean flag to `Onyx.init` to capture call statistics
46+
47+
```js
48+
Onyx.init({
49+
keys: ONYXKEYS,
50+
safeEvictionKeys: [ONYXKEYS.COLLECTION.REPORT_ACTIONS],
51+
captureMetrics: Config.BENCHMARK_ONYX,
52+
});
53+
```
54+
55+
At any point you can get the collected statistics using `Onyx.getMetrics()`.
56+
This will return an object containing `totalTime`, `averageTime` and `summaries`.
57+
`summaries` is a collection of statistics for each method it contains data about:
58+
- method name
59+
- total, max, min, average times for this method calls
60+
- calls - a list of individual calls with each having: start time; end time; call duration; call arguments
61+
- start/end times are relative to application launch time - 0.00 being exactly at launch
62+
63+
If you wish to reset the metrics and start over use `Onyx.resetMetrics()`
64+
65+
Finally, there's a `Onyx.printMetrics()` method which prints human friendly statistics information on the dev console
66+
You can use this method during debugging e.g. add an `Onyx.printMetrics()` line somewhere in code or call it
67+
through the dev console.
68+
69+
Sample output of `Onyx.printMetrics()`
70+
71+
```
72+
Onyx Benchmark
73+
Total: 135.9min
74+
Average: 45.3min
75+
76+
Onyx:get
77+
total: 51.5min
78+
max: 6.20sec
79+
min: 26.650ms
80+
avg: 4.24sec
81+
calls: 728
82+
83+
Onyx:getAllKeys
84+
total: 84.3min
85+
max: 6.20sec
86+
min: 8.210ms
87+
avg: 3.78sec
88+
calls: 1336
89+
```

lib/Onyx.js

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ const evictionBlocklist = {};
2828
let defaultKeyStates = {};
2929

3030
/**
31-
* When a key change happens, search for any callbacks matching the regex pattern and trigger those callbacks
3231
* Get some data from the store
3332
*
3433
* @param {string} key
@@ -40,6 +39,14 @@ function get(key) {
4039
.catch(err => logInfo(`Unable to get item from persistent storage. Key: ${key} Error: ${err}`));
4140
}
4241

42+
/**
43+
* Returns current key names stored in persisted storage
44+
* @returns {Promise<string[]>}
45+
*/
46+
function getAllKeys() {
47+
return AsyncStorage.getAllKeys();
48+
}
49+
4350
/**
4451
* Checks to see if the a subscriber's supplied key
4552
* is associated with a collection of keys.
@@ -141,7 +148,7 @@ function addToEvictionBlockList(key, connectionID) {
141148
* removed.
142149
*/
143150
function addAllSafeEvictionKeysToRecentlyAccessedList() {
144-
AsyncStorage.getAllKeys()
151+
getAllKeys()
145152
.then((keys) => {
146153
_.each(evictionAllowList, (safeEvictionKey) => {
147154
_.each(keys, (key) => {
@@ -310,7 +317,7 @@ function connect(mapping) {
310317
addLastAccessedKey(mapping.key);
311318
}
312319

313-
AsyncStorage.getAllKeys()
320+
getAllKeys()
314321
.then((keys) => {
315322
// Find all the keys matched by the config key
316323
const matchingKeys = _.filter(keys, key => isKeyMatch(mapping.key, key));
@@ -482,22 +489,23 @@ function applyMerge(key, data) {
482489
*
483490
* @param {string} key
484491
* @param {*} val
492+
* @returns {Promise}
485493
*/
486494
function merge(key, val) {
487495
if (mergeQueue[key]) {
488496
mergeQueue[key].push(val);
489-
return;
497+
return Promise.resolve();
490498
}
491499

492500
mergeQueue[key] = [val];
493-
get(key)
501+
return get(key)
494502
.then((data) => {
495503
const modifiedData = applyMerge(key, data);
496504

497505
// Clean up the write queue so we
498506
// don't apply these changes again
499507
delete mergeQueue[key];
500-
set(key, modifiedData);
508+
return set(key, modifiedData);
501509
});
502510
}
503511

@@ -515,7 +523,7 @@ function initializeWithDefaultKeyStates() {
515523
*/
516524
function clear() {
517525
let allKeys;
518-
return AsyncStorage.getAllKeys()
526+
return getAllKeys()
519527
.then(keys => allKeys = keys)
520528
.then(() => AsyncStorage.clear())
521529
.then(() => {
@@ -545,7 +553,7 @@ function mergeCollection(collectionKey, collection) {
545553

546554
const existingKeyCollection = {};
547555
const newCollection = {};
548-
return AsyncStorage.getAllKeys()
556+
return getAllKeys()
549557
.then((keys) => {
550558
_.each(collection, (data, dataKey) => {
551559
if (keys.includes(dataKey)) {
@@ -586,13 +594,21 @@ function mergeCollection(collectionKey, collection) {
586594
* @param {function} registerStorageEventListener a callback when a storage event happens.
587595
* This applies to web platforms where the local storage emits storage events
588596
* across all open tabs and allows Onyx to stay in sync across all open tabs.
597+
* @param {Boolean} [options.captureMetrics]
589598
*/
590599
function init({
591600
keys,
592601
initialKeyStates,
593602
safeEvictionKeys,
594-
registerStorageEventListener
603+
registerStorageEventListener,
604+
captureMetrics = false,
595605
}) {
606+
if (captureMetrics) {
607+
// The code here is only bundled and applied when the captureMetrics is set
608+
// eslint-disable-next-line no-use-before-define
609+
applyDecorators();
610+
}
611+
596612
// Let Onyx know about all of our keys
597613
onyxKeys = keys;
598614

@@ -625,4 +641,35 @@ const Onyx = {
625641
isSafeEvictionKey,
626642
};
627643

644+
/**
645+
* Apply calls statistic decorators to benchmark Onyx
646+
*/
647+
function applyDecorators() {
648+
// We're requiring the script dynamically here so that it's only evaluated when decorators are used
649+
const decorate = require('./decorateWithMetrics');
650+
651+
// Re-assign with decorated functions
652+
/* eslint-disable no-func-assign */
653+
get = decorate.decorateWithMetrics(get, 'Onyx:get');
654+
set = decorate.decorateWithMetrics(set, 'Onyx:set');
655+
multiSet = decorate.decorateWithMetrics(multiSet, 'Onyx:multiSet');
656+
clear = decorate.decorateWithMetrics(clear, 'Onyx:clear');
657+
merge = decorate.decorateWithMetrics(merge, 'Onyx:merge');
658+
mergeCollection = decorate.decorateWithMetrics(mergeCollection, 'Onyx:mergeCollection');
659+
getAllKeys = decorate.decorateWithMetrics(getAllKeys, 'Onyx:getAllKeys');
660+
/* eslint-enable */
661+
662+
// Re-expose decorated methods
663+
Onyx.set = set;
664+
Onyx.multiSet = multiSet;
665+
Onyx.clear = clear;
666+
Onyx.merge = merge;
667+
Onyx.mergeCollection = mergeCollection;
668+
669+
// Expose stats methods on Onyx
670+
Onyx.getMetrics = decorate.getMetrics;
671+
Onyx.resetMetrics = decorate.resetMetrics;
672+
Onyx.printMetrics = decorate.printMetrics;
673+
}
674+
628675
export default Onyx;

lib/decorateWithMetrics.js

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import _ from 'underscore';
2+
3+
/**
4+
* Each key is a method name and the value is an array of calls metadata
5+
* @type {Record<String, Array<Object>>}
6+
*/
7+
let stats = {};
8+
9+
/**
10+
* Wraps a function with metrics capturing logic
11+
* @param {function} func
12+
* @param {String} [alias]
13+
* @returns {function} The wrapped function
14+
*/
15+
function decorateWithMetrics(func, alias = func.name) {
16+
if (_.has(stats, alias)) {
17+
throw new Error(`"${alias}" is already decorated`);
18+
}
19+
20+
stats[alias] = [];
21+
22+
function decorated(...args) {
23+
const startTime = performance.now();
24+
25+
const originalPromise = func.apply(this, args);
26+
27+
/*
28+
* Then handlers added here are not affecting the original promise
29+
* They crate a separate chain that's not exposed (returned) to the original caller
30+
* */
31+
originalPromise
32+
.finally(() => {
33+
const endTime = performance.now();
34+
35+
if (!_.has(stats, alias)) {
36+
stats[alias] = [];
37+
}
38+
39+
const methodStats = stats[alias];
40+
methodStats.push({
41+
methodName: alias,
42+
startTime,
43+
endTime,
44+
args,
45+
});
46+
});
47+
48+
return originalPromise;
49+
}
50+
51+
return decorated;
52+
}
53+
54+
/**
55+
* Calculate the total sum of a given key in a list
56+
* @param {Array<Record<prop, Number>>} list
57+
* @param {string} prop
58+
* @returns {number}
59+
*/
60+
function sum(list, prop) {
61+
return _.reduce(list, (memo, next) => memo + next[prop], 0);
62+
}
63+
64+
/**
65+
* Returns total, average time and all captured stats mapped under
66+
* summaries.methodName -> method stats
67+
* @returns {{averageTime: number, summaries: Record<string, Object>, totalTime: number}}
68+
*/
69+
function getMetrics() {
70+
const summaries = _.chain(stats)
71+
.map((data, methodName) => {
72+
const calls = _.map(data, call => ({...call, duration: call.endTime - call.startTime}));
73+
const total = sum(calls, 'duration');
74+
const avg = (total / calls.length) || 0;
75+
const max = _.max(calls, 'duration').duration || 0;
76+
const min = _.min(calls, 'duration').duration || 0;
77+
78+
return [methodName, {
79+
methodName,
80+
total,
81+
max,
82+
min,
83+
avg,
84+
calls,
85+
}];
86+
})
87+
.object() // Create a map like methodName -> StatSummary
88+
.value();
89+
90+
const totalTime = sum(_.values(summaries), 'total');
91+
const averageTime = (totalTime / _.size(summaries)) || 0;
92+
93+
return {
94+
totalTime,
95+
averageTime,
96+
summaries,
97+
};
98+
}
99+
100+
function toHumanReadableDuration(millis) {
101+
const minute = 60 * 1000;
102+
if (millis > minute) {
103+
return `${(millis / minute).toFixed(1)}min`;
104+
}
105+
106+
const second = 1000;
107+
if (millis > second) {
108+
return `${(millis / second).toFixed(2)}sec`;
109+
}
110+
111+
return `${millis.toFixed(3)}ms`;
112+
}
113+
114+
/**
115+
* Print extensive information on the dev console
116+
* max, min, average, total time for each method
117+
* and a table of individual calls
118+
*/
119+
function printMetrics() {
120+
const {totalTime, averageTime, summaries} = getMetrics();
121+
122+
/* eslint-disable no-console */
123+
console.group('Onyx Benchmark');
124+
console.info(' Total: ', toHumanReadableDuration(totalTime));
125+
console.info(' Average: ', toHumanReadableDuration(averageTime));
126+
127+
_.chain(summaries)
128+
.sortBy('avg')
129+
.reverse()
130+
.forEach(({calls, methodName, ...summary}) => {
131+
const times = _.map(summary, (value, key) => `${key}: ${toHumanReadableDuration(value)}`);
132+
133+
console.groupCollapsed(`${methodName}\n ${times.join('\n ')} \n calls: ${calls.length}`);
134+
console.table(calls.map(call => ({
135+
startTime: toHumanReadableDuration(call.startTime),
136+
endTime: toHumanReadableDuration(call.endTime),
137+
duration: toHumanReadableDuration(call.duration),
138+
args: JSON.stringify(call.args)
139+
})));
140+
console.groupEnd();
141+
});
142+
console.groupEnd();
143+
/* eslint-enable */
144+
}
145+
146+
/**
147+
* Clears all collected metrics.
148+
*/
149+
function resetMetrics() {
150+
stats = {};
151+
}
152+
153+
export {
154+
decorateWithMetrics,
155+
getMetrics,
156+
resetMetrics,
157+
printMetrics,
158+
};

0 commit comments

Comments
 (0)