Skip to content
This repository was archived by the owner on Jun 21, 2023. It is now read-only.

Commit 645ea8a

Browse files
authored
feat: React native datafile manager that supports Datafile caching. (optimizely#430)
Summary: This overrides existing HttpPollingDatafileManager to support Datafile Caching for React Native. Browser and Node implementations can also override the same methods to support datafile caching. Test plan Unit Tests
1 parent 24fe5d6 commit 645ea8a

6 files changed

+223
-32
lines changed

packages/datafile-manager/__test__/httpPollingDatafileManager.spec.ts

+119-25
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import HttpPollingDatafileManager from '../src/httpPollingDatafileManager'
1818
import { Headers, AbortableRequest, Response } from '../src/http'
1919
import { DatafileManagerConfig } from '../src/datafileManager';
2020
import { advanceTimersByTime, getTimerCount } from './testUtils'
21+
import PersistentKeyValueCache from '../src/persistentKeyValueCache'
2122

2223
jest.mock('../src/backoffController', () => {
2324
return jest.fn().mockImplementation(() => {
@@ -39,6 +40,8 @@ class TestDatafileManager extends HttpPollingDatafileManager {
3940

4041
responsePromises: Promise<Response>[] = []
4142

43+
simulateResponseDelay: boolean = false
44+
4245
makeGetRequest(url: string, headers: Headers): AbortableRequest {
4346
const nextResponse: Error | Response | undefined = this.queuedResponses.pop()
4447
let responsePromise: Promise<Response>
@@ -47,7 +50,12 @@ class TestDatafileManager extends HttpPollingDatafileManager {
4750
} else if (nextResponse instanceof Error) {
4851
responsePromise = Promise.reject(nextResponse)
4952
} else {
50-
responsePromise = Promise.resolve(nextResponse)
53+
if (this.simulateResponseDelay) {
54+
// Actual response will have some delay. This is required to get expected behavior for caching.
55+
responsePromise = new Promise((resolve) => setTimeout(() => resolve(nextResponse), 50))
56+
} else {
57+
responsePromise = Promise.resolve(nextResponse)
58+
}
5159
}
5260
this.responsePromises.push(responsePromise)
5361
return { responsePromise, abort: jest.fn() }
@@ -58,6 +66,30 @@ class TestDatafileManager extends HttpPollingDatafileManager {
5866
}
5967
}
6068

69+
const testCache : PersistentKeyValueCache = {
70+
get(key: string): Promise<any | null> {
71+
let val = null
72+
switch(key) {
73+
case 'opt-datafile-keyThatExists':
74+
val = { name: 'keyThatExists' }
75+
break
76+
}
77+
return Promise.resolve(val)
78+
},
79+
80+
set(key: string, val: any): Promise<void> {
81+
return Promise.resolve()
82+
},
83+
84+
contains(key: string): Promise<Boolean> {
85+
return Promise.resolve(false)
86+
},
87+
88+
remove(key: string): Promise<void> {
89+
return Promise.resolve()
90+
}
91+
}
92+
6193
describe('httpPollingDatafileManager', () => {
6294
beforeEach(() => {
6395
jest.useFakeTimers()
@@ -82,13 +114,7 @@ describe('httpPollingDatafileManager', () => {
82114
expect(manager.get()).toEqual({ foo: 'abcd' })
83115
})
84116

85-
it('resolves onReady immediately', async () => {
86-
manager.start()
87-
await manager.onReady()
88-
expect(manager.get()).toEqual({ foo: 'abcd' })
89-
})
90-
91-
it('after being started, fetches the datafile, updates itself, emits an update event, and updates itself again after a timeout', async () => {
117+
it('after being started, fetches the datafile, updates itself, and updates itself again after a timeout', async () => {
92118
manager.queuedResponses.push(
93119
{
94120
statusCode: 200,
@@ -106,10 +132,6 @@ describe('httpPollingDatafileManager', () => {
106132
manager.start()
107133
expect(manager.responsePromises.length).toBe(1)
108134
await manager.responsePromises[0]
109-
expect(updateFn).toBeCalledTimes(1)
110-
expect(updateFn).toBeCalledWith({
111-
datafile: { foo: 'bar' }
112-
})
113135
expect(manager.get()).toEqual({ foo: 'bar' })
114136
updateFn.mockReset()
115137

@@ -134,27 +156,15 @@ describe('httpPollingDatafileManager', () => {
134156
expect(manager.get()).toEqual({ foo: 'abcd' })
135157
})
136158

137-
it('after being started, resolves onReady immediately', async () => {
138-
manager.start()
139-
await manager.onReady()
140-
expect(manager.get()).toEqual({ foo: 'abcd' })
141-
})
142-
143-
it('after being started, fetches the datafile, updates itself once, and emits an update event, but does not schedule a future update', async () => {
159+
it('after being started, fetches the datafile, updates itself once, but does not schedule a future update', async () => {
144160
manager.queuedResponses.push({
145161
statusCode: 200,
146162
body: '{"foo": "bar"}',
147163
headers: {}
148164
})
149-
const updateFn = jest.fn()
150-
manager.on('update', updateFn)
151165
manager.start()
152166
expect(manager.responsePromises.length).toBe(1)
153167
await manager.responsePromises[0]
154-
expect(updateFn).toBeCalledTimes(1)
155-
expect(updateFn).toBeCalledWith({
156-
datafile: { foo: 'bar' }
157-
})
158168
expect(manager.get()).toEqual({ foo: 'bar' })
159169
expect(getTimerCount()).toBe(0)
160170
})
@@ -634,4 +644,88 @@ describe('httpPollingDatafileManager', () => {
634644
expect(makeGetRequestSpy).toBeCalledTimes(2)
635645
})
636646
})
647+
648+
describe('when constructed with a cache implementation having an already cached datafile', () => {
649+
beforeEach(() => {
650+
manager = new TestDatafileManager({
651+
sdkKey: 'keyThatExists',
652+
updateInterval: 500,
653+
autoUpdate: true,
654+
cache: testCache,
655+
})
656+
manager.simulateResponseDelay = true
657+
})
658+
659+
it('uses cached version of datafile first and resolves the promise while network throws error and no update event is triggered', async () => {
660+
manager.queuedResponses.push(new Error('Connection Error'))
661+
const updateFn = jest.fn()
662+
manager.on('update', updateFn)
663+
manager.start()
664+
await manager.onReady()
665+
expect(manager.get()).toEqual({name: 'keyThatExists'})
666+
await advanceTimersByTime(50)
667+
expect(manager.get()).toEqual({name: 'keyThatExists'})
668+
expect(updateFn).toBeCalledTimes(0)
669+
})
670+
671+
it('uses cached datafile, resolves ready promise, fetches new datafile from network and triggers update event', async() => {
672+
manager.queuedResponses.push({
673+
statusCode: 200,
674+
body: '{"foo": "bar"}',
675+
headers: {}
676+
})
677+
678+
const updateFn = jest.fn()
679+
manager.on('update', updateFn)
680+
manager.start()
681+
await manager.onReady()
682+
expect(manager.get()).toEqual({ name: 'keyThatExists' })
683+
expect(updateFn).toBeCalledTimes(0)
684+
await advanceTimersByTime(50)
685+
expect(manager.get()).toEqual({ foo: 'bar' })
686+
expect(updateFn).toBeCalledTimes(1)
687+
})
688+
689+
it('sets newly recieved datafile in to cache', async() => {
690+
const cacheSetSpy = jest.spyOn(testCache, 'set')
691+
manager.queuedResponses.push({
692+
statusCode: 200,
693+
body: '{"foo": "bar"}',
694+
headers: {}
695+
})
696+
manager.start()
697+
await manager.onReady()
698+
await advanceTimersByTime(50)
699+
expect(manager.get()).toEqual({ foo: 'bar' })
700+
expect(cacheSetSpy).toBeCalledWith('opt-datafile-keyThatExists', {"foo": "bar"})
701+
})
702+
})
703+
704+
describe('when constructed with a cache implementation without an already cached datafile', () => {
705+
beforeEach(() => {
706+
manager = new TestDatafileManager({
707+
sdkKey: 'keyThatDoesExists',
708+
updateInterval: 500,
709+
autoUpdate: true,
710+
cache: testCache,
711+
})
712+
manager.simulateResponseDelay = true
713+
})
714+
715+
it('does not find cached datafile, fetches new datafile from network, resolves promise and does not trigger update event', async() => {
716+
manager.queuedResponses.push({
717+
statusCode: 200,
718+
body: '{"foo": "bar"}',
719+
headers: {}
720+
})
721+
722+
const updateFn = jest.fn()
723+
manager.on('update', updateFn)
724+
manager.start()
725+
await advanceTimersByTime(50)
726+
await manager.onReady()
727+
expect(manager.get()).toEqual({ foo: 'bar' })
728+
expect(updateFn).toBeCalledTimes(0)
729+
})
730+
})
637731
})

packages/datafile-manager/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
},
99
"main": "lib/index.node.js",
1010
"browser": "lib/index.browser.js",
11+
"react-native": "lib/index.react_native.js",
1112
"types": "lib/index.d.ts",
1213
"directories": {
1314
"lib": "lib",

packages/datafile-manager/src/datafileManager.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,11 @@
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
1515
*/
16+
import PersistentKeyValueCache from './persistentKeyValueCache'
1617

17-
export interface DatafileUpdate {
18-
datafile: object
19-
}
18+
export interface DatafileUpdate {
19+
datafile: object
20+
}
2021

2122
export interface DatafileUpdateListener {
2223
(datafileUpdate: DatafileUpdate): void
@@ -41,4 +42,5 @@ export interface DatafileManagerConfig {
4142
sdkKey: string
4243
updateInterval?: number
4344
urlTemplate?: string
45+
cache?: PersistentKeyValueCache
4446
}

packages/datafile-manager/src/httpPollingDatafileManager.ts

+45-4
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,12 @@
1616

1717
import { getLogger } from '@optimizely/js-sdk-logging'
1818
import { sprintf } from '@optimizely/js-sdk-utils';
19-
import { DatafileManager, DatafileManagerConfig, DatafileUpdate } from './datafileManager';
19+
import { DatafileManager, DatafileManagerConfig, DatafileUpdate } from './datafileManager'
2020
import EventEmitter from './eventEmitter'
21-
import { AbortableRequest, Response, Headers } from './http';
21+
import { AbortableRequest, Response, Headers } from './http'
2222
import { DEFAULT_UPDATE_INTERVAL, MIN_UPDATE_INTERVAL, DEFAULT_URL_TEMPLATE } from './config'
23-
import BackoffController from './backoffController';
23+
import BackoffController from './backoffController'
24+
import PersistentKeyValueCache from './persistentKeyValueCache'
2425

2526
const logger = getLogger('DatafileManager')
2627

@@ -34,6 +35,24 @@ function isSuccessStatusCode(statusCode: number): boolean {
3435
return statusCode >= 200 && statusCode < 400
3536
}
3637

38+
const noOpKeyValueCache: PersistentKeyValueCache = {
39+
get(key: string): Promise<any | null> {
40+
return Promise.resolve(null)
41+
},
42+
43+
set(key: string, val: any): Promise<void> {
44+
return Promise.resolve()
45+
},
46+
47+
contains(key: string): Promise<Boolean> {
48+
return Promise.resolve(false)
49+
},
50+
51+
remove(key: string): Promise<void> {
52+
return Promise.resolve()
53+
}
54+
}
55+
3756
export default abstract class HttpPollingDatafileManager implements DatafileManager {
3857
// Make an HTTP get request to the given URL with the given headers
3958
// Return an AbortableRequest, which has a promise for a Response.
@@ -72,6 +91,10 @@ export default abstract class HttpPollingDatafileManager implements DatafileMana
7291

7392
private backoffController: BackoffController
7493

94+
private cacheKey: string
95+
96+
private cache: PersistentKeyValueCache
97+
7598
// When true, this means the update interval timeout fired before the current
7699
// sync completed. In that case, we should sync again immediately upon
77100
// completion of the current request, instead of waiting another update
@@ -89,8 +112,11 @@ export default abstract class HttpPollingDatafileManager implements DatafileMana
89112
sdkKey,
90113
updateInterval = DEFAULT_UPDATE_INTERVAL,
91114
urlTemplate = DEFAULT_URL_TEMPLATE,
115+
cache = noOpKeyValueCache,
92116
} = configWithDefaultsApplied
93117

118+
this.cache = cache
119+
this.cacheKey = 'opt-datafile-' + sdkKey
94120
this.isReadyPromiseSettled = false
95121
this.readyPromiseResolver = () => {}
96122
this.readyPromiseRejecter = () => {}
@@ -101,7 +127,9 @@ export default abstract class HttpPollingDatafileManager implements DatafileMana
101127

102128
if (datafile) {
103129
this.currentDatafile = datafile
104-
this.resolveReadyPromise()
130+
if (!sdkKey) {
131+
this.resolveReadyPromise()
132+
}
105133
} else {
106134
this.currentDatafile = null
107135
}
@@ -133,6 +161,7 @@ export default abstract class HttpPollingDatafileManager implements DatafileMana
133161
logger.debug('Datafile manager started')
134162
this.isStarted = true
135163
this.backoffController.reset()
164+
this.setDatafileFromCacheIfAvailable()
136165
this.syncDatafile()
137166
}
138167
}
@@ -197,6 +226,7 @@ export default abstract class HttpPollingDatafileManager implements DatafileMana
197226
if (datafile !== null) {
198227
logger.info('Updating datafile from response')
199228
this.currentDatafile = datafile
229+
this.cache.set(this.cacheKey, datafile)
200230
if (!this.isReadyPromiseSettled) {
201231
this.resolveReadyPromise()
202232
} else {
@@ -314,4 +344,15 @@ export default abstract class HttpPollingDatafileManager implements DatafileMana
314344
logger.debug('Saved last modified header value from response: %s', this.lastResponseLastModified)
315345
}
316346
}
347+
348+
setDatafileFromCacheIfAvailable(): void {
349+
this.cache.get(this.cacheKey)
350+
.then(datafile => {
351+
if (this.isStarted && !this.isReadyPromiseSettled && datafile) {
352+
logger.debug('Using datafile from cache')
353+
this.currentDatafile = datafile
354+
this.resolveReadyPromise()
355+
}
356+
})
357+
}
317358
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* Copyright 2020, Optimizely
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
export * from './datafileManager'
18+
export { default as HttpPollingDatafileManager } from './reactNativeDatafileManager'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Copyright 2020, Optimizely
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { makeGetRequest } from './browserRequest'
18+
import HttpPollingDatafileManager from './httpPollingDatafileManager'
19+
import { Headers, AbortableRequest } from './http'
20+
import { DatafileManagerConfig } from './datafileManager'
21+
import ReactNativeAsyncStorageCache from './reactNativeAsyncStorageCache'
22+
23+
export default class ReactNativeDatafileManager extends HttpPollingDatafileManager {
24+
25+
protected makeGetRequest(reqUrl: string, headers: Headers): AbortableRequest {
26+
return makeGetRequest(reqUrl, headers)
27+
}
28+
29+
protected getConfigDefaults(): Partial<DatafileManagerConfig> {
30+
return {
31+
autoUpdate: true,
32+
cache: new ReactNativeAsyncStorageCache(),
33+
}
34+
}
35+
}

0 commit comments

Comments
 (0)