-
Notifications
You must be signed in to change notification settings - Fork 74
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
Fix race condition with clearing Onyx and the SyncQueue #220
Changes from 20 commits
d7cfb12
02a6004
842be42
b98d3c5
c570823
88a165c
fb3ab20
4a2faf6
c58061c
264be3c
a64df8f
b1016ea
c158e4f
d7ef5d3
e4313d1
5cb00d2
93bb6ee
f6312e8
a5a35c9
c5f138c
0219ea9
17cb9cd
212f27e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -1050,6 +1050,13 @@ function clear(keysToPreserve = []) { | |
const keyValuesToReset = []; | ||
const defaultKeys = _.keys(defaultKeyStates); | ||
|
||
// Get all the values for the keys that need to be preserved. These key/value pairs will be set | ||
// in Onyx after the database is cleared(). | ||
const keyValuesToPreserve = _.reduce(keysToPreserve, (finalArray, keyToPreserve) => { | ||
finalArray.push([keyToPreserve, cache.getValue(keyToPreserve)]); | ||
return finalArray; | ||
}, []); | ||
|
||
// 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) | ||
|
@@ -1102,7 +1109,11 @@ function clear(keysToPreserve = []) { | |
notifyCollectionSubscribersOnNextTick(key, value); | ||
}); | ||
|
||
return Storage.multiSet(keyValuesToReset); | ||
// Call clear() and make sure that the default key/values and the key/values from the parameter | ||
// are preserved in storage. This makes sure to always leave storage in a state that contains | ||
// all the default values and any additional values that we want to remain after the database is cleared. | ||
return Storage.clear() | ||
.then(() => Storage.multiSet(defaultKeyValuePairs.concat(keyValuesToPreserve))); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why do we use Storage.multiSet([...defaultKeyValuePairs, ...keyValuesToPreserve]) both There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I try to stay away from using the spread operator on code that needs to be super performant. Babel will transpile to something that's pretty slow. It's probably not really necessary here, but it's the habit I've been in. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This might be Code const arr3 = [...arr1, ...arr2]; Transpiled var arr3 = [].concat(arr1, arr2); You can check that out in the live https://babeljs.io/repl though I think babel stopped transpiling the spread operator as it is supported natively by all alive browsers: https://caniuse.com/?search=spread%20operator I think if we tell webpack/babel to ignore IE11 it would just stop transpiling the spread operator There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, that would be great to stop transpiling it altogether. We are not interested in supporting IE11. I didn't know that about arrays. I'll go ahead and switch this to a spread operator. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Here's a small PR regarding that: #226 |
||
}); | ||
} | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,8 @@ | ||
/** | ||
* This file is here to wrap LocalForage with a layer that provides data-changed events like the ones that exist | ||
* when using LocalStorage APIs in the browser. These events are great because multiple tabs can listen for when | ||
* data changes and then stay up-to-date with everything happening in Onyx. | ||
*/ | ||
import _ from 'underscore'; | ||
import Storage from './providers/LocalForage'; | ||
|
||
|
@@ -27,10 +32,23 @@ const webStorage = { | |
.then(() => raiseStorageSyncEvent(key)); | ||
|
||
// If we just call Storage.clear other tabs will have no idea which keys were available previously | ||
// so that they can call keysChanged for them. That's why we iterate and remove keys one by one | ||
this.clear = () => Storage.getAllKeys() | ||
.then(keys => _.map(keys, key => this.removeItem(key))) | ||
.then(tasks => Promise.all(tasks)); | ||
// so that they can call keysChanged for them. That's why we iterate over every key and raise a storage sync | ||
// event for each one | ||
this.clear = () => { | ||
let allKeys; | ||
|
||
// They keys must be retreived before storage is cleared or else the list of keys would be empty | ||
return Storage.getAllKeys() | ||
.then((keys) => { | ||
allKeys = keys; | ||
}) | ||
.then(() => Storage.clear()) | ||
.then(() => { | ||
// Now that storage is cleared, the storage sync event can happen so that it is more of an atomic | ||
// action | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe it's only me, but it bothers me that this has to be a 2 line comments just because of 1 word |
||
_.each(allKeys, raiseStorageSyncEvent); | ||
}); | ||
}; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can avoid the closure variable by doing something like this.clear = () => {
return Storage.getAllKeys()
.then((allKeys) => {
return Storage.clear()
.then(() => _.each(allKeys, raiseStorageSyncEvent))
});
}; Although code has more nesting, it conveys the information you have to explain using comments here I guess this is opinion based and there's no hard rule on how to style such concepts There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Makes sense, yeah. I prefer the clarity of my current code than trying to reason about those nested promises. Thanks! |
||
|
||
// This listener will only be triggered by events coming from other tabs | ||
global.addEventListener('storage', (event) => { | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -50,26 +50,6 @@ describe('Set data while storage is clearing', () => { | |
return Onyx.clear(); | ||
}); | ||
|
||
it('should persist the value of Onyx.merge when called between the cache and storage clearing', () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @neil-marcellini I saw you added these tests, and as far as I can tell, they are no longer necessary because the behavior of Basically, the way I read this is that the problem only happened because we were done something like this:
My PR here changes this behavior so that no one should ever be doing that anymore. It should now become:
So that's why I think it's OK to remove these tests since they no longer test a valid use case. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These tests make sure that the following flow works for keys with a default key state, like the session.
I think it might still be valuable to test calling There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OK, thanks! My point is that this...
...isn't possible to do. If there is a valid use case of calling There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Ah ok nice. If that's the case for all platforms then I agree that we don't need this test any more 👍.
I don't think we are based on the code here. As soon as the session key is cleared we are signed out, and that can happen before Onyx finishes clearing. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Ah yes, you're technically right. However, the session key is removed at the same time as all other keys now (because it is once again using |
||
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); | ||
const storedValue = Storage.getItem(ONYX_KEYS.DEFAULT_KEY); | ||
return expect(storedValue).resolves.toBe(MERGED_VALUE); | ||
}); | ||
}); | ||
|
||
it('should replace the value of Onyx.set with the default key state in the cache', () => { | ||
expect.assertions(3); | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,6 +2,8 @@ import localforage from 'localforage'; | |
import _ from 'underscore'; | ||
|
||
import StorageProvider from '../../../../lib/storage/providers/LocalForage'; | ||
import createDeferredTask from '../../../../lib/createDeferredTask'; | ||
import waitForPromisesToResolve from '../../../utils/waitForPromisesToResolve'; | ||
|
||
describe('storage/providers/LocalForage', () => { | ||
const SAMPLE_ITEMS = [ | ||
|
@@ -104,4 +106,35 @@ describe('storage/providers/LocalForage', () => { | |
}); | ||
}); | ||
}); | ||
|
||
it('clear', () => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Almost there. I think you just need to change the mock like so: // Given an implementation of setItem that resolves after 1000ms
localForage.setItem = jest.fn(() => new Promise(
(resolve) => setTimeout(resolve, 1000))
);
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah hm... in actuality, we want to measure how many times the promise is resolved then, right? But there's no other way to do that other than having a callback get called when the promise resolves? So my thought was to do something like this instead: it('clear', () => {
// Use fake timers, so we can manipulate time at our will for this test.
jest.useFakeTimers();
// Given a mocked implementation of setItem that calls a callback function after 1000ms
const callback = jest.fn();
localforage.setItem = jest.fn(() => new Promise(resolve => setTimeout(resolve, 1000)).then(callback));
// When we call setItem 5 times, but then call clear after only 1000ms
for (let i = 0; i < 5; i++) {
StorageProvider.setItem(`key${i}`, `value${i}`);
}
// We should not have called the mocked callback for setItem yet because we have not advanced the timer.
expect(callback).not.toHaveBeenCalled();
jest.advanceTimersByTime(1000);
StorageProvider.clear();
jest.advanceTimersByTime(4000);
// The mocked callback for setItem should only have been called once since all other calls were aborted when we called clear()
expect(callback).toHaveBeenCalledTimes(1);
}); However I'm seeing that's not quite working as I would expect 🤔
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also, I noticed you had the comment here noting that "fake timers cause promises to hang" - wonder if that might be the same reason for the issue I'm seeing? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (Btw, just tested this again using real timers and I don't see this problem. So I do have a suspicion this is somehow related to FakeTimers) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I guess it might look like that from a certain point of view, but it's not necessary If we make 5
I'm can't remember what lead me to that conclusion... This might also need calls to
I'm not sure how you test this with real timers, but if you don't wait for the timers to expire the test should appear as passing successfully There's an alternative way to test this without using timers const task = createDeferredTask();
localforage.setItem = jest.fn()
. mockReturnValue(Promise.resolve()) // default value
. mockReturnValueOnce(task.promise) // first call
for (let i = 0; i < 5; i++) {
StorageProvider.setItem(`key${i}`, `value${i}`);
}
// at this point `localForage.setItem` should be called once, but we control when it resolves
// and we keep it unresolved
// this should queue any calls that follow, so we don't expect more than 1 `localForage.setItem` call
StorageProvider.clear();
// we simulate the 1st setItem resolves after we called clear
task.resolve()
return waitForPromisesToResolve().then(() => {
expect(localForage.setItem).toHaveBeenCalledTimes(1);
}) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Running this test against the pre-PR code should produce exactly that error
So the promise of the 5th call waits the promise of the 4th call and only then calls There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah, amazing explanation - thanks for taking the time to explain that to me I appreciate it! Okay, let's go with this then since it doesn't require us to update jest in this PR. |
||
// We're creating a Promise which we programatically control when to resolve. | ||
const task = createDeferredTask(); | ||
|
||
// We configure localforage.setItem to return this promise the first time it's called and to otherwise return resolved promises | ||
localforage.setItem = jest.fn() | ||
. mockReturnValue(Promise.resolve()) // Default behavior | ||
. mockReturnValueOnce(task.promise); // First call behavior | ||
|
||
// Make 5 StorageProvider.setItem calls - this adds 5 items to the queue and starts executing the first localForage.setItem | ||
for (let i = 0; i < 5; i++) { | ||
StorageProvider.setItem(`key${i}`, `value${i}`); | ||
} | ||
|
||
// At this point,`localForage.setItem` should have been called once, but we control when it resolves, and we'll keep it unresolved. | ||
// This simulates the 1st localForage.setItem taking a random time. | ||
// We then call StorageProvider.clear() while the first localForage.setItem isn't completed yet. | ||
StorageProvider.clear(); | ||
|
||
// Any calls that follow this would have been queued - so we don't expect more than 1 `localForage.setItem` call after the | ||
// first one resolves. | ||
task.resolve(); | ||
|
||
// waitForPromisesToResolve() makes jest wait for any promises (even promises returned as the result of a promise) to resolve. | ||
// If StorageProvider.clear() does not abort the queue, more localForage.setItem calls would be executed because they would | ||
// be sitting in the setItemQueue | ||
return waitForPromisesToResolve().then(() => { | ||
expect(localforage.setItem).toHaveBeenCalledTimes(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.
Instead of using
_.reduce
it's simpler to just use_.map
We remap every key we want to preserve to a key-value pair, in the end we still end up with the same list of key-value pairs
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.
Agreed, makes things much easier to read.