Skip to content
Merged
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
13 changes: 11 additions & 2 deletions lib/client_factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Client, Config } from "./shared_types";
import { Config } from "./shared_types";
import { extractLogger } from "./logging/logger_factory";
import { extractErrorNotifier } from "./error/error_notifier_factory";
import { extractConfigManager } from "./project_config/config_manager_factory";
Expand All @@ -26,6 +26,7 @@ import Optimizely from "./optimizely";
import { DefaultCmabClient } from "./core/decision_service/cmab/cmab_client";
import { CmabCacheValue, DefaultCmabService } from "./core/decision_service/cmab/cmab_service";
import { InMemoryLruCache } from "./utils/cache/in_memory_lru_cache";
import { transformCache, CacheWithRemove } from "./utils/cache/cache";

export type OptimizelyFactoryConfig = Config & {
requestHandler: RequestHandler;
Expand Down Expand Up @@ -54,9 +55,17 @@ export const getOptimizelyInstance = (config: OptimizelyFactoryConfig): Optimize
requestHandler,
});

const cmabCache: CacheWithRemove<CmabCacheValue> = config.cmab?.cache ?
transformCache(config.cmab.cache, (value) => JSON.parse(value), (value) => JSON.stringify(value)) :
(() => {
const cacheSize = config.cmab?.cacheSize || DEFAULT_CMAB_CACHE_SIZE;
const cacheTtl = config.cmab?.cacheTtl || DEFAULT_CMAB_CACHE_TIMEOUT;
return new InMemoryLruCache<CmabCacheValue>(cacheSize, cacheTtl);
})();

const cmabService = new DefaultCmabService({
cmabClient,
cmabCache: new InMemoryLruCache<CmabCacheValue>(DEFAULT_CMAB_CACHE_SIZE, DEFAULT_CMAB_CACHE_TIMEOUT),
cmabCache,
});

const optimizelyOptions = {
Expand Down
6 changes: 6 additions & 0 deletions lib/shared_types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import { OpaqueErrorNotifier } from './error/error_notifier_factory';
import { OpaqueEventProcessor } from './event_processor/event_processor_factory';
import { OpaqueOdpManager } from './odp/odp_manager_factory';
import { OpaqueVuidManager } from './vuid/vuid_manager_factory';
import { CacheWithRemove } from './utils/cache/cache';

export { EventDispatcher } from './event_processor/event_dispatcher/event_dispatcher';
export { EventProcessor } from './event_processor/event_processor';
Expand Down Expand Up @@ -397,6 +398,11 @@ export interface Config {
odpManager?: OpaqueOdpManager;
vuidManager?: OpaqueVuidManager;
disposable?: boolean;
cmab?: {
cacheSize?: number;
cacheTtl?: number;
cache?: CacheWithRemove<string>;
}
}

export type OptimizelyExperimentsMap = {
Expand Down
170 changes: 170 additions & 0 deletions lib/utils/cache/cache.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/**
* Copyright 2025, Optimizely
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { transformCache, SyncCacheWithRemove, AsyncCacheWithRemove, CacheWithRemove } from './cache';
import { getMockSyncCache, getMockAsyncCache } from '../../tests/mock/mock_cache';

const numberToString = (value: number): string => value.toString();
const stringToNumber = (value: string): number => parseInt(value, 10);

describe('transformCache', () => {
describe('sync cache operations', () => {
let mockCache: SyncCacheWithRemove<string>;
let transformedCache: CacheWithRemove<number>;

beforeEach(() => {
mockCache = getMockSyncCache<string>();
transformedCache = transformCache(mockCache, stringToNumber, numberToString);
});

it('should transform and save values using transformSet', () => {
const value = 42;
const key = 'test-key';

transformedCache.save(key, value);

expect(mockCache.lookup(key)).toBe('42');
});

it('should lookup and transform values using transformGet when value exists', () => {
const key = 'test-key';
const storedValue = '123';

mockCache.save(key, storedValue);

const result = transformedCache.lookup(key);
expect(result).toBe(123);
});

it('should return undefined when lookup value does not exist', () => {
const key = 'non-existent-key';

const result = transformedCache.lookup(key);
expect(result).toBeUndefined();
});

it('should return the saved value on lookup', () => {
const key = 'sample-key';
const value = 100;

transformedCache.save(key, value);
const result = transformedCache.lookup(key);
expect(result).toBe(value);
});

it('should reset the cache when reset is called', () => {
transformedCache.save('key1', 42);
transformedCache.save('key2', 1729);

transformedCache.reset();

expect(mockCache.lookup('key1')).toBeUndefined();
expect(mockCache.lookup('key2')).toBeUndefined();
expect(transformedCache.lookup('key1')).toBeUndefined();
expect(transformedCache.lookup('key2')).toBeUndefined();
});

it('should remove the key from the cache when remove is called', () => {
const key = 'test-key';
transformedCache.save(key, 99);

expect(transformedCache.lookup(key)).toBe(99);

transformedCache.remove(key);
expect(mockCache.lookup(key)).toBeUndefined();
expect(transformedCache.lookup(key)).toBeUndefined();
});

it('should preserve original cache operation type', () => {
expect(transformedCache.operation).toBe('sync');
});
});

describe('async cache operations', () => {
let mockAsyncCache: AsyncCacheWithRemove<string>;
let transformedAsyncCache: CacheWithRemove<number>;

beforeEach(() => {
mockAsyncCache = getMockAsyncCache<string>();
transformedAsyncCache = transformCache(mockAsyncCache, stringToNumber, numberToString);
});

it('should transform and save values using transformSet', async () => {
const value = 42;
const key = 'test-key';

await transformedAsyncCache.save(key, value);

const result = await mockAsyncCache.lookup(key);
expect(result).toBe('42');
});

it('should lookup and transform values using transformGet when value exists', async () => {
const key = 'test-key';
const storedValue = '123';

await mockAsyncCache.save(key, storedValue);

const result = await transformedAsyncCache.lookup(key);
expect(result).toBe(123);
});

it('should return undefined when lookup value does not exist', async () => {
const key = 'non-existent-key';

const result = await transformedAsyncCache.lookup(key);
expect(result).toBeUndefined();
});

it('should return the saved value on lookup', async () => {
const key = 'sample-key';
const value = 100;

await transformedAsyncCache.save(key, value);
const result = await transformedAsyncCache.lookup(key);
expect(result).toBe(value);
});

it('should reset the cache when reset is called', async () => {
await transformedAsyncCache.save('key1', 42);
await transformedAsyncCache.save('key2', 1729);

await transformedAsyncCache.reset();

await expect(mockAsyncCache.lookup('key1')).resolves.toBeUndefined();
await expect(mockAsyncCache.lookup('key2')).resolves.toBeUndefined();

await expect(transformedAsyncCache.lookup('key1')).resolves.toBeUndefined();
await expect(transformedAsyncCache.lookup('key2')).resolves.toBeUndefined();
});

it('should remove the key from the cache when remove is called', async () => {
const key = 'test-key';
await transformedAsyncCache.save(key, 99);

await expect(transformedAsyncCache.lookup(key)).resolves.toBe(99);

await transformedAsyncCache.remove(key);

await expect(mockAsyncCache.lookup(key)).resolves.toBeUndefined();
await expect(transformedAsyncCache.lookup(key)).resolves.toBeUndefined();
});

it('should preserve original cache operation type', () => {
expect(transformedAsyncCache.operation).toBe('async');
});
});
});
36 changes: 35 additions & 1 deletion lib/utils/cache/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/
import { OpType, OpValue } from '../../utils/type';

import { Transformer } from '../../utils/type';
export interface OpCache<OP extends OpType, V> {
operation: OP;
save(key: string, value: V): OpValue<OP, unknown>;
Expand All @@ -34,3 +34,37 @@ export interface OpCacheWithRemove<OP extends OpType, V> extends OpCache<OP, V>
export type SyncCacheWithRemove<V> = OpCacheWithRemove<'sync', V>;
export type AsyncCacheWithRemove<V> = OpCacheWithRemove<'async', V>;
export type CacheWithRemove<V> = SyncCacheWithRemove<V> | AsyncCacheWithRemove<V>;

export const transformCache = <U, V> (
cache: CacheWithRemove<U>,
transformGet: Transformer<U, V>,
transformSet: Transformer<V, U>,
): CacheWithRemove<V> => {
const transform = <U, V>(value: U | undefined, transformer: Transformer<U, V>): V | undefined => {
if (value === undefined) {
return undefined;
}
return transformer(value);
}

const lookup: any = (key: string) => {
if (cache.operation === 'sync') {
return transform(cache.lookup(key), transformGet);
}

return cache.lookup(key).then((v) => transform(v, transformGet));
}

const save: any = (key: string, value: V) => {
return cache.save(key, transformSet(value));
}

const transformedCache = {
lookup,
save,
};

Object.setPrototypeOf(transformedCache, cache);

return transformedCache as CacheWithRemove<V>;
};
Loading