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

Try to synchronize operations within one update #490

Merged
merged 46 commits into from
Jul 16, 2024
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
b4c739b
Try to synchronize operations within one update
paultsimura Mar 3, 2024
e9966f0
DRY
paultsimura Mar 4, 2024
9cb563e
Broadcast updates
paultsimura Mar 4, 2024
c7247ea
Formatting
paultsimura Mar 4, 2024
1d32cc2
Update test
paultsimura Mar 4, 2024
748c51f
Update test
paultsimura Mar 5, 2024
d0d261b
Update comments
paultsimura Mar 5, 2024
44617ed
Merge branch 'main' into fix/update-order
paultsimura Mar 6, 2024
f537212
WIP
paultsimura Mar 7, 2024
c160503
WIP
paultsimura Mar 7, 2024
e32f0e3
WIP
paultsimura Mar 7, 2024
b56d236
WIP
paultsimura Mar 7, 2024
f2513d8
WIP
paultsimura Mar 11, 2024
a98aeb1
Remove failing tests for the deep null removal
paultsimura Mar 12, 2024
150a16e
Cleanup
paultsimura Mar 12, 2024
94ec428
Merge branch 'main' into fix/update-order
paultsimura Mar 12, 2024
b954d72
Use multiSet
paultsimura Mar 12, 2024
2796388
Handle `null` merge efficiently
paultsimura Mar 15, 2024
10ab402
Use mergeCollection even for 1-item updates
paultsimura Mar 18, 2024
90473be
Update test: include mergeCollection call
paultsimura Mar 18, 2024
3447e6f
Bring back the 1-item merge
paultsimura Mar 18, 2024
a153237
Merge branch 'main' into fix/update-order
paultsimura Mar 25, 2024
290b4b7
Merge branch 'main' into fix/update-order
paultsimura Apr 5, 2024
00ddb11
Implement the bulk merge in TS
paultsimura Apr 12, 2024
bb3acaa
Merge branch 'main' into fix/update-order
paultsimura Apr 12, 2024
6fea4d3
Fix tests
paultsimura Apr 12, 2024
918d2e9
Merge branch 'main' into fix/update-order
paultsimura Apr 17, 2024
47fc3cb
TS migration
paultsimura Apr 17, 2024
c986e51
Patch: TS migration
paultsimura Apr 17, 2024
3381ff8
Fix TS issues
paultsimura Apr 17, 2024
31fd63e
Fix TS issues
paultsimura Apr 18, 2024
e9abdd5
Merge branch 'main' into fix/update-order
paultsimura Apr 24, 2024
ad25727
Merge branch 'main' into fix/update-order
paultsimura May 13, 2024
4169591
Fix TS issues
paultsimura May 14, 2024
d1b6b95
fix comments
paultsimura May 23, 2024
ca3cd29
Merge branch 'main' into fix/update-order
paultsimura Jun 9, 2024
8ea4699
Resolve conflicts
paultsimura Jun 9, 2024
28c6d5c
Resolve conflicts
paultsimura Jun 18, 2024
230ef57
TEMP: add dist
paultsimura Jun 19, 2024
c2e6351
Merge branch 'main' into fix/update-order
paultsimura Jun 19, 2024
153bc79
TEMP: add dist
paultsimura Jun 19, 2024
2f74bfd
TEMP: remove dist
paultsimura Jun 24, 2024
45b0523
Merge branch 'main' into fix/update-order
paultsimura Jun 28, 2024
d11cbd4
Resolve conflicts
paultsimura Jun 29, 2024
873443c
TEMP: add dist
paultsimura Jun 29, 2024
4fabc64
TEMP: revert dist
paultsimura Jul 16, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 105 additions & 32 deletions lib/Onyx.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ let lastConnectionID = 0;
const callbackToStateMapping = {};

// Keeps a copy of the values of the onyx collection keys as a map for faster lookups
let onyxCollectionKeyMap = new Map();
let onyxCollectionKeySet = new Set();

// Holds a list of keys that have been directly subscribed to or recently modified from least to most recent
let recentlyAccessedKeys = [];
Expand Down Expand Up @@ -194,14 +194,14 @@ function getAllKeys() {
}

/**
* Checks to see if the a subscriber's supplied key
* Checks to see if the subscriber's supplied key
* is associated with a collection of keys.
*
* @param {String} key
* @returns {Boolean}
*/
function isCollectionKey(key) {
return onyxCollectionKeyMap.has(key);
return onyxCollectionKeySet.has(key);
}

/**
Expand Down Expand Up @@ -1115,7 +1115,7 @@ function evictStorageAndRetry(error, onyxMethod, ...args) {
}

/**
* Notifys subscribers and writes current value to cache
* Notifies subscribers and writes current value to cache
*
* @param {String} key
* @param {*} value
Expand Down Expand Up @@ -1472,24 +1472,10 @@ function clear(keysToPreserve = []) {
});
}

/**
* Merges a collection based on their keys
*
* @example
*
* Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, {
* [`${ONYXKEYS.COLLECTION.REPORT}1`]: report1,
* [`${ONYXKEYS.COLLECTION.REPORT}2`]: report2,
* });
*
* @param {String} collectionKey e.g. `ONYXKEYS.COLLECTION.REPORT`
* @param {Object} collection Object collection keyed by individual collection member keys and values
* @returns {Promise}
*/
function mergeCollection(collectionKey, collection) {
function isValidMergeCollection(collectionKey, collection) {
if (!_.isObject(collection) || _.isArray(collection) || _.isEmpty(collection)) {
Logger.logInfo('mergeCollection() called with invalid or empty value. Skipping this update.');
return Promise.resolve();
return false;
}

// Confirm all the collection keys belong to the same parent
Expand All @@ -1506,9 +1492,25 @@ function mergeCollection(collectionKey, collection) {
hasCollectionKeyCheckFailed = true;
Logger.logAlert(`Provided collection doesn't have all its data belonging to the same parent. CollectionKey: ${collectionKey}, DataKey: ${dataKey}`);
});
return !hasCollectionKeyCheckFailed;
}

// Gracefully handle bad mergeCollection updates so it doesn't block the merge queue
if (hasCollectionKeyCheckFailed) {
/**
* Merges a collection based on their keys
*
* @example
*
* Onyx.mergeCollection(ONYXKEYS.COLLECTION.REPORT, {
* [`${ONYXKEYS.COLLECTION.REPORT}1`]: report1,
* [`${ONYXKEYS.COLLECTION.REPORT}2`]: report2,
* });
*
* @param {String} collectionKey e.g. `ONYXKEYS.COLLECTION.REPORT`
* @param {Object} collection Object collection keyed by individual collection member keys and values
* @returns {Promise}
*/
function mergeCollection(collectionKey, collection) {
if (!isValidMergeCollection(collectionKey, collection)) {
return Promise.resolve();
}

Expand Down Expand Up @@ -1581,22 +1583,42 @@ function update(data) {
}
});

const promises = [];
const updateQueue = {};
const enqueueSetOperation = (key, value) => {
// If a `set` operation is enqueued, we should clear the whole queue.
// Since the `set` operation replaces the value entirely, there's no need to perform any previous operations.
// To do this, we first put `null` in the queue, which removes the existing value, and then merge the new value.
updateQueue[key] = [null, value];
};
const enqueueMergeOperation = (key, value) => {
if (value === null) {
// If we merge `null`, the value is removed and all the previous operations are discarded.
updateQueue[key] = [null];
} else if (!updateQueue[key]) {
updateQueue[key] = [value];
} else {
updateQueue[key].push(value);
}
};

let clearPromise = Promise.resolve();

_.each(data, ({onyxMethod, key, value}) => {
switch (onyxMethod) {
case METHOD.SET:
promises.push(() => set(key, value));
enqueueSetOperation(key, value);
break;
case METHOD.MERGE:
promises.push(() => merge(key, value));
case METHOD.MERGE: {
enqueueMergeOperation(key, value);
break;
}
case METHOD.MERGE_COLLECTION:
promises.push(() => mergeCollection(key, value));
if (isValidMergeCollection(key, value)) {
_.each(value, (_value, _key) => enqueueMergeOperation(_key, _value));
}
break;
case METHOD.MULTI_SET:
promises.push(() => multiSet(value));
_.each(value, (_value, _key) => enqueueSetOperation(_key, _value));
break;
case METHOD.CLEAR:
clearPromise = clear();
paultsimura marked this conversation as resolved.
Show resolved Hide resolved
Expand All @@ -1606,6 +1628,57 @@ function update(data) {
}
});

const promises = [];

// Group all the collection-related keys and update each collection in a single `mergeCollection` call
onyxCollectionKeySet.forEach((collectionKey) => {
const collectionItemKeys = _.filter(_.keys(updateQueue), (key) => isKeyMatch(collectionKey, key));
if (_.size(collectionItemKeys) <= 1) {
// If there are no items of this collection in the updateQueue, we should skip it.
// If there is only one item, we should update it individually, therefore retain it in the updateQueue.
return;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm interested in the decision-making process for using mergeCollection versus individual merges based on the count of items in the update.

A few questions arise from this approach:

  1. Rationale Behind Individual Merges for Single Items: Could you provide more details on the decision to favor individual updates over mergeCollection when there's only one item in the update? Understanding the rationale could clarify whether it's a performance consideration, a matter of update specificity, or another reason.

  2. Notification to Collection Subscribers: How does this approach impact the notification of collection subscribers, especially in scenarios where a key is added or removed? The decision to handle single and multiple item updates differently could affect how subscribers are notified, potentially leading to inconsistencies.

  3. Consistency in Handling Updates: If bypassing mergeCollection for single-item updates achieves the intended functionality and subscriber notification, could a similar logic not be applied to multiple item updates? Understanding the distinction between handling one versus multiple items could help in assessing potential improvements or optimizations in the update mechanism.

Your insights on these points would enhance my understanding and ensure that the logic aligns with the intended behavior for collection updates.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the questions.

1. Rationale Behind Individual Merges for Single Items: I've performed a bunch of performance tests for both single- and collection merges when there is a single item to merge. The difference is not huge, but in general the single merge is about 2-3ms faster when the operation takes 100-120ms.

2. Notification to Collection Subscribers: from what I managed to test, both single and collection updates notify the subscribers of a specific collection item as well as the whole collection subscriber.

For mergeCollection, we pass the collection key and then check all the single-item subscribers to notify:

function keysChanged(collectionKey, partialCollection, notifyRegularSubscibers = true, notifyWithOnyxSubscibers = true) {
// We are iterating over all subscribers similar to keyChanged(). However, we are looking for subscribers who are subscribing to either a collection key or
// individual collection key member for the collection that is being updated. It is important to note that the collection parameter cane be a PARTIAL collection
// and does not represent all of the combined keys and values for a collection key. It is just the "new" data that was merged in via mergeCollection().
const stateMappingKeys = _.keys(callbackToStateMapping);
for (let i = 0; i < stateMappingKeys.length; i++) {
const subscriber = callbackToStateMapping[stateMappingKeys[i]];
if (!subscriber) {
continue;
}
// Skip iteration if we do not have a collection key or a collection member key on this subscriber
if (!Str.startsWith(subscriber.key, collectionKey)) {
continue;
}
/**
* e.g. Onyx.connect({key: ONYXKEYS.COLLECTION.REPORT, callback: ...});
*/
const isSubscribedToCollectionKey = subscriber.key === collectionKey;
/**
* e.g. Onyx.connect({key: `${ONYXKEYS.COLLECTION.REPORT}{reportID}`, callback: ...});
*/
const isSubscribedToCollectionMemberKey = isCollectionMemberKey(collectionKey, subscriber.key);
// We prepare the "cached collection" which is the entire collection + the new partial data that
// was merged in via mergeCollection().
const cachedCollection = getCachedCollection(collectionKey);
// Regular Onyx.connect() subscriber found.
if (_.isFunction(subscriber.callback)) {
if (!notifyRegularSubscibers) {
continue;
}
// If they are subscribed to the collection key and using waitForCollectionCallback then we'll
// send the whole cached collection.
if (isSubscribedToCollectionKey) {
if (subscriber.waitForCollectionCallback) {
subscriber.callback(cachedCollection);
continue;
}
// If they are not using waitForCollectionCallback then we notify the subscriber with
// the new merged data but only for any keys in the partial collection.
const dataKeys = _.keys(partialCollection);
for (let j = 0; j < dataKeys.length; j++) {
const dataKey = dataKeys[j];
subscriber.callback(cachedCollection[dataKey], dataKey);
}
continue;
}
// And if the subscriber is specifically only tracking a particular collection member key then we will
// notify them with the cached data for that key only.
if (isSubscribedToCollectionMemberKey) {
subscriber.callback(cachedCollection[subscriber.key], subscriber.key);
continue;
}
continue;
}
// React component subscriber found.
if (subscriber.withOnyxInstance) {
if (!notifyWithOnyxSubscibers) {
continue;
}
// We are subscribed to a collection key so we must update the data in state with the new
// collection member key values from the partial update.
if (isSubscribedToCollectionKey) {
// If the subscriber has a selector, then the component's state must only be updated with the data
// returned by the selector.
if (subscriber.selector) {
subscriber.withOnyxInstance.setStateProxy((prevState) => {
const previousData = prevState[subscriber.statePropertyName];
const newData = reduceCollectionWithSelector(cachedCollection, subscriber.selector, subscriber.withOnyxInstance.state);
if (!deepEqual(previousData, newData)) {
return {
[subscriber.statePropertyName]: newData,
};
}
return null;
});
continue;
}
subscriber.withOnyxInstance.setStateProxy((prevState) => {
const finalCollection = _.clone(prevState[subscriber.statePropertyName] || {});
const dataKeys = _.keys(partialCollection);
for (let j = 0; j < dataKeys.length; j++) {
const dataKey = dataKeys[j];
finalCollection[dataKey] = cachedCollection[dataKey];
}
PerformanceUtils.logSetStateCall(subscriber, prevState[subscriber.statePropertyName], finalCollection, 'keysChanged', collectionKey);
return {
[subscriber.statePropertyName]: finalCollection,
};
});
continue;
}
// If a React component is only interested in a single key then we can set the cached value directly to the state name.
if (isSubscribedToCollectionMemberKey) {
// However, we only want to update this subscriber if the partial data contains a change.
// Otherwise, we would update them with a value they already have and trigger an unnecessary re-render.
const dataFromCollection = partialCollection[subscriber.key];
if (_.isUndefined(dataFromCollection)) {
continue;
}
// If the subscriber has a selector, then the component's state must only be updated with the data
// returned by the selector and the state should only change when the subset of data changes from what
// it was previously.
if (subscriber.selector) {
subscriber.withOnyxInstance.setStateProxy((prevState) => {
const prevData = prevState[subscriber.statePropertyName];
const newData = getSubsetOfData(cachedCollection[subscriber.key], subscriber.selector, subscriber.withOnyxInstance.state);
if (!deepEqual(prevData, newData)) {
PerformanceUtils.logSetStateCall(subscriber, prevData, newData, 'keysChanged', collectionKey);
return {
[subscriber.statePropertyName]: newData,
};
}
return null;
});
continue;
}
subscriber.withOnyxInstance.setStateProxy((prevState) => {
const data = cachedCollection[subscriber.key];
const previousData = prevState[subscriber.statePropertyName];
// Avoids triggering unnecessary re-renders when feeding empty objects
if (utils.isEmptyObject(data) && utils.isEmptyObject(previousData)) {
return null;
}
if (data === previousData) {
return null;
}
PerformanceUtils.logSetStateCall(subscriber, previousData, data, 'keysChanged', collectionKey);
return {
[subscriber.statePropertyName]: data,
};
});
}
}
}
}

For merge, we pass the item key, and "go upwards" – check all the other subscribers that may be subscribed to the whole collection:

/**
* When a key change happens, search for any callbacks matching the key or collection key and trigger those callbacks
*
* @example
* keyChanged(key, value, subscriber => subscriber.initWithStoredValues === false)
*
* @private
* @param {String} key
* @param {*} data
* @param {*} prevData
* @param {Function} [canUpdateSubscriber] only subscribers that pass this truth test will be updated
* @param {boolean} [notifyRegularSubscibers=true]
* @param {boolean} [notifyWithOnyxSubscibers=true]
*/
function keyChanged(key, data, prevData, canUpdateSubscriber = () => true, notifyRegularSubscibers = true, notifyWithOnyxSubscibers = true) {
// Add or remove this key from the recentlyAccessedKeys lists
if (!_.isNull(data)) {
addLastAccessedKey(key);
} else {
removeLastAccessedKey(key);
}
// We are iterating over all subscribers to see if they are interested in the key that has just changed. If the subscriber's key is a collection key then we will
// notify them if the key that changed is a collection member. Or if it is a regular key notify them when there is an exact match. Depending on whether the subscriber
// was connected via withOnyx we will call setState() directly on the withOnyx instance. If it is a regular connection we will pass the data to the provided callback.
const stateMappingKeys = _.keys(callbackToStateMapping);
for (let i = 0; i < stateMappingKeys.length; i++) {
const subscriber = callbackToStateMapping[stateMappingKeys[i]];
if (!subscriber || !isKeyMatch(subscriber.key, key) || !canUpdateSubscriber(subscriber)) {
continue;
}
// Subscriber is a regular call to connect() and provided a callback
if (_.isFunction(subscriber.callback)) {
if (!notifyRegularSubscibers) {
continue;
}
if (isCollectionKey(subscriber.key) && subscriber.waitForCollectionCallback) {
const cachedCollection = getCachedCollection(subscriber.key);
cachedCollection[key] = data;
subscriber.callback(cachedCollection);
continue;
}
subscriber.callback(data, key);
continue;
}
// Subscriber connected via withOnyx() HOC
if (subscriber.withOnyxInstance) {
if (!notifyWithOnyxSubscibers) {
continue;
}
// Check if we are subscribing to a collection key and overwrite the collection member key value in state
if (isCollectionKey(subscriber.key)) {
// If the subscriber has a selector, then the consumer of this data must only be given the data
// returned by the selector and only when the selected data has changed.
if (subscriber.selector) {
subscriber.withOnyxInstance.setStateProxy((prevState) => {
const prevWithOnyxData = prevState[subscriber.statePropertyName];
const newWithOnyxData = {
[key]: getSubsetOfData(data, subscriber.selector, subscriber.withOnyxInstance.state),
};
const prevDataWithNewData = {
...prevWithOnyxData,
...newWithOnyxData,
};
if (!deepEqual(prevWithOnyxData, prevDataWithNewData)) {
PerformanceUtils.logSetStateCall(subscriber, prevWithOnyxData, newWithOnyxData, 'keyChanged', key);
return {
[subscriber.statePropertyName]: prevDataWithNewData,
};
}
return null;
});
continue;
}
subscriber.withOnyxInstance.setStateProxy((prevState) => {
const collection = prevState[subscriber.statePropertyName] || {};
const newCollection = {
...collection,
[key]: data,
};
PerformanceUtils.logSetStateCall(subscriber, collection, newCollection, 'keyChanged', key);
return {
[subscriber.statePropertyName]: newCollection,
};
});
continue;
}
// If the subscriber has a selector, then the component's state must only be updated with the data
// returned by the selector and only if the selected data has changed.
if (subscriber.selector) {
subscriber.withOnyxInstance.setStateProxy(() => {
const previousValue = getSubsetOfData(prevData, subscriber.selector, subscriber.withOnyxInstance.state);
const newValue = getSubsetOfData(data, subscriber.selector, subscriber.withOnyxInstance.state);
if (!deepEqual(previousValue, newValue)) {
return {
[subscriber.statePropertyName]: newValue,
};
}
return null;
});
continue;
}
// If we did not match on a collection key then we just set the new data to the state property
subscriber.withOnyxInstance.setStateProxy((prevState) => {
const prevWithOnyxData = prevState[subscriber.statePropertyName];
// Avoids triggering unnecessary re-renders when feeding empty objects
if (utils.isEmptyObject(data) && utils.isEmptyObject(prevWithOnyxData)) {
return null;
}
if (prevWithOnyxData === data) {
return null;
}
PerformanceUtils.logSetStateCall(subscriber, prevData, data, 'keyChanged', key);
return {
[subscriber.statePropertyName]: data,
};
});
continue;
}
console.error('Warning: Found a matching subscriber to a key that changed, but no callback or withOnyxInstance could be found.');
}
}

In both cases, we iterate over every subscriber.

3. Consistency in Handling Updates:

could a similar logic not be applied to multiple item updates?

If I understood you correctly, do you mean that we could potentially use merge operations for every enqueued collection item? If so – no, we cannot – it will trigger the collection subscriber callback N times, when the mergeCollection does it only once, with the fully updated snapshot of the collection.
The reason why I'm safe with using the merge instead of mergeCollection when there's only 1 item is that the callback will be called the same amount of times – 1.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your comprehensive explanations, @paultsimura.

I see the logic behind the current method and its potential for performance improvement. However, I'd like to offer a suggestion - not as a directive but as a point for consideration - that we might not need to differentiate between single-item and multiple-item updates right now. This thought is guided by a preference for simplicity, aiming to ease the review from others.

Perhaps the special handling for "1 collection key" scenarios could be tabled as an area for future exploration. This way, we can focus on the core functionality at present, leaving room for optimization down the line. Of course, this is just my perspective, and I'm open to further discussion or to hear other team members' thoughts on this matter.


const batchedCollectionUpdates = _.reduce(
collectionItemKeys,
(acc, key) => {
const operations = updateQueue[key];

// Remove the collection-related key from the updateQueue so that it won't be processed individually later.
delete updateQueue[key];

const updatedValue = applyMerge(undefined, operations, false);
if (operations[0] === null) {
acc.set[key] = updatedValue;
} else {
acc.merge[key] = updatedValue;
}
return acc;
},
{
merge: {},
set: {},
},
);

if (!_.isEmpty(batchedCollectionUpdates.merge)) {
promises.push(() => mergeCollection(collectionKey, batchedCollectionUpdates.merge));
}
if (!_.isEmpty(batchedCollectionUpdates.set)) {
promises.push(() => multiSet(batchedCollectionUpdates.set));
}
});

_.each(updateQueue, (operations, key) => {
const batchedChanges = applyMerge(undefined, operations, false);

if (operations[0] === null) {
promises.push(() => set(key, batchedChanges));
} else {
promises.push(() => merge(key, batchedChanges));
}
});

return clearPromise.then(() => Promise.all(_.map(promises, (p) => p())));
}

Expand Down Expand Up @@ -1655,15 +1728,15 @@ function init({keys = {}, initialKeyStates = {}, safeEvictionKeys = [], maxCache
}

// We need the value of the collection keys later for checking if a
// key is a collection. We store it in a map for faster lookup.
// key is a collection. We store it in a Set for faster lookup.
const collectionValues = _.values(keys.COLLECTION);
onyxCollectionKeyMap = _.reduce(
onyxCollectionKeySet = _.reduce(
collectionValues,
(acc, val) => {
acc.set(val, true);
acc.add(val);
return acc;
},
new Map(),
new Set(),
);

// Set our default key states to use when initializing and clearing Onyx data
Expand Down
Loading
Loading