Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions lib/event_processor/event_processor_factory.browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@
*/
import { EventDispatcher } from './event_dispatcher/event_dispatcher';
import { EventProcessor } from './event_processor';
import { EventWithId } from './batch_event_processor';
import {
import {
getOpaqueBatchEventProcessor,
BatchEventProcessorOptions,
OpaqueEventProcessor,
Expand Down Expand Up @@ -44,7 +43,7 @@ export const createBatchEventProcessor = (
options: BatchEventProcessorOptions = {}
): OpaqueEventProcessor => {
const eventStore = options.eventStore ? getPrefixEventStore(options.eventStore) : new EventStore({
store: new LocalStorageCache<EventWithId>(),
store: new LocalStorageCache(),
maxSize: options.batchSize ? Math.max(options.batchSize * 2, DEFAULT_MAX_EVENTS_IN_STORE)
: DEFAULT_MAX_EVENTS_IN_STORE,
ttl: options.storeTtl,
Expand Down
3 changes: 1 addition & 2 deletions lib/event_processor/event_processor_factory.react_native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import {
getForwardingEventProcessor,
} from './event_processor_factory';
import { FAILED_EVENT_RETRY_INTERVAL } from './event_processor_factory';
import { EventWithId } from './batch_event_processor';
import { AsyncStorageCache } from '../utils/cache/async_storage_cache.react_native';
import { ReactNativeNetInfoEventProcessor } from './batch_event_processor.react_native';
import { DEFAULT_MAX_EVENTS_IN_STORE, EventStore } from './event_store';
Expand All @@ -43,7 +42,7 @@ export const createBatchEventProcessor = (
options: BatchEventProcessorOptions = {}
): OpaqueEventProcessor => {
const eventStore = options.eventStore ? getPrefixEventStore(options.eventStore) : new EventStore({
store: new AsyncStorageCache<EventWithId>(),
store: new AsyncStorageCache(),
maxSize: options.batchSize ? Math.max(options.batchSize * 2, DEFAULT_MAX_EVENTS_IN_STORE)
: DEFAULT_MAX_EVENTS_IN_STORE,
ttl: options.storeTtl,
Expand Down
44 changes: 23 additions & 21 deletions lib/event_processor/event_store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ type TestStoreConfig = {
}

const getEventStore = (config: TestStoreConfig = {}) => {
const mockStore = getMockAsyncCache<StoredEvent>();
const mockStore = getMockAsyncCache<string>();
const store = new EventStore({...config, store: mockStore });
return { mockStore, store }
}
Expand Down Expand Up @@ -213,17 +213,18 @@ describe('EventStore', () => {
const originalSet = mockStore.set.bind(mockStore);

let call = 0;
const setSpy = vi.spyOn(mockStore, 'set').mockImplementation(async (key: string, value: StoredEvent) => {
const setSpy = vi.spyOn(mockStore, 'set').mockImplementation(async (key: string, value: string) => {
if (call++ > 0) {
return originalSet(key, value);
}

// Simulate old stored event without time info
const stored: StoredEvent = JSON.parse(value);
const eventWithoutTime: StoredEvent = {
id: value.id,
event: value.event,
id: stored.id,
event: stored.event,
};
return originalSet(key, eventWithoutTime);
return originalSet(key, JSON.stringify(eventWithoutTime));
});

await store.set('test', event);
Expand All @@ -235,12 +236,12 @@ describe('EventStore', () => {
await exhaustMicrotasks();
expect(setSpy).toHaveBeenCalledTimes(2);

const secondCall = setSpy.mock.calls[1];
const resavedEvent: StoredEvent = JSON.parse(setSpy.mock.calls[1][1]);

expect(secondCall[1]._time).toBeDefined();
expect(secondCall[1]._time?.storedAt).toBeLessThanOrEqual(Date.now());
expect(secondCall[1]._time?.storedAt).toBeGreaterThanOrEqual(Date.now() - 10);
expect(secondCall[1]._time?.ttl).toBe(ttl);
expect(resavedEvent._time).toBeDefined();
expect(resavedEvent._time?.storedAt).toBeLessThanOrEqual(Date.now());
expect(resavedEvent._time?.storedAt).toBeGreaterThanOrEqual(Date.now() - 10);
expect(resavedEvent._time?.ttl).toBe(ttl);
});

it('should store event when key expires after store being full', async () => {
Expand Down Expand Up @@ -327,37 +328,38 @@ describe('EventStore', () => {
const originalSet = mockStore.set.bind(mockStore);

let call = 0;
const setSpy = vi.spyOn(mockStore, 'set').mockImplementation(async (key: string, value: StoredEvent) => {
const setSpy = vi.spyOn(mockStore, 'set').mockImplementation(async (key: string, value: string) => {
if (call++ > 0) {
return originalSet(key, value);
}

// Simulate old stored event without time information
const stored: StoredEvent = JSON.parse(value);
const eventWithoutTime: StoredEvent = {
id: value.id,
event: value.event,
id: stored.id,
event: stored.event,
};
return originalSet(key, eventWithoutTime);
return originalSet(key, JSON.stringify(eventWithoutTime));
});

await store.set('key-1', event);
await store.set('key-2', event);

const results = await store.getBatched(['key-1', 'key-2']);

expect(results).toHaveLength(2);
expect(results[0]).toEqual(expect.objectContaining(event));
expect(results[1]).toEqual(expect.objectContaining(event));

await exhaustMicrotasks();
expect(setSpy).toHaveBeenCalledTimes(3);

const secondCall = setSpy.mock.calls[1];
const resavedEvent: StoredEvent = JSON.parse(setSpy.mock.calls[1][1]);

expect(secondCall[1]._time).toBeDefined();
expect(secondCall[1]._time?.storedAt).toBeLessThanOrEqual(Date.now());
expect(secondCall[1]._time?.storedAt).toBeGreaterThanOrEqual(Date.now() - 10);
expect(secondCall[1]._time?.ttl).toBe(ttl);
expect(resavedEvent._time).toBeDefined();
expect(resavedEvent._time?.storedAt).toBeLessThanOrEqual(Date.now());
expect(resavedEvent._time?.storedAt).toBeGreaterThanOrEqual(Date.now() - 10);
expect(resavedEvent._time?.ttl).toBe(ttl);
});

it('should store event when keys expire during getBatched after store being full', async () => {
Expand Down Expand Up @@ -401,7 +403,7 @@ describe('EventStore', () => {
const originalSet = mockStore.set.bind(mockStore);

let call = 0;
vi.spyOn(mockStore, 'set').mockImplementation(async (key: string, value: StoredEvent) => {
vi.spyOn(mockStore, 'set').mockImplementation(async (key: string, value: string) => {
// only the seconde set call should fail
if (call++ != 1) return originalSet(key, value);
return Promise.reject(new Error('Simulated set failure'));
Expand Down
8 changes: 3 additions & 5 deletions lib/event_processor/event_store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ export type StoredEvent = EventWithId & {
};
};

const identity = <T>(v: T): T => v;

const LOGGER_NAME = 'EventStore';
export const DEFAULT_MAX_EVENTS_IN_STORE = 500;
export const DEFAULT_STORE_TTL = 10 * 24 * 60 * 60 * 1000; // 10 days
Expand All @@ -31,7 +29,7 @@ export const EVENT_STORE_PREFIX = 'optly_event:';
export type EventStoreConfig = {
maxSize?: number;
ttl?: number,
store: Store<EventWithId>,
store: Store<string>,
logger?: LoggerFacade,
};

Expand All @@ -56,9 +54,9 @@ export class EventStore extends AsyncStoreWithBatchedGet<EventWithId> implements
} = config;

if (store.operation === 'sync') {
this.store = new SyncPrefixStore(store, EVENT_STORE_PREFIX, identity, identity);
this.store = new SyncPrefixStore<string, StoredEvent>(store, EVENT_STORE_PREFIX, JSON.parse, JSON.stringify);
} else {
this.store = new AsyncPrefixStore(store, EVENT_STORE_PREFIX, identity, identity);
this.store = new AsyncPrefixStore<string, StoredEvent>(store, EVENT_STORE_PREFIX, JSON.parse, JSON.stringify);
}

if (logger) {
Expand Down
14 changes: 7 additions & 7 deletions lib/project_config/polling_datafile_manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ describe('PollingDatafileManager', () => {
const repeater = getMockRepeater();
const requestHandler = getMockRequestHandler(); // response promise is pending
const cache = getMockAsyncCache<string>();
await cache.set('opt-datafile-keyThatExists', JSON.stringify({ name: 'keyThatExists' }));
await cache.set('opt-datafile-v6-keyThatExists', JSON.stringify({ name: 'keyThatExists' }));

const manager = new PollingDatafileManager({
repeater,
Expand All @@ -131,7 +131,7 @@ describe('PollingDatafileManager', () => {
requestHandler.makeRequest.mockReturnValueOnce(mockResponse);

const cache = getMockAsyncCache<string>();
await cache.set('opt-datafile-keyThatExists', JSON.stringify({ name: 'keyThatExists' }));
await cache.set('opt-datafile-v6-keyThatExists', JSON.stringify({ name: 'keyThatExists' }));

const manager = new PollingDatafileManager({
repeater,
Expand All @@ -155,7 +155,7 @@ describe('PollingDatafileManager', () => {
const repeater = getMockRepeater();
const requestHandler = getMockRequestHandler();
const cache = getMockAsyncCache<string>();
await cache.set('opt-datafile-keyThatExists', JSON.stringify({ name: 'keyThatExists' }));
await cache.set('opt-datafile-v6-keyThatExists', JSON.stringify({ name: 'keyThatExists' }));
const mockResponse = getMockAbortableRequest();
requestHandler.makeRequest.mockReturnValueOnce(mockResponse);

Expand Down Expand Up @@ -564,7 +564,7 @@ describe('PollingDatafileManager', () => {
repeater.execute(0);

await expect(manager.onRunning()).resolves.not.toThrow();
expect(spy).toHaveBeenCalledWith('opt-datafile-keyThatDoesNotExists', '{"foo": "bar"}');
expect(spy).toHaveBeenCalledWith('opt-datafile-v6-keyThatDoesNotExists', '{"foo": "bar"}');
});
});

Expand Down Expand Up @@ -635,9 +635,9 @@ describe('PollingDatafileManager', () => {
}

await expect(manager.onRunning()).resolves.not.toThrow();
expect(spy).toHaveBeenNthCalledWith(1, 'opt-datafile-keyThatDoesNotExists', '{"foo": "bar"}');
expect(spy).toHaveBeenNthCalledWith(2, 'opt-datafile-keyThatDoesNotExists', '{"foo2": "bar2"}');
expect(spy).toHaveBeenNthCalledWith(3, 'opt-datafile-keyThatDoesNotExists', '{"foo3": "bar3"}');
expect(spy).toHaveBeenNthCalledWith(1, 'opt-datafile-v6-keyThatDoesNotExists', '{"foo": "bar"}');
expect(spy).toHaveBeenNthCalledWith(2, 'opt-datafile-v6-keyThatDoesNotExists', '{"foo2": "bar2"}');
expect(spy).toHaveBeenNthCalledWith(3, 'opt-datafile-v6-keyThatDoesNotExists', '{"foo3": "bar3"}');
});

it('logs an error if fetch request fails and does not call onUpdate handler', async () => {
Expand Down
2 changes: 1 addition & 1 deletion lib/project_config/polling_datafile_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export class PollingDatafileManager extends BaseService implements DatafileManag
logger,
} = config;
this.cache = cache;
this.cacheKey = 'opt-datafile-' + sdkKey;
this.cacheKey = 'opt-datafile-v6-' + sdkKey;
this.sdkKey = sdkKey;
this.datafileAccessToken = datafileAccessToken;
this.customHeaders = customHeaders;
Expand Down
37 changes: 11 additions & 26 deletions lib/utils/cache/async_storage_cache.react_native.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,56 +20,41 @@ import { getDefaultAsyncStorage } from '../import.react_native/@react-native-asy

vi.mock('@react-native-async-storage/async-storage');

type TestData = {
a: number;
b: string;
d: { e: boolean };
};

describe('AsyncStorageCache', () => {
const asyncStorage = getDefaultAsyncStorage();

it('should store a stringified value in async storage', async () => {
const cache = new AsyncStorageCache<TestData>();
it('should store the value as-is in async storage without serialization', async () => {
const cache = new AsyncStorageCache();

const data = { a: 1, b: '2', d: { e: true } };
await cache.set('key', data);
await cache.set('key', 'value');

expect(await asyncStorage.getItem('key')).toBe(JSON.stringify(data));
expect(await cache.get('key')).toEqual(data);
expect(await asyncStorage.getItem('key')).toBe('value');
expect(await cache.get('key')).toBe('value');
});

it('should return undefined if get is called for a nonexistent key', async () => {
const cache = new AsyncStorageCache<string>();
const cache = new AsyncStorageCache();

expect(await cache.get('nonexistent')).toBeUndefined();
});

it('should return the value if get is called for an existing key', async () => {
const cache = new AsyncStorageCache<string>();
const cache = new AsyncStorageCache();
await cache.set('key', 'value');

expect(await cache.get('key')).toBe('value');
});

it('should return the value after json parsing if get is called for an existing key', async () => {
const cache = new AsyncStorageCache<TestData>();
const data = { a: 1, b: '2', d: { e: true } };
await cache.set('key', data);

expect(await cache.get('key')).toEqual(data);
});

it('should remove the key from async storage when remove is called', async () => {
const cache = new AsyncStorageCache<string>();
const cache = new AsyncStorageCache();
await cache.set('key', 'value');
await cache.remove('key');

expect(await asyncStorage.getItem('key')).toBeNull();
});

it('should remove all keys from async storage when clear is called', async () => {
const cache = new AsyncStorageCache<string>();
const cache = new AsyncStorageCache();
await cache.set('key1', 'value1');
await cache.set('key2', 'value2');

Expand All @@ -79,15 +64,15 @@ describe('AsyncStorageCache', () => {
});

it('should return all keys when getKeys is called', async () => {
const cache = new AsyncStorageCache<string>();
const cache = new AsyncStorageCache();
await cache.set('key1', 'value1');
await cache.set('key2', 'value2');

expect(await cache.getKeys()).toEqual(['key1', 'key2']);
});

it('should return an array of values for an array of keys when getBatched is called', async () => {
const cache = new AsyncStorageCache<string>();
const cache = new AsyncStorageCache();
await cache.set('key1', 'value1');
await cache.set('key2', 'value2');

Expand Down
14 changes: 7 additions & 7 deletions lib/utils/cache/async_storage_cache.react_native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,21 @@ import { AsyncStore } from "./store";
import { getDefaultAsyncStorage } from "../import.react_native/@react-native-async-storage/async-storage";
import { Platform } from '../../platform_support';

export class AsyncStorageCache<V> implements AsyncStore<V> {
export class AsyncStorageCache implements AsyncStore<string> {
public readonly operation = 'async';
private asyncStorage = getDefaultAsyncStorage();

async get(key: string): Promise<V | undefined> {
async get(key: string): Promise<string | undefined> {
const value = await this.asyncStorage.getItem(key);
return value ? JSON.parse(value) : undefined;
return value ?? undefined;
}

async remove(key: string): Promise<unknown> {
return this.asyncStorage.removeItem(key);
}

async set(key: string, val: V): Promise<unknown> {
return this.asyncStorage.setItem(key, JSON.stringify(val));
async set(key: string, val: string): Promise<unknown> {
return this.asyncStorage.setItem(key, val);
}

async clear(): Promise<unknown> {
Expand All @@ -44,9 +44,9 @@ export class AsyncStorageCache<V> implements AsyncStore<V> {
return [... await this.asyncStorage.getAllKeys()];
}

async getBatched(keys: string[]): Promise<Maybe<V>[]> {
async getBatched(keys: string[]): Promise<Maybe<string>[]> {
const items = await this.asyncStorage.multiGet(keys);
return items.map(([key, value]) => value ? JSON.parse(value) : undefined);
return items.map(([key, value]) => value ?? undefined);
}
}

Expand Down
Loading
Loading