Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
…tive-onyx into perf/use-startswith-only
  • Loading branch information
hurali97 committed Aug 2, 2024
2 parents 52b35cd + 055cbd5 commit dc00235
Show file tree
Hide file tree
Showing 7 changed files with 483 additions and 60 deletions.
133 changes: 105 additions & 28 deletions lib/Onyx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,16 @@ import Storage from './storage';
import utils from './utils';
import DevTools from './DevTools';
import type {
Collection,
CollectionKey,
CollectionKeyBase,
ConnectOptions,
InitOptions,
KeyValueMapping,
Mapping,
OnyxInputKeyValueMapping,
OnyxCollection,
MixedOperationsQueue,
OnyxKey,
OnyxMergeCollectionInput,
OnyxMergeInput,
Expand Down Expand Up @@ -84,7 +87,7 @@ function init({
* @param [mapping.callback] a method that will be called with changed data
* This is used by any non-React code to connect to Onyx
* @param [mapping.initWithStoredValues] If set to false, then no data will be prefilled into the
* component
* component. Default is true.
* @param [mapping.waitForCollectionCallback] If set to true, it will return the entire collection to the callback as a single object
* @param [mapping.selector] THIS PARAM IS ONLY USED WITH withOnyx(). If included, this will be used to subscribe to a subset of an Onyx key's data.
* The sourceData and withOnyx state are passed to the selector and should return the simplified data. Using this setting on `withOnyx` can have very positive
Expand Down Expand Up @@ -437,30 +440,16 @@ function merge<TKey extends OnyxKey>(key: TKey, changes: OnyxMergeInput<TKey>):
* @param collection Object collection keyed by individual collection member keys and values
*/
function mergeCollection<TKey extends CollectionKeyBase, TMap>(collectionKey: TKey, collection: OnyxMergeCollectionInput<TKey, TMap>): Promise<void> {
if (typeof collection !== 'object' || Array.isArray(collection) || utils.isEmptyObject(collection)) {
if (!OnyxUtils.isValidNonEmptyCollectionForMerge(collection)) {
Logger.logInfo('mergeCollection() called with invalid or empty value. Skipping this update.');
return Promise.resolve();
}

const mergedCollection: OnyxInputKeyValueMapping = collection;

// Confirm all the collection keys belong to the same parent
let hasCollectionKeyCheckFailed = false;
const mergedCollectionKeys = Object.keys(mergedCollection);
mergedCollectionKeys.forEach((dataKey) => {
if (OnyxUtils.isKeyMatch(collectionKey, dataKey)) {
return;
}

if (process.env.NODE_ENV === 'development') {
throw new Error(`Provided collection doesn't have all its data belonging to the same parent. CollectionKey: ${collectionKey}, DataKey: ${dataKey}`);
}

hasCollectionKeyCheckFailed = true;
Logger.logAlert(`Provided collection doesn't have all its data belonging to the same parent. CollectionKey: ${collectionKey}, DataKey: ${dataKey}`);
});

// Gracefully handle bad mergeCollection updates so it doesn't block the merge queue
if (hasCollectionKeyCheckFailed) {
if (!OnyxUtils.doAllCollectionItemsBelongToSameParent(collectionKey, mergedCollectionKeys)) {
return Promise.resolve();
}

Expand Down Expand Up @@ -565,22 +554,27 @@ function mergeCollection<TKey extends CollectionKeyBase, TMap>(collectionKey: TK
* @param keysToPreserve is a list of ONYXKEYS that should not be cleared with the rest of the data
*/
function clear(keysToPreserve: OnyxKey[] = []): Promise<void> {
const defaultKeyStates = OnyxUtils.getDefaultKeyStates();
const initialKeys = Object.keys(defaultKeyStates);

return OnyxUtils.getAllKeys()
.then((keys) => {
.then((cachedKeys) => {
cache.clearNullishStorageKeys();

const keysToBeClearedFromStorage: OnyxKey[] = [];
const keyValuesToResetAsCollection: Record<OnyxKey, OnyxCollection<KeyValueMapping[OnyxKey]>> = {};
const keyValuesToResetIndividually: KeyValueMapping = {};

const allKeys = new Set([...cachedKeys, ...initialKeys]);

// The only keys that should not be cleared are:
// 1. Anything specifically passed in keysToPreserve (because some keys like language preferences, offline
// status, or activeClients need to remain in Onyx even when signed out)
// 2. Any keys with a default state (because they need to remain in Onyx as their default, and setting them
// to null would cause unknown behavior)
keys.forEach((key) => {
// 2.1 However, if a default key was explicitly set to null, we need to reset it to the default value
allKeys.forEach((key) => {
const isKeyToPreserve = keysToPreserve.includes(key);
const defaultKeyStates = OnyxUtils.getDefaultKeyStates();
const isDefaultKey = key in defaultKeyStates;

// If the key is being removed or reset to default:
Expand Down Expand Up @@ -622,7 +616,6 @@ function clear(keysToPreserve: OnyxKey[] = []): Promise<void> {
updatePromises.push(OnyxUtils.scheduleNotifyCollectionSubscribers(key, value));
});

const defaultKeyStates = OnyxUtils.getDefaultKeyStates();
const defaultKeyValuePairs = Object.entries(
Object.keys(defaultKeyStates)
.filter((key) => !keysToPreserve.includes(key))
Expand Down Expand Up @@ -712,23 +705,55 @@ function update(data: OnyxUpdate[]): Promise<void> {
}
});

// The queue of operations within a single `update` call in the format of <item key - list of operations updating the item>.
// This allows us to batch the operations per item and merge them into one operation in the order they were requested.
const updateQueue: Record<OnyxKey, Array<OnyxValue<OnyxKey>>> = {};
const enqueueSetOperation = (key: OnyxKey, value: OnyxValue<OnyxKey>) => {
// 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: OnyxKey, value: OnyxValue<OnyxKey>) => {
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);
}
};

const promises: Array<() => Promise<void>> = [];
let clearPromise: Promise<void> = Promise.resolve();

data.forEach(({onyxMethod, key, value}) => {
switch (onyxMethod) {
case OnyxUtils.METHOD.SET:
promises.push(() => set(key, value));
enqueueSetOperation(key, value);
break;
case OnyxUtils.METHOD.MERGE:
promises.push(() => merge(key, value));
enqueueMergeOperation(key, value);
break;
case OnyxUtils.METHOD.MERGE_COLLECTION:
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- We validated that the value is a collection
promises.push(() => mergeCollection(key, value as any));
case OnyxUtils.METHOD.MERGE_COLLECTION: {
const collection = value as Collection<CollectionKey, unknown, unknown>;
if (!OnyxUtils.isValidNonEmptyCollectionForMerge(collection)) {
Logger.logInfo('mergeCollection enqueued within update() with invalid or empty value. Skipping this operation.');
break;
}

// Confirm all the collection keys belong to the same parent
const collectionKeys = Object.keys(collection);
if (OnyxUtils.doAllCollectionItemsBelongToSameParent(key, collectionKeys)) {
const mergedCollection: OnyxInputKeyValueMapping = collection;
collectionKeys.forEach((collectionKey) => enqueueMergeOperation(collectionKey, mergedCollection[collectionKey]));
}

break;
}
case OnyxUtils.METHOD.MULTI_SET:
promises.push(() => multiSet(value));
Object.entries(value).forEach(([entryKey, entryValue]) => enqueueSetOperation(entryKey, entryValue));
break;
case OnyxUtils.METHOD.CLEAR:
clearPromise = clear();
Expand All @@ -738,6 +763,58 @@ function update(data: OnyxUpdate[]): Promise<void> {
}
});

// Group all the collection-related keys and update each collection in a single `mergeCollection` call.
// This is needed to prevent multiple `mergeCollection` calls for the same collection and `merge` calls for the individual items of the said collection.
// This way, we ensure there is no race condition in the queued updates of the same key.
OnyxUtils.getCollectionKeys().forEach((collectionKey) => {
const collectionItemKeys = Object.keys(updateQueue).filter((key) => OnyxUtils.isKeyMatch(collectionKey, key));
if (collectionItemKeys.length <= 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;
}

const batchedCollectionUpdates = collectionItemKeys.reduce(
(queue: MixedOperationsQueue, key: string) => {
const operations = updateQueue[key];

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

const updatedValue = OnyxUtils.applyMerge(undefined, operations, false);
if (operations[0] === null) {
// eslint-disable-next-line no-param-reassign
queue.set[key] = updatedValue;
} else {
// eslint-disable-next-line no-param-reassign
queue.merge[key] = updatedValue;
}
return queue;
},
{
merge: {},
set: {},
},
);

if (!utils.isEmptyObject(batchedCollectionUpdates.merge)) {
promises.push(() => mergeCollection(collectionKey, batchedCollectionUpdates.merge as Collection<CollectionKey, unknown, unknown>));
}
if (!utils.isEmptyObject(batchedCollectionUpdates.set)) {
promises.push(() => multiSet(batchedCollectionUpdates.set));
}
});

Object.entries(updateQueue).forEach(([key, operations]) => {
const batchedChanges = OnyxUtils.applyMerge(undefined, operations, false);

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

return clearPromise
.then(() => Promise.all(promises.map((p) => p())))
.then(() => updateSnapshots(data))
Expand Down
Loading

0 comments on commit dc00235

Please sign in to comment.