-
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
Add sync writes to multiMerge() in WebStorage. Fix caching when multiMerge() runs. #122
Changes from all commits
7537744
ca0f4a5
7a2e722
d4e0d10
3fa2aea
1ac6096
29fd8a3
451b8c1
f6bad97
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,37 @@ | ||
import _ from 'underscore'; | ||
|
||
let storageMap = {}; | ||
|
||
const localforageMock = { | ||
get storageMap() { | ||
return storageMap; | ||
}, | ||
keys() { | ||
return new Promise((resolve) => { | ||
resolve(_.keys(storageMap)); | ||
}); | ||
}, | ||
setInitialMockData: (data) => { | ||
storageMap = data; | ||
}, | ||
config: jest.fn(), | ||
getItem: jest.fn(), | ||
setItem: jest.fn(() => Promise.resolve()), | ||
getItem: jest.fn(key => new Promise((resolve) => { | ||
resolve(storageMap[key]); | ||
})), | ||
setItem: jest.fn((key, value) => new Promise((resolve) => { | ||
storageMap[key] = value; | ||
resolve(); | ||
})), | ||
removeItem: jest.fn(key => new Promise((resolve) => { | ||
delete storageMap[key]; | ||
resolve(); | ||
})), | ||
clear() { | ||
return new Promise((resolve) => { | ||
storageMap = {}; | ||
resolve(); | ||
}); | ||
}, | ||
}; | ||
|
||
export default localforageMock; |
Original file line number | Diff line number | Diff line change | ||
---|---|---|---|---|
@@ -0,0 +1,51 @@ | ||||
/** | ||||
* Synchronous queue that can be used to ensure promise based tasks are run in sequence. | ||||
* Pass to the constructor a function that returns a promise to run the task then add data. | ||||
* | ||||
* @example | ||||
* | ||||
* const queue = new SyncQueue(({key, val}) => { | ||||
* return someAsyncProcess(key, val); | ||||
* }); | ||||
* | ||||
* queue.push({key: 1, val: '1'}); | ||||
* queue.push({key: 2, val: '2'}); | ||||
*/ | ||||
export default class SyncQueue { | ||||
/** | ||||
* @param {Function} run - must return a promise | ||||
*/ | ||||
constructor(run) { | ||||
this.queue = []; | ||||
this.isProcessing = false; | ||||
this.run = run; | ||||
} | ||||
|
||||
process() { | ||||
if (this.isProcessing || this.queue.length === 0) { | ||||
return; | ||||
} | ||||
|
||||
this.isProcessing = true; | ||||
|
||||
const {data, resolve, reject} = this.queue.shift(); | ||||
this.run(data) | ||||
.then(resolve) | ||||
.catch(reject) | ||||
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. Should we log or do anything with failed requests? Or I guess we will have server logs 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. We do have some handling here (but it could be better). In this case, we're just letting the original caller handle the error though and don't need more specific handling (at least not for now since the error will reach this code here).. Line 778 in 032ff54
|
||||
.finally(() => { | ||||
this.isProcessing = false; | ||||
this.process(); | ||||
}); | ||||
} | ||||
|
||||
/** | ||||
* @param {*} data | ||||
* @returns {Promise} | ||||
*/ | ||||
push(data) { | ||||
return new Promise((resolve, reject) => { | ||||
this.queue.push({resolve, reject, data}); | ||||
this.process(); | ||||
}); | ||||
} | ||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
import WebStorage from '../WebStorage'; | ||
|
||
export default WebStorage; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,172 @@ | ||
import OnyxCache from '../../lib/OnyxCache'; | ||
import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; | ||
import localforageMock from '../../__mocks__/localforage'; | ||
|
||
const ONYX_KEYS = { | ||
COLLECTION: { | ||
TEST_KEY: 'test_', | ||
}, | ||
}; | ||
|
||
const initialTestObject = {a: 'a'}; | ||
const initialData = { | ||
test_1: initialTestObject, | ||
test_2: initialTestObject, | ||
test_3: initialTestObject, | ||
}; | ||
|
||
localforageMock.setInitialMockData(initialData); | ||
|
||
describe('Onyx.mergeCollection() amd WebStorage', () => { | ||
let Onyx; | ||
|
||
beforeAll(() => { | ||
// Force using WebStorage provider for these tests | ||
jest.mock('../../lib/storage'); | ||
Onyx = require('../../index').default; | ||
jest.useRealTimers(); | ||
|
||
Onyx.init({ | ||
keys: ONYX_KEYS, | ||
registerStorageEventListener: () => {}, | ||
initialKeyStates: {}, | ||
}); | ||
}); | ||
|
||
afterEach(() => Onyx.clear()); | ||
|
||
it('merges two sets of data consecutively', () => { | ||
// Given initial data in storage | ||
expect(localforageMock.storageMap.test_1).toEqual(initialTestObject); | ||
expect(localforageMock.storageMap.test_2).toEqual(initialTestObject); | ||
expect(localforageMock.storageMap.test_3).toEqual(initialTestObject); | ||
|
||
// And an empty cache values for the collection keys | ||
expect(OnyxCache.getValue('test_1')).not.toBeDefined(); | ||
expect(OnyxCache.getValue('test_2')).not.toBeDefined(); | ||
expect(OnyxCache.getValue('test_3')).not.toBeDefined(); | ||
|
||
// When we merge additional data | ||
const additionalDataOne = {b: 'b', c: 'c'}; | ||
Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { | ||
test_1: additionalDataOne, | ||
test_2: additionalDataOne, | ||
test_3: additionalDataOne, | ||
}); | ||
|
||
// And call again consecutively with different data | ||
const additionalDataTwo = {d: 'd'}; | ||
Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { | ||
test_1: additionalDataTwo, | ||
test_2: additionalDataTwo, | ||
test_3: additionalDataTwo, | ||
}); | ||
|
||
return waitForPromisesToResolve() | ||
.then(() => { | ||
const finalObject = { | ||
a: 'a', b: 'b', c: 'c', d: 'd', | ||
}; | ||
|
||
// Then our new data should merge with the existing data in the cache | ||
expect(OnyxCache.getValue('test_1')).toEqual(finalObject); | ||
expect(OnyxCache.getValue('test_2')).toEqual(finalObject); | ||
expect(OnyxCache.getValue('test_3')).toEqual(finalObject); | ||
|
||
// And the storage should reflect the same state | ||
expect(localforageMock.storageMap.test_1).toEqual(finalObject); | ||
expect(localforageMock.storageMap.test_2).toEqual(finalObject); | ||
expect(localforageMock.storageMap.test_3).toEqual(finalObject); | ||
}); | ||
}); | ||
|
||
it('cache updates correctly when accessed again if keys are removed or evicted', () => { | ||
// Given empty storage | ||
expect(localforageMock.storageMap.test_1).toBeFalsy(); | ||
expect(localforageMock.storageMap.test_2).toBeFalsy(); | ||
expect(localforageMock.storageMap.test_3).toBeFalsy(); | ||
|
||
// And an empty cache values for the collection keys | ||
expect(OnyxCache.getValue('test_1')).toBeFalsy(); | ||
expect(OnyxCache.getValue('test_2')).toBeFalsy(); | ||
expect(OnyxCache.getValue('test_3')).toBeFalsy(); | ||
|
||
// When we merge additional data and wait for the change | ||
const data = {a: 'a', b: 'b'}; | ||
Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { | ||
test_1: data, | ||
test_2: data, | ||
test_3: data, | ||
}); | ||
|
||
return waitForPromisesToResolve() | ||
.then(() => { | ||
// Then the cache and storage should match | ||
expect(OnyxCache.getValue('test_1')).toEqual(data); | ||
expect(OnyxCache.getValue('test_2')).toEqual(data); | ||
expect(OnyxCache.getValue('test_3')).toEqual(data); | ||
expect(localforageMock.storageMap.test_1).toEqual(data); | ||
expect(localforageMock.storageMap.test_2).toEqual(data); | ||
expect(localforageMock.storageMap.test_3).toEqual(data); | ||
|
||
// When we drop all the cache keys (but do not modify the underlying storage) and merge another object | ||
OnyxCache.drop('test_1'); | ||
OnyxCache.drop('test_2'); | ||
OnyxCache.drop('test_3'); | ||
|
||
const additionalData = {c: 'c'}; | ||
Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { | ||
test_1: additionalData, | ||
test_2: additionalData, | ||
test_3: additionalData, | ||
}); | ||
|
||
return waitForPromisesToResolve(); | ||
}) | ||
.then(() => { | ||
const finalObject = { | ||
a: 'a', b: 'b', c: 'c', | ||
}; | ||
|
||
// Then our new data should merge with the existing data in the cache | ||
expect(OnyxCache.getValue('test_1')).toEqual(finalObject); | ||
expect(OnyxCache.getValue('test_2')).toEqual(finalObject); | ||
expect(OnyxCache.getValue('test_3')).toEqual(finalObject); | ||
|
||
// And the storage should reflect the same state | ||
expect(localforageMock.storageMap.test_1).toEqual(finalObject); | ||
expect(localforageMock.storageMap.test_2).toEqual(finalObject); | ||
expect(localforageMock.storageMap.test_3).toEqual(finalObject); | ||
}); | ||
}); | ||
|
||
it('setItem() and multiMerge()', () => { | ||
expect(localforageMock.storageMap).toEqual({}); | ||
|
||
// Given no previous data and several calls to setItem and call to mergeCollection to update a given key | ||
|
||
// 1st call | ||
Onyx.set('test_1', {a: 'a'}); | ||
|
||
// These merges will all queue together | ||
Onyx.merge('test_1', {b: 'b'}); | ||
Onyx.merge('test_1', {c: 'c'}); | ||
|
||
// 2nd call | ||
Onyx.mergeCollection(ONYX_KEYS.COLLECTION.TEST_KEY, { | ||
test_1: {d: 'd', e: 'e'}, | ||
}); | ||
|
||
// Last call | ||
Onyx.merge('test_1', {f: 'f'}); | ||
return waitForPromisesToResolve() | ||
.then(() => { | ||
const finalObject = { | ||
a: 'a', b: 'b', c: 'c', f: 'f', d: 'd', e: 'e', | ||
}; | ||
|
||
expect(OnyxCache.getValue('test_1')).toEqual(finalObject); | ||
expect(localforageMock.storageMap.test_1).toEqual(finalObject); | ||
}); | ||
}); | ||
}); |
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 makes sense, but:
get
has to read from disk, data might not yet be written to disk (when write is still pending), and still result in partial data being added to cachekeysChanged
would be delayed ifget
actually has to read somethingPoint 2) is probably covered by
keysChnaged
being delayed as well - anyone that read stale information would be notified of an update, but we'd still need to explain that and warn anyone to keep usages together in that order.The goal with cache is to update it sync as fast as possible to avoid concurrency issues
This solves problems like
set
starts and setskey: alpha
get
starts forkey: alpha
Perhaps you'd like this alternative:
cache.merge
to exclude merging any keys that are not currently present in cacheThe idea is:
If we lack data in cache, we skip merging potentially partial data
When someone actually needs to
get
this key, it would trigger a read from disk and re-populate cache with correct dataWe don't have many usages of
cache.merge
but this could address the partial data problem for all such usagesThere 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.
If you can, please explain how exactly it can result in partial data being added to the cache? I would love to write a test for it and we can come up with a solution if it's possible, but I want to make sure there we understand how it can happen.
One alternative I can think of is to prefill the cache with all data before Onyx does any work at all (guessing most front end caches work this way).
Yes, but better for something to be delayed than send partial data without previous data initially in storage.
I think I understand this argument, but I'm not sure that's how
merge()
works today. Here's the implementation formerge()
which also potentially blocks with aget()
callreact-native-onyx/lib/Onyx.js
Lines 672 to 681 in 032ff54
set()
is the only thing which modifies the data in the cache first before reading existing valuesreact-native-onyx/lib/Onyx.js
Lines 548 to 551 in 032ff54
I do like this idea and it solves this problem in a different way, but we would defer the filling of the cache. I'm not sure if that is bad or good or doesn't make a difference and would be willing to try it if it sounds simpler. 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.
Ok so pulling on that last thread and I can't get it working. It leads to a stale cache that is missing the data. Here are the changes I made:
I think it's because the subscriber can be added at anytime while we are adding new data. We already skipped the cache for any consecutive calls to mergeCollection() and yes they are making it to storage correctly. But if you add a subscriber at the same time while this process is happened it will only get the result of what has been written and save that to the cache.
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.
Yeah, this case slipped my mind
I think you answered that yourself at the end or at least the reason is similar
mergeCollection
call and then anothermergeCollection
call(s) in close proximityPromise.all(_.map(existingKeys, get))
is invoked before scheduled writes completeexistingKeys
is not available in cacheget
from disk is triggered - it reads older data and puts it in cacheTo capture that in a test we're looking for a way to:
{ a: 1, b: 1, c: 1 }
for a givenkey
key
being written at the end of the queue. Changingb: 2
key
mergeCollection
to merge multiple updates, one of them regardingkey
settingc: 2
I think that what we have here with Promise.all would result in cache ending up with
vs
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.
Hey @kidroca, I couldn't figure this one out, but got your other idea working. Let me know if you have any other thoughts here.
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 think prefilling cache might be our safest and optimal option here
It seems that for Native:
get
would resolve with the value from step 2)I'm not certain this would work out the same way on Web/Desktop
It used to work that way when we used
window.localStorage
because local storage calls are sync and happen sequentiallySo we're back to: "Oh, Cache save us from concurrency issues" 😄
And to do that it might be best to leverage
multiGet
and get everything but safe eviction keys in cache during initThen we don't have to delay updates to cache and cache should never become stale
Another strategy could be to recreated the above 3 steps ourselves