-
Notifications
You must be signed in to change notification settings - Fork 69
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
Set default key states when clearing the cache #129
Conversation
How can I get Onyx.merge or Onyx.set to run between clearing the cache and clearing the storage? I tried inserting a lot of data into storage before clearing, but that didn't work. I need some kind of callback in between, or I need to make Storage.clear take a really long time. Right now I have this test for Case 1: it('should store merged values when calling merge on a default key after clear', () => {
let defaultValue;
connectionID = Onyx.connect({
key: ONYX_KEYS.DEFAULT_KEY,
initWithStoredValues: false,
callback: val => defaultValue = val,
});
console.log('Calling clear in test');
const afterClear = Onyx.clear();
console.log('Calling merge');
const afterMerge = Onyx.merge(ONYX_KEYS.DEFAULT_KEY, 'merged');
return Promise.all([afterClear, afterMerge])
.then(() => {
console.log('defaultValue = ', defaultValue);
expect(defaultValue).toEqual('merged');
return Storage.getItem(ONYX_KEYS.DEFAULT_KEY);
})
.then((storedValue) => {
const cachedValue = cache.getValue(ONYX_KEYS.DEFAULT_KEY);
console.log('cachedValue = ', cachedValue);
console.log('storedValue = ', storedValue);
expect(cachedValue).toEqual(storedValue);
});
}); I'm also console logging when Onyx starts clearing Storage and when it stops:
|
I'm not sure if I understand the question exactly, but
We can't call really call One idea - though I'm not sure if it will work - is to add the ability to make the |
I can understand how we could create a version of Storage.clear that is delayed, but I don't get how we would make Onyx.clear() call that version. I thought about adding an optional callback param to Onyx.clear(). By doing this I can get merge or set to run in between. The problem with this approach of course is that the code is only used for the tests. I also tried injecting the code into Onyx.clear during the test and then calling eval, based on this SO answer, but I couldn't get it to work and it's kind of a messy solution. function clear(testingCallback = null) {
return getAllKeys()
.then((keys) => {
_.each(keys, (key) => {
const resetValue = lodashGet(defaultKeyStates, key, null);
keyChanged(key, resetValue);
cache.set(key, resetValue);
});
})
.then(() => new Promise((resolve) => {
if (testingCallback) {
resolve(testingCallback());
} else {
resolve();
}
}))
.then(Storage.clear)
} |
With jest you can mock whatever you want which will force Actually, there are some tests using a mock already since node doesn't have "storage"... react-native-onyx/__mocks__/localforage.js Lines 29 to 34 in f6c2b03
Maybe just create something like |
Here's an example where we took the mock and added our own test functionality to it by setting some "initial data" react-native-onyx/tests/unit/onyxMultiMergeWebStorageTest.js Lines 3 to 18 in 2d42566
|
Awesome, thanks so much for steering me in the right direction here. Jest is new to me and I struggled with it quite a bit yesterday, but hopefully I can get it working today. |
I went down a bit of a rabbit hole again unfortunately, but I learned a few things in the process. When calling merge or set after clear the value will get set in the cache right away. However, If we merge and then clear storage the defaultKeyState will not be set in the storage. Default key states are not set in storage after clearing on this branch or on |
Ready for review now. For cases 3 and 4, I did not include an assertion that the stored value matches the cached value after setting data and then clearing Onyx.
|
Sorry, @neil-marcellini I'm going OOO for a bit. List of all contributors here might be a good bet for a replacement reviewer: https://github.com/Expensify/react-native-onyx/graphs/contributors |
Where are the unit tests being added? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These changes look fine.
Depending on how you want to mock Storage, you can either init Onyx with Lines 844 to 848 in ce5bd2c
Or you can override the storage mock like so: unit-test.jsimport Storage from '../../lib/storage';
// makes `Storage` imported above to be the one from `storage/__mocks__`
jest.mock('../../lib/storage');
// we can now override methods like remove the `keepInstancesSync` and/or change the `clear` method
delete Storage.keepInstancesSync;
Storage.clear = jest.fn(() => Promise.resolve());
// The important bit is to import/init Onyx only after `Storage` had been overridden
const Onyx = require('../../index').default;
describe('My tests', () => {
/* ... */
}); I can help writing unit tests if you'd like |
Based on this slack thread Marc suggested that we should not set However, you made me realize that I could mock Since I have a lot of fixes blocked on this change and the manual tests work well, may I go ahead and merge it now? Or should we add the unit tests first? @tgolen @kidroca |
I personally feel like any change to Onyx (especially fixing or changing behavior) should always be done from a unit-test first approach. So, unless there is any reason to move forward without having unit tests (like it's fixing a fire or something), then I think we should wait until the unit tests are done. Admittedly, I don't know the context of all the fixes blocked on this change though. |
Ok, that makes sense. I'm not fixing a fire here. All of the transition flow fixes are blocked on this but I don't think it's super urgent. I will work on the unit tests again and I'll ask @kidroca for help when I need it. Thanks for reviewing! |
By default Onyx uses react-native-onyx/lib/storage/index.js Lines 3 to 6 in ce5bd2c
So unless we call In unit tests you can simply import
unit-test.jsimport Storage from '../../lib/storage';
Storage.clear = jest.fn(() => Promise.resolve());
describe('My tests', () => {
/* ... */
}); If you want to be more explicit about it you can instead specify a mock like so: unit-test.jsimport Storage from '../../lib/storage';
jest.mock('../../lib/storage', () => {
const NativeStorage = require('../../lib/storage/NativeStorage').default;
NativeStorage.clear = jest.fn(() => Promise.resolve());
return NativeStorage;
});
describe('My tests', () => {
/* ... */
}); |
Since in unit tests it's not possible to use a real storage provider, trying to mimic specific storage provider behavior (like The functionality you're changing depends solely on To assert
|
I addressed Tim's comments and I followed up with the plan from our call. I still have a question from #129 (comment) and I would appreciate another draft review. I'm assuming that I should still add these test cases for web storage as well? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @neil-marcellini, I gave this a more thorough review. Ultimately, I think the code is looking fine, but I'm having a really difficult time making sense out of the tests (but appreciate you taking the time to look into them).
I think at this point it would be best if we greatly simplified the testing portion here and just test whatever we reasonably can without introducing delays and creative mocks. If we run into an actual issue with the code we can try to resolve it and react by creating a test.
const resetValue = lodashGet(defaultKeyStates, key, null); | ||
cache.set(key, resetValue); | ||
|
||
// Optimistically inform subscribers on the next tick |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll add the context here so we can stay in this thread. @neil-marcellini said
On our call I forgot to mention that I added this line to fix test case 4 (merge then clear). This line sends the default value to subscribers after the merge value. I will update the comment to explain that.
Not sure I get it. If we are calling a merge()
then a clear()
in the same tick then wouldn't the correct behavior be for the clear()
to cancel the merge()
... ?
Thanks for the review Marc! I should have mentioned that I'm not done with the WebStorage tests yet. Overall I think I made these tests too complicated. I was tracking the resolve order of the Storage calls to be explicit about when each call completes, but it's really not necessary so I will remove all of that complexity. I was putting the Storage calls into a queue based off of this comment, but maybe I interpreted this wrong and it is also unnecessary.
I will try to greatly simplify these tests while still making sure that |
const cachedValue = cache.getValue(ONYX_KEYS.DEFAULT_KEY); | ||
expect(cachedValue).toBe(MERGED_VALUE); | ||
const storedValue = Storage.getItem(ONYX_KEYS.DEFAULT_KEY); | ||
return expect(storedValue).resolves.toBe(MERGED_VALUE); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The storedValue
is resolving to null
and I have no idea why. From some logging I added it looks like Storage.setItem(ONYXKEYS.DEFAULT_KEY, MERGED_VALUE)
called from Onyx.set
is the last call to resolve before the assertions, so why is the value null
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's look at what Storage.getItem()
doing in the context of this test...
It will get the value inside the mock here so I'd imagine that this code is running and clearing the value.
It seems like this validates my concern that a value merged after Onyx.clear()
will not actually make it into device storage, but does make it to the cache. Though, I'm not sure whether this is a problem or not for our applications using Onyx.
If it causes no issues for us in App
I think it would be best to create an issue to improve this later and document that it's a known issue.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems like this validates my concern that a value merged after
Onyx.clear()
will not actually make it into device storage, but does make it to the cache.
Yeah I think you are right. I did some logging with the web storage test and what I'm seeing is that merge()
is called before the cache is cleared from clear()
. I would like to go back to my previous test where I call merge()
in between the cache and storage clearing. That test works and it is the case that this PR aims to fix.
react-native-onyx/tests/unit/onyxClearNativeStorageTest.js
Lines 50 to 56 in 1781223
Storage.clear = jest.fn(() => { | |
// 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); | |
AsyncStorageMock.clear(); | |
return waitForPromisesToResolve(); | |
}); | |
Onyx.clear(); |
If it causes no issues for us in
App
I think it would be best to create an issue to improve this later and document that it's a known issue.
I don't think it creates any issues in the App and I agree that we should create an issue to improve it later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would like to go back to my previous test where I call merge() in between the cache and storage clearing. That test works and it is the case that this PR aims to fix.
Can we back up and re-state what it is exactly that we are trying to test with recreating the "in between the cache and storage clearing" condition?
If this test is failing I think that's fine, but we must change the assertion and add some documentation that basically explains how there is a remote possibility that values might not be saved to device storage and that it's a known issue.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we back up and re-state what it is exactly that we are trying to test with recreating the "in between the cache and storage clearing" condition?
I think I was trying to test to see if Storage.setItem()
waits for Storage.clear()
to finish before it runs, based on this Slack comment. However, embarrassingly, I'm still not quite sure why I tried to test in between the cache and storage clearing.
If this test is failing I think that's fine, but we must change the assertion and add some documentation that basically explains how there is remove possibility that values might not be saved to device storage and that it's a known issue.
Ok. It seems that any Onyx action to set data around Onyx.clear()
might be unreliable because the data that gets set in Onyx, the cache, and Storage depends on how long it takes async calls to run. The async calls include Onyx.get()
, Onyx.getAllKeys()
and Storage.clear()
and Storage.setItem()
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It seems that any Onyx action to set data around Onyx.clear() might be unreliable because the data that gets set in Onyx, the cache, and Storage depends on how long it takes async calls to run.
I think it's less drastic and probably more common that Storage.clear()
happens after we set a value because of the general order of operations and cached values that we are working with.
Onyx.set()
immediately saves to storage
Lines 540 to 556 in b61c904
function set(key, value) { | |
// Logging properties only since values could be sensitive things we don't want to log | |
Logger.logInfo(`set() called for key: ${key}${_.isObject(value) ? ` properties: ${_.keys(value).join(',')}` : ''}`); | |
// eslint-disable-next-line no-use-before-define | |
if (hasPendingMergeForKey(key)) { | |
Logger.logAlert(`Onyx.set() called after Onyx.merge() for key: ${key}. It is recommended to use set() or merge() not both.`); | |
} | |
// Adds the key to cache when it's not available | |
cache.set(key, value); | |
// Optimistically inform subscribers on the next tick | |
Promise.resolve().then(() => keyChanged(key, value)); | |
// Write the thing to persistent storage, which will trigger a storage event for any other tabs open on this domain | |
return Storage.setItem(key, value) |
Onyx.merge()
has to wait for get()
before calling set()
(the get value is usually cached so we will 99% of the time just continue on the next tick and not a random time)
Lines 667 to 683 in b61c904
function merge(key, value) { | |
if (mergeQueue[key]) { | |
mergeQueue[key].push(value); | |
return Promise.resolve(); | |
} | |
mergeQueue[key] = [value]; | |
return get(key) | |
.then((data) => { | |
try { | |
const modifiedData = applyMerge(key, data); | |
// Clean up the write queue so we | |
// don't apply these changes again | |
delete mergeQueue[key]; | |
return set(key, modifiedData); |
Onyx.clear()
has to wait for getAllKeys()
before it calls Storage.clear()
(this value is also cached so we will continue on the next tick not a random time)
Lines 713 to 721 in b61c904
function clear() { | |
return getAllKeys() | |
.then((keys) => { | |
_.each(keys, (key) => { | |
keyChanged(key, null); | |
cache.set(key, null); | |
}); | |
}) | |
.then(Storage.clear) |
Here are my thoughts...
- clear -> set - 100% of the time a set after clear will not get saved to storage
- clear -> merge - If we have a merge after clear it is not possible for
Storage.clear()
to get called after theset()
insidemerge()
because it will always run before theget()
inmerge()
resolves - no matter how long theget()
takes. - merge -> clear - Our
get()
inmerge()
would have to take longer thangetAllKeys()
andStorage.clear()
and in that case it seems possible for it to set a stale value into storage. This would be very rare since most values will immediately return from the cache. - set -> clear - There is no promise in
set()
so it's impossible for us to set a stale value asStorage.setItem()
will always run beforeStorage.clear()
Only 1 and 3 seem like problems we might want to solve, but it's not clear if we need to yet.
One idea for how to fix this would be something like:
set after clear solution:
- Set an internal flag that
Onyx.clear()
is still running and hasn't fully cleared storage. - While that happens batch any new writes to storage or flag which keys need to be saved after we are done, but allow cached values to be set, merged, whatever.
- Once
Storage.clear()
is done we can sync up any queued "writes" with the values in the cache.
merge before clear solution:
- Add a "cancelled" state to any merges going into the merge queue here:
Lines 668 to 671 in b61c904
if (mergeQueue[key]) { mergeQueue[key].push(value); return Promise.resolve(); } - When we call clear() flag all the things in the merge queue as cancelled.
- Don't process any cancelled items when applying the merge.
There could be other solutions but I'm not sure if we need these ones or others.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Currently case 2 is the only one that fails in my automated tests. Case 1 also fails but I have deleted it. All cases work in the manual tests.
Thank you for outlining the potential solutions. Could we please leave those for a follow up? Should I add an example and documentation for case 2 to the Onyx.clear()
JSDoc? Can I remove case 2 like I removed case 1?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we please leave those for a follow up?
Yeah I am not sure we even need to follow up. Creating an issue could be good but it doesn't seem like a priority right now.
Should I add an example and documentation for case 2 to the Onyx.clear() JSDoc?
That sounds good and yes remove whichever cases you think make sense to remove based on this.
@marcaaron I was tinkering around and found that if I call Also, with this change and for the native storage test exclusively, it is escaping the quotes in the value. Do you know why that is or how to get around it? I did find this SO that sort of explains why, but not how to fix it. |
Ok so moving the
Maybe I was wrong about that, but the change you are suggesting now makes it true? |
Hmm not entirely sure but seems maybe related to stringifying JSON. Maybe try something that isn't a string as a value - object e.g. |
Yes
It's super weird. It seems like the mock isn't very good. I wrote a work-around using integers and |
}) | ||
.then(Storage.clear) | ||
.then(initializeWithDefaultKeyStates); | ||
Storage.clear(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
NAB
Storage.clear(); | |
return Storage.clear(); |
const resetValue = lodashGet(defaultKeyStates, key, null); | ||
cache.set(key, resetValue); | ||
|
||
// Optimistically inform subscribers on the next tick |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I still think we should improve this later but the context is here so I'm good with this.
cc @marcaaron
Details
When we clear Onyx and reset the cache set every value to
null
OR the default key state if one exists for that key. Remove the call toinitializeWithDefaultKeyStates
since it is no longer needed. These changes prevent updating Onyx subscribers with out of date information by sending only the defaultKeyStates. See the issue for more info and an example.Related Issues
#125
Automated Tests
onyxClearNativeStorageTest.js and onyxClearWebStorageTest.js
Manual Tests
I had a lot of trouble adding an automated tests earlier so I also added manual tests.
npm i
to install this Onyx version that has been modified for testingFor each case below:
npm run web
command + f
in the console and search 'Onyx test'Linked PRs
Expensify/App#8855