-
Notifications
You must be signed in to change notification settings - Fork 78
Replace old value after null
merges in nested properties when batching merges/updates
#620
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
base: main
Are you sure you want to change the base?
Replace old value after null
merges in nested properties when batching merges/updates
#620
Conversation
Strangely the Maybe the |
My tests were "wrong", now it will output the errors in the two scenarios of both Onyx.update() and Onyx.merge() |
null
merges in nested properties when batching merges/updatesnull
merges in nested properties when batching merges/updates
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.
Left some comments
// To achieve this, we first mark these nested objects with an internal flag. With the desired objects | ||
// marked, when calling this method again with "shouldReplaceMarkedObjects" set to true we can proceed | ||
// to effectively replace them in the next condition. | ||
if (isBatchingMergeChanges && targetValue === 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.
Could we do something like this?
if (isBatchingMergeChanges && targetValue === null) { | |
if (isBatchingMergeChanges && !targetValue) { |
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.
We should explicitly check for null
. undefined
values are not supposed to delete a key. Effectively, they should never be in store anyway, but i think making this explicit makes more sense
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.
We did that previously, but actually for this logic, we might want to rethink that.
If we merge a nested object with an existing key, we would expect undefined
values to be set, rather than discarded.
So in this case i would still explicitly check for if (targetValue == null)
(which includes undefined
values)
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.
LGTM! I added a few comments around the SQLite part and tests, but this has a GO from me!
// To achieve this, we first mark these nested objects with an internal flag. With the desired objects | ||
// marked, when calling this method again with "shouldReplaceMarkedObjects" set to true we can proceed | ||
// to effectively replace them in the next condition. | ||
if (isBatchingMergeChanges && targetValue === 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.
We should explicitly check for null
. undefined
values are not supposed to delete a key. Effectively, they should never be in store anyway, but i think making this explicit makes more sense
// To achieve this, we first mark these nested objects with an internal flag. With the desired objects | ||
// marked, when calling this method again with "shouldReplaceMarkedObjects" set to true we can proceed | ||
// to effectively replace them in the next condition. | ||
if (isBatchingMergeChanges && targetValue === 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.
We did that previously, but actually for this logic, we might want to rethink that.
If we merge a nested object with an existing key, we would expect undefined
values to be set, rather than discarded.
So in this case i would still explicitly check for if (targetValue == null)
(which includes undefined
values)
return this.multiGet(nonNullishPairsKeys).then((storagePairs) => { | ||
// multiGet() is not guaranteed to return the data in the same order we asked with "nonNullishPairsKeys", | ||
// so we use a map to associate keys to their existing values correctly. | ||
const existingMap = new Map<OnyxKey, OnyxValue<OnyxKey>>(); | ||
// eslint-disable-next-line @typescript-eslint/prefer-for-of | ||
for (let i = 0; i < storagePairs.length; i++) { | ||
existingMap.set(storagePairs[i][0], storagePairs[i][1]); | ||
} | ||
|
||
const newPairs: KeyValuePairList = []; | ||
|
||
// eslint-disable-next-line @typescript-eslint/prefer-for-of | ||
for (let i = 0; i < nonNullishPairs.length; i++) { | ||
const key = nonNullishPairs[i][0]; | ||
const newValue = nonNullishPairs[i][1]; | ||
|
||
const existingValue = existingMap.get(key) ?? {}; | ||
|
||
const mergedValue = utils.fastMerge(existingValue, newValue, true, false, true); | ||
|
||
newPairs.push([key, mergedValue]); | ||
} | ||
|
||
return this.multiSet(newPairs); | ||
}); |
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'm really not sure about the performance implications of this change on native.
I know it is hard to have SQLite handle merges with through ON CONFLICT DO UPDATE
and JSON_PATCH
, but this was supposed to add significant performance gains, since we can basically offload all of the merging (at least with the existing values in store) to the low-level C++ implementation of SQLite. Ideally we would want to let SQLite handle as much of merging/batching as possible.
I see though, that we already had a lot of exceptions to this before and that we still always needed to "pre-merge" in order to broadcast the update, so i think it should be fine for now. In a bigger re-design we could tackle and improve all of this, to make sure that each platform is facilitated to it's fullest.
Still, do we have any benchmarks around how this affects performance of simple Onyx.merge
operations?
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.
Yes I think it would be a good idea to measure performance in the app before and after this change to make sure it doesn't cause a large performance hit.
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.
+1 but I am less convinced that this is "fine for now". It feels like we are undoing work that we had a good reason to do at some point. I trust that we are moving in a good direction, but would rather let some benchmarks do the talking.
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 agree with the changes others suggested. I would like to see the tests cleaned up a little and agree with Chris that we should measure the performance impact of this.
I'm also going to request a review from @marcaaron now that he is back and because he has a good perspective about how Onyx should work.
return this.multiGet(nonNullishPairsKeys).then((storagePairs) => { | ||
// multiGet() is not guaranteed to return the data in the same order we asked with "nonNullishPairsKeys", | ||
// so we use a map to associate keys to their existing values correctly. | ||
const existingMap = new Map<OnyxKey, OnyxValue<OnyxKey>>(); | ||
// eslint-disable-next-line @typescript-eslint/prefer-for-of | ||
for (let i = 0; i < storagePairs.length; i++) { | ||
existingMap.set(storagePairs[i][0], storagePairs[i][1]); | ||
} | ||
|
||
const newPairs: KeyValuePairList = []; | ||
|
||
// eslint-disable-next-line @typescript-eslint/prefer-for-of | ||
for (let i = 0; i < nonNullishPairs.length; i++) { | ||
const key = nonNullishPairs[i][0]; | ||
const newValue = nonNullishPairs[i][1]; | ||
|
||
const existingValue = existingMap.get(key) ?? {}; | ||
|
||
const mergedValue = utils.fastMerge(existingValue, newValue, true, false, true); | ||
|
||
newPairs.push([key, mergedValue]); | ||
} | ||
|
||
return this.multiSet(newPairs); | ||
}); |
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.
Yes I think it would be a good idea to measure performance in the app before and after this change to make sure it doesn't cause a large performance hit.
Can you please update the description to mention which customizations are not compatible with JSON_PATCH? I will review today, thanks! |
Is it possible to reword some of this for clarity?
Specifically, "handle the replacement of old values after null merges" didn't quite make sense to me.
A part of me also finds the description a bit confusing. Maybe you can also explain what a "marked object" is in the description? Thanks! |
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.
This is generally looking good. Thanks for the work on it @fabioh8010 @chrispader. My main feedback (that @neil-marcellini already called out) would be to make sure that JSON_PATCH
in the native provider is something we can confidently get rid of. If so, then 👍.
@@ -307,8 +307,10 @@ function merge<TKey extends OnyxKey>(key: TKey, changes: OnyxMergeInput<TKey>): | |||
} | |||
|
|||
try { | |||
// We first only merge the changes, so we can provide these to the native implementation (SQLite uses only delta changes in "JSON_PATCH" to merge) |
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.
A part of me is confused about why we are getting rid of JSON_PATCH. Another part believes that this was a benefit since when we do this we end up passing a smaller delta change instead of a potentially large object and therefore it should be more efficient (hand wave there as I do not entirely recall anymore).
@@ -346,11 +348,12 @@ function merge<TKey extends OnyxKey>(key: TKey, changes: OnyxMergeInput<TKey>): | |||
return Promise.resolve(); | |||
} | |||
|
|||
// For providers that can't handle delta changes, we need to merge the batched changes with the existing value beforehand. |
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.
flagging this since I still have questions about JSON_PATCH
before we remove it...
return this.multiGet(nonNullishPairsKeys).then((storagePairs) => { | ||
// multiGet() is not guaranteed to return the data in the same order we asked with "nonNullishPairsKeys", | ||
// so we use a map to associate keys to their existing values correctly. | ||
const existingMap = new Map<OnyxKey, OnyxValue<OnyxKey>>(); | ||
// eslint-disable-next-line @typescript-eslint/prefer-for-of | ||
for (let i = 0; i < storagePairs.length; i++) { | ||
existingMap.set(storagePairs[i][0], storagePairs[i][1]); | ||
} | ||
|
||
const newPairs: KeyValuePairList = []; | ||
|
||
// eslint-disable-next-line @typescript-eslint/prefer-for-of | ||
for (let i = 0; i < nonNullishPairs.length; i++) { | ||
const key = nonNullishPairs[i][0]; | ||
const newValue = nonNullishPairs[i][1]; | ||
|
||
const existingValue = existingMap.get(key) ?? {}; | ||
|
||
const mergedValue = utils.fastMerge(existingValue, newValue, true, false, true); | ||
|
||
newPairs.push([key, mergedValue]); | ||
} | ||
|
||
return this.multiSet(newPairs); | ||
}); |
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.
+1 but I am less convinced that this is "fine for now". It feels like we are undoing work that we had a good reason to do at some point. I trust that we are moving in a good direction, but would rather let some benchmarks do the talking.
@fabioh8010 please request our reviews again when it's ready |
Sure, I started working through the comments today and preparing better explanation for you before requesting. |
… batchMergeChanges() function
Updates:
TODO
|
@fabioh8010 what is the latest update? How is it coming along? |
@neil-marcellini Sorry for the late, I was working on two high priority design docs and it was a bit difficult to focus on this, but now I have more capacity and will be addressing the remaining stuff today/tomorrow at most. |
Performance AnalysisFor these analysis I tested on Android (the only provider that have substantial changes is the native one) and profiled Baseline (main)
Delta (this PR)
As you can see the operations are quite more expensive now, probably due to having to use I will try to look for another solution for the native provider, maybe I can keep the JSON_PATCH and only do additional SQL operations for the properties we need to fully replace. |
Updates:
|
Just FYI that I will be OOO next week and will return on May 5th 🌴 |
Updates:
|
Updates:
|
Updates:
|
Cool! Lmk if you need any help with the Onyx storage layer or SQLite related stuff, since i'm the maintainer of |
For sure man, thanks! I need to do some cleanup before, and for now I won't merge with |
Updates:
|
Updates:
|
Updates:
|
Updates:
|
Details
#615 (comment)
This PR:
applyMerge
/fastMerge
/mergeObject
) to have two additional flags:isBatchingMergeChanges
andshouldReplaceMarkedObjects
. TheisBatchingMergeChanges
flag will be used when batching any queued merge changes, as it has a special logic to handle the replacement of old values afternull
merges. TheshouldReplaceMarkedObjects
flag will be used when applying this batched merge changes to the existing value, using this special logic to effectively replace the old values we desire.Related Issues
#615
Automated Tests
Unit tests were added to cover these changes.
Manual Tests
Same as Expensify/App#55199.
Author Checklist
### Related Issues
section aboveTests
sectiontoggleReport
and notonIconClick
)myBool && <MyComponent />
.STYLE.md
) were followedAvatar
, I verified the components usingAvatar
are working as expected)/** comment above it */
this
properly so there are no scoping issues (i.e. foronClick={this.submit}
the methodthis.submit
should be bound tothis
in the constructor)this
are necessary to be bound (i.e. avoidthis.submit = this.submit.bind(this);
ifthis.submit
is never passed to a component event handler likeonClick
)Avatar
is modified, I verified thatAvatar
is working as expected in all cases)main
branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTest
steps.Screenshots/Videos
Android: Native
Screen.Recording.2025-03-25.at.22.13.11-compressed.mov
Android: mWeb Chrome
I'm having problems with my emulators when opening the Chrome app (they crash instantly), so I couldn't record videos for this platform.
iOS: Native
Screen.Recording.2025-03-25.at.22.29.49-compressed.mov
iOS: mWeb Safari
Screen.Recording.2025-03-25.at.22.34.02-compressed.mov
MacOS: Chrome / Safari
Screen.Recording.2025-03-25.at.22.36.15-compressed.mov
Screen.Recording.2025-03-25.at.22.37.28-compressed.mov
MacOS: Desktop
Screen.Recording.2025-03-25.at.22.43.39-compressed.mov