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

Update Onyx.clear to accept passed in keys to preserve, and use multiSet to clear storage #214

Merged
merged 22 commits into from
Dec 9, 2022

Conversation

yuwenmemon
Copy link
Contributor

@yuwenmemon yuwenmemon commented Dec 8, 2022

@neil-marcellini please review
cc @marcaaron @tgolen

Details

Add a keysToPreserve param to clear to allow it to accept passed-in keys to preserve when clearing Onyx. To better support this, clear Onyx by using multiSet to set all keys to null (except the ones we want to preserve)

Related Issues

Expensify/App#13062

Automated Tests

Added tests in onyxClearWebStorageTest.js and onyxClearNativeStorageTest.js

Additionally, ran some quick performance tests on Android to make sure there were no regressions:

main:
DEBUG  [info] YUWEN clearStorageAndRedirect timing: 208 - ""
DEBUG  [info] YUWEN clearStorageAndRedirect timing: 273 - ""
DEBUG  [info] YUWEN clearStorageAndRedirect timing: 260 - ""
DEBUG  [info] YUWEN clearStorageAndRedirect timing: 230 - ""

yuwen-additionalDefaultsMultiSet:
DEBUG  [info] YUWEN clearStorageAndRedirect timing: 206 - ""
DEBUG  [info] YUWEN clearStorageAndRedirect timing: 215 - ""
DEBUG  [info] YUWEN clearStorageAndRedirect timing: 215 - ""
DEBUG  [info] YUWEN clearStorageAndRedirect timing: 218 - ""

Linked PRs

Expensify/App#13384

@yuwenmemon yuwenmemon self-assigned this Dec 8, 2022
@yuwenmemon yuwenmemon requested a review from a team as a code owner December 8, 2022 18:49
@melvin-bot melvin-bot bot requested review from amyevans and removed request for a team December 8, 2022 18:50
@@ -73,15 +74,12 @@ describe('Set data while storage is clearing', () => {
Onyx.clear();
return waitForPromisesToResolve()
.then(() => {
// Then the value in Onyx and the cache is the default key state
// Then the value in Onyx, the cache, and Storage is the default key state
Copy link
Contributor Author

Choose a reason for hiding this comment

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

A nice side effect of this change: the cache and Storage are actually in sync when clearing.

Copy link
Contributor

Choose a reason for hiding this comment

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

Love that

@yuwenmemon yuwenmemon force-pushed the yuwen-additionalDefaultsMultiSet branch from e98f078 to 851da7d Compare December 8, 2022 19:04
marcaaron
marcaaron previously approved these changes Dec 8, 2022
Copy link
Contributor

@marcaaron marcaaron left a comment

Choose a reason for hiding this comment

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

Changes look good to me. I haven't had a chance to test though.

lib/Onyx.js Outdated Show resolved Hide resolved
@@ -73,15 +74,12 @@ describe('Set data while storage is clearing', () => {
Onyx.clear();
return waitForPromisesToResolve()
.then(() => {
// Then the value in Onyx and the cache is the default key state
// Then the value in Onyx, the cache, and Storage is the default key state
Copy link
Contributor

Choose a reason for hiding this comment

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

Love that

yuwenmemon and others added 2 commits December 8, 2022 11:38
Co-authored-by: Marc Glasser <marc.aaron.glasser@gmail.com>
lib/Onyx.js Outdated
@@ -1025,17 +1028,22 @@ function initializeWithDefaultKeyStates() {
* Storage.setItem() from Onyx.clear() will have already finished and the merged
* value will be saved to storage after the default value.
*
* @param {Array} keysToPreserve ONYXKEYS which values you want to keep
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
* @param {Array} keysToPreserve ONYXKEYS which values you want to keep
* @param {Array} keysToPreserve is a list of ONYXKEYS which values you don't want to be cleared with the rest of the data

The original description read a little funny to me and I think this is more clear

Copy link
Contributor

Choose a reason for hiding this comment

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

That's a good point Tim, I find the wording odd too, especially "which". What about this?

* @param {Array} keysToPreserve is a list of ONYXKEYS that should not be cleared with the rest of the data

lib/Onyx.js Outdated Show resolved Hide resolved
lib/Onyx.js Outdated
const defaultKeys = _.keys(defaultKeyStates);
const keysToClear = _.without(keys, ..._.union(keysToPreserve, defaultKeys));
const keyValuesToClear = _.map(keysToClear, key => [key, null]);
const defaultKeyValues = _.pairs(_.omit(defaultKeyStates, ...keysToPreserve));
Copy link
Collaborator

Choose a reason for hiding this comment

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

Same here, why a spread operator?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Same as above

Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
const defaultKeyValues = _.pairs(_.omit(defaultKeyStates, ...keysToPreserve));
const defaultKeyValuePairs = _.pairs(_.omit(defaultKeyStates, ...keysToPreserve));

lib/Onyx.js Outdated
const keyValuesToClear = _.map(keysToClear, key => [key, null]);
const defaultKeyValues = _.pairs(_.omit(defaultKeyStates, ...keysToPreserve));
const newStoreKeyValues = _.union(keyValuesToClear, defaultKeyValues);
_.each(newStoreKeyValues, (keyValue) => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Are we still sure that we want this to be done before it's actually cleared in Onyx? Could we instead move this to multiSet().then() so that there is no possibility of a weird race condition where a subscriber has gotten a value, but the storage still hasn't cleared it yet?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah so switching this around breaks the test here:

it('should persist the value of Onyx.merge when called between the cache and storage clearing', () => {
expect.assertions(3);
// Given that Onyx is completely clear
// When Onyx.clear() is called
Onyx.clear();
// When merge is called between the cache and storage clearing, on a key with a default key state
Onyx.merge(ONYX_KEYS.DEFAULT_KEY, MERGED_VALUE);
return waitForPromisesToResolve()
.then(() => {
// Then the value in Onyx, the cache, and the storage is the merged value
expect(onyxValue).toBe(MERGED_VALUE);
const cachedValue = cache.getValue(ONYX_KEYS.DEFAULT_KEY);
expect(cachedValue).toBe(MERGED_VALUE);
// Use parseInt to convert the string value from the storage mock back to an integer
return Storage.getItem(ONYX_KEYS.DEFAULT_KEY)
.then(storedValue => expect(parseInt(storedValue, 10)).toBe(MERGED_VALUE));
});
});

Where the onyxValue ends up being the DEFAULT_VALUE instead. What I think is happening is that because we're calling notifySubscribersOnNextTick only after clearing the storage that tick happens at the same time / after the merge call and this thus the DEFAULT_VALUE is preserved.

@neil-marcellini thoughts on this? I know you dealt with this weird race condition and clear before...

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah so the problem we had with Onyx.clear() for the transition flows is that after Onyx cleared and the previous user was signed out, we would sign in the new transitioning user (with an Onyx merge), and then the default Onyx value would get set for the session key, which would sign the user out again.

So yes we need to make sure, that clear() followed by merge() on a key with a default state results in the merged value getting saved.

Copy link
Collaborator

Choose a reason for hiding this comment

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

OK, that's a nasty race condition, and I think notifySubscribersOnNextTick really bites us in that instance. It would be great to move a lot of the context from these comments into the code comment.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@neil-marcellini feel free to take a look at the updated comment I added above this and see if you agree 👍

lib/Onyx.js Outdated
const keysToClear = _.without(keys, ..._.union(keysToPreserve, defaultKeys));
const keyValuesToClear = _.map(keysToClear, key => [key, null]);
const defaultKeyValues = _.pairs(_.omit(defaultKeyStates, ...keysToPreserve));
const newStoreKeyValues = _.union(keyValuesToClear, defaultKeyValues);
Copy link
Collaborator

Choose a reason for hiding this comment

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

It's a bit confusing at this point what exactly is being done with all this logic. Maybe adding some comments will help? Maybe explaining why we need to keep doing stuff with default keys, and why we are doing all the keys(), without(), map(), pairs(), union(). It's all just a tad confusing.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Thanks for the additional comments! I think what is still confusing is some of the variable names.

  • keyValuesToClear sounds like it would be the thing that eventually is passed to multiSet() just by the name of it
  • newStoreKeyValues is what actually gets passed to multiSet() but the name doesn't really imply that at all. "New" is pretty meaningless in the name of this variable

What I would suggest is to start with an empty variable:

const keyValuesToClear = [];

Then as you progress through the logic, things will get added to that array.

keyValuesToClear.push(defaultKeyValues);

Eventually, that's what gets passed to multiSet

Storage.multiSet(keyValuesToClear);

return waitForPromisesToResolve()
.then(() => {
// Then the value in Onyx and the cache for the default key is the default key state
expect(onyxValue).toBe(DEFAULT_VALUE);
Copy link
Collaborator

Choose a reason for hiding this comment

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

It appears like these assertions are already done in the test above. Why is it necessary to test them again? Just to ensure that passing a list of keys doesn't change the default behavior? If so, maybe we can DRY this up a little bit.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just to ensure that passing a list of keys doesn't change the default behavior?

Yeah, that was the thought. Although yeah I think it's kind of redundant just thinking about the code. I think it'll be clearer just to remove this in hindsight 👍

tests/unit/onyxClearNativeStorageTest.js Show resolved Hide resolved
});

it('should preserve the value of any keysToPreserve passed in', () => {
expect.assertions(6);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is there a purpose for this?

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, it helps to make sure that assertions within async callbacks are actually called during the test. In my experience it's really easy to mess up and have an assertion that jest does not wait for. https://jestjs.io/docs/expect#expectassertionsnumber

Copy link
Collaborator

Choose a reason for hiding this comment

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

Huh, interesting. I've never used it, but the point about async assertions makes sense.

Onyx.set(ONYX_KEYS.REGULAR_KEY, SET_VALUE);

// When clear is called with a key to preserve
Onyx.clear([ONYX_KEYS.REGULAR_KEY]);
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think you want to be sure that the clear() doesn't happen until set() is done, so I would chain those together.

@@ -141,7 +141,11 @@ describe('Onyx.mergeCollection() amd WebStorage', () => {
});

it('setItem() and multiMerge()', () => {
expect(localforageMock.storageMap).toEqual({});
expect(localforageMock.storageMap).toEqual({
test_1: null,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why this change?

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 pretty sure he made this change because now instead of removing all of the keys and values when clearing Storage, the keys remain but all the values are set to null.

Copy link
Collaborator

Choose a reason for hiding this comment

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

ahhh, OK. Could you add a code comment about that please?

Copy link
Contributor

@neil-marcellini neil-marcellini left a comment

Choose a reason for hiding this comment

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

This approach looks great. I have two small comments.

tests/unit/onyxClearNativeStorageTest.js Outdated Show resolved Hide resolved
tests/unit/onyxClearNativeStorageTest.js Outdated Show resolved Hide resolved
@yuwenmemon yuwenmemon force-pushed the yuwen-additionalDefaultsMultiSet branch from 85b0a43 to 7f7f06b Compare December 8, 2022 21:50
@yuwenmemon
Copy link
Contributor Author

Updated!

Copy link
Collaborator

@tgolen tgolen left a comment

Choose a reason for hiding this comment

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

Sorry for being such a stickler for all the comments! This code is really complex and it's very important that intention and logic are super clear.

lib/Onyx.js Outdated Show resolved Hide resolved
lib/Onyx.js Outdated Show resolved Hide resolved
lib/Onyx.js Outdated
const defaultKeys = _.keys(defaultKeyStates);
const keysToClear = _.without(keys, ..._.union(keysToPreserve, defaultKeys));
const keyValuesToClear = _.map(keysToClear, key => [key, null]);
const defaultKeyValues = _.pairs(_.omit(defaultKeyStates, ...keysToPreserve));
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
const defaultKeyValues = _.pairs(_.omit(defaultKeyStates, ...keysToPreserve));
const defaultKeyValuePairs = _.pairs(_.omit(defaultKeyStates, ...keysToPreserve));

lib/Onyx.js Outdated
Comment on lines 1043 to 1044
// If keysToPreserve contains a key that also has a default key state, we'll preserve it rather than reset
// it to the default.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can you please update this comment to replace the two instances of "it" with what the reference is? It's a little ambiguous and makes it hard to understand. The comment could also do a little bit better job of explaining why we want this behavior and the intention behind it. Why do we handle the conflict between keysToPreserve and defaultKeyStates this way as opposed to some other way?

lib/Onyx.js Outdated
const keysToClear = _.without(keys, ..._.union(keysToPreserve, defaultKeys));
const keyValuesToClear = _.map(keysToClear, key => [key, null]);
const defaultKeyValues = _.pairs(_.omit(defaultKeyStates, ...keysToPreserve));
const newStoreKeyValues = _.union(keyValuesToClear, defaultKeyValues);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Thanks for the additional comments! I think what is still confusing is some of the variable names.

  • keyValuesToClear sounds like it would be the thing that eventually is passed to multiSet() just by the name of it
  • newStoreKeyValues is what actually gets passed to multiSet() but the name doesn't really imply that at all. "New" is pretty meaningless in the name of this variable

What I would suggest is to start with an empty variable:

const keyValuesToClear = [];

Then as you progress through the logic, things will get added to that array.

keyValuesToClear.push(defaultKeyValues);

Eventually, that's what gets passed to multiSet

Storage.multiSet(keyValuesToClear);

lib/Onyx.js Outdated
const keyValuesToClear = _.map(keysToClear, key => [key, null]);
const defaultKeyValues = _.pairs(_.omit(defaultKeyStates, ...keysToPreserve));
const newStoreKeyValues = _.union(keyValuesToClear, defaultKeyValues);
_.each(newStoreKeyValues, (keyValue) => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

OK, that's a nasty race condition, and I think notifySubscribersOnNextTick really bites us in that instance. It would be great to move a lot of the context from these comments into the code comment.

tests/unit/onyxClearNativeStorageTest.js Show resolved Hide resolved
tests/unit/onyxClearNativeStorageTest.js Show resolved Hide resolved
tests/unit/onyxMultiMergeWebStorageTest.js Outdated Show resolved Hide resolved
yuwenmemon and others added 4 commits December 9, 2022 11:02
Co-authored-by: Tim Golen <tgolen@expensify.com>
Co-authored-by: Tim Golen <tgolen@expensify.com>
Co-authored-by: Tim Golen <tgolen@expensify.com>
@yuwenmemon
Copy link
Contributor Author

Updated!

lib/Onyx.js Outdated Show resolved Hide resolved
lib/Onyx.js Outdated
// Combine the pairs of keys we're clearing and keys we're setting back to default for our multiSet call
const newStoreKeyValues = _.union(keyValuesToClear, defaultKeyValues);
// Add the default key value pairs to the keyValuesToClear so that they get set back to their default values when we clear Onyx
keyValuesToClear.push(...defaultKeyValuePairs);
Copy link
Contributor

Choose a reason for hiding this comment

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

Nitpick: what about keyValuesToReset? We aren't clearing the defaultKeys we are actually resetting them.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I like!

Co-authored-by: Neil Marcellini <neil@expensify.com>
lib/Onyx.js Outdated Show resolved Hide resolved
Comment on lines +1054 to +1055
// We do this before clearing Storage so that any call to clear() followed by merge() on a key with a default state results in the merged value getting saved, since the update
// from the merge() call would happen on the tick after the update from this clear()
Copy link
Contributor

Choose a reason for hiding this comment

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

I like this 👍

@yuwenmemon
Copy link
Contributor Author

Updated!

Copy link
Collaborator

@tgolen tgolen left a comment

Choose a reason for hiding this comment

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

Looks great now, thanks for all the attention on those comments!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants