-
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 all 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 |
---|---|---|
|
@@ -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.
@neil-marcellini I saw you added these tests, and as far as I can tell, they are no longer necessary because the behavior of
clear()
has been changed. Can you please take a look at this and make sure that I am not introducing regressions that I'm not aware 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 comment
The 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
Onyx.clear()
and thenOnyx.merge
on a key with a default state.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.
OK, thanks! My point is that this...
...isn't possible to do.
Onyx.clear()
takes less that 200ms to complete now on an account with 4000 reports, as well as we are no longer optimistically redirecting to the sign-on page. We are waiting untilOnyx.clear()
is finished before the redirect happens.If there is a valid use case of calling
clear -> merge
, I could see having a test, but I don't know if any time that would ever be a valid case. Do you have a specific one in mind? If that does exist in the code, it should be rewritten like I showed above.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.
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 comment
The 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
LocalForage.clear()
under the hood rather than using amultiSet()
ormultiRemove()
).