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

Fix concurrent writes in the update operation #384

Merged
merged 5 commits into from
Oct 6, 2023

Conversation

ospfranco
Copy link
Contributor

Details

The update operation was written in such manner that it fired all database operations at once. Some of the operations internally rely on other promises, meaning they will get re-scheduled on another tick. This causes unpredictable behavior as you cannot guarantee the order of the operations. The solution is clear and is the only one, all DB writes must respect the order of the operations.

The incorrect ordering of the operations being committed to the DB causes other random behavior around the app as well. Linked the two issues.

Related Issues

Expensify/App#28737
Expensify/App#28584

Automated Tests

Added one more test that makes sure the correct data is persisted. It failed with the previous implementation, now it passes.

Linked PRs

@ospfranco ospfranco requested a review from a team as a code owner October 4, 2023 11:36
@melvin-bot melvin-bot bot requested review from deetergp and removed request for a team October 4, 2023 11:37
expect(otherTestCallback).toHaveBeenNthCalledWith(1, 'pizza', ONYX_KEYS.OTHER_TEST);
expect(collectionCallback).toHaveBeenNthCalledWith(2, {[itemKey]: {a: 'a'}});
expect(testCallback).toHaveBeenNthCalledWith(2, 'taco', ONYX_KEYS.TEST_KEY);
expect(otherTestCallback).toHaveBeenNthCalledWith(2, 'pizza', ONYX_KEYS.OTHER_TEST);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This previously was receiving the write updates before the hydration callback, but now that the write operations take a little longer the order is correct.

});

return clearPromise.then(() => Promise.all(_.map(promises, p => p())));
return innerUpdate(data);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

As a data point, I tried the proposed solution by Marc which would try to resolve earlier while keeping the writes async, this however is not correct and broke a bunch of other tests.

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.

Has there been any testing done in App to see what kind of performance impact this will have? If so, can you please attach it to this PR?

lib/Onyx.js Outdated
@@ -1534,6 +1539,7 @@ const Onyx = {
connect,
disconnect,
set,
get,
Copy link
Collaborator

Choose a reason for hiding this comment

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

It was pretty import to avoid exporting this so that people don't get lazy in App and start using it all over the place (which encourages a lot of chaining calls). It looks like you're exporting it for use in the tests and I would like to propose these alternatives:

  • Inside the tests use Onyx.connect() to get the value (this is the preferred method)
  • Rename this export to something like onlyToBeUsedInTestCode_get

Copy link
Contributor Author

Choose a reason for hiding this comment

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

From what I saw Onyx.connect has caching implemented, which might not be what is actually persisted on the DB. I was about to directly query the DB which is at the end of the day what really matters. I think this get might also use caches right? if so, checking the DB should be the correct path.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I like the idea of querying the DB directly if that's possible.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

@ospfranco
Copy link
Contributor Author

No, I haven't tested the app. I can try to get some metrics but I already asked in slack if there is any particular call to update that is heavy.

@tgolen
Copy link
Collaborator

tgolen commented Oct 4, 2023

The OpenApp and ReconnectApp API commands would be the best place to start. Those are the ones receiving the largest calls to Onyx.update().

@ospfranco
Copy link
Contributor Author

I added some react-native-performance measures on app start, I cannot tell which one exactly is OpenApp but they are all basically negible. Writing to DB should not be the bottleneck, and even then, correctness is not negotiable.

Oscar Franco Screen 000065

@ospfranco ospfranco requested a review from tgolen October 5, 2023 06:37
@tgolen
Copy link
Collaborator

tgolen commented Oct 5, 2023

OK, I just created a large account for testing things like this, so let me give it a try with this today.

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.

I tested it in App with a large account and I couldn't notice any performance impact, so I think these are good changes. I just noticed this one thing.

@@ -1392,6 +1392,36 @@ function mergeCollection(collectionKey, collection) {
});
}

function innerUpdate(data) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Please add JSDoc

@marcaaron
Copy link
Contributor

Doing some testing now...

@marcaaron
Copy link
Contributor

Alright well - it is defying my expectations a bit, but when timing the calls to Onyx.update() they are running way faster synchronously 😅

Sign in trials

With sync updates:

1

Timing:development.new.expensify.flushQueue 458
Timing.js:49 Timing:development.new.expensify.flushQueue 0
Timing.js:49 Timing:development.new.expensify.flushQueue 407

2

Timing:development.new.expensify.flushQueue 468
Timing.js:49 Timing:development.new.expensify.flushQueue 1
Timing.js:49 Timing:development.new.expensify.flushQueue 191

3

Timing:development.new.expensify.flushQueue 311
Timing.js:49 Timing:development.new.expensify.flushQueue 0
Timing.js:49 Timing:development.new.expensify.flushQueue 226

Without sync updates (but on latest react-native-onyx):

1

Timing:development.new.expensify.flushQueue 488
Timing.js:49 Timing:development.new.expensify.flushQueue 4147
Timing.js:49 Timing:development.new.expensify.flushQueue 962

2

Timing:development.new.expensify.flushQueue 420
Timing.js:49 Timing:development.new.expensify.flushQueue 1126
Timing.js:49 Timing:development.new.expensify.flushQueue 170

3

Timing:development.new.expensify.flushQueue 464
Timing.js:49 Timing:development.new.expensify.flushQueue 4729
Timing.js:49 Timing:development.new.expensify.flushQueue 976

This was a web-only test. Haven't tested on iOS or Android - but looks promising.

}
});

return clearPromise.then(() => Promise.all(_.map(promises, p => p())));
Copy link
Contributor

Choose a reason for hiding this comment

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

I see we are undoing the previous logic to move the clear() to the front of the update queue. Does running the updates sync solve the issue that caused us to make that change?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

ah, probably not. I'll take care of it.

Copy link
Contributor Author

@ospfranco ospfranco Oct 6, 2023

Choose a reason for hiding this comment

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

I'm not sure making sure clear runs first is a desired behavior but I guess now it is too late to change it, other parts of the system might rely on it. I added sorting to the list to make sure clear operations get executed first.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, I agree it is a bit of a weird hack. But changing it could have unexpected consequences. Thanks for updating.

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.

🚀

@marcaaron marcaaron merged commit 01e2bab into Expensify:main Oct 6, 2023
3 checks passed
Comment on lines +1408 to +1426
switch (onyxMethod) {
case METHOD.SET:
promise = set(key, value);
break;
case METHOD.MERGE:
promise = merge(key, value);
break;
case METHOD.MERGE_COLLECTION:
promise = mergeCollection(key, value);
break;
case METHOD.MULTI_SET:
promise = multiSet(value);
break;
case METHOD.CLEAR:
promise = clear();
break;
default:
break;
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

It looks like this change possibly led to some regressions spotted in this PR and discussed in this Slack thread that updated the Onyx version

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