diff --git a/lib/client_factory.ts b/lib/client_factory.ts index 87a239246..f1534d786 100644 --- a/lib/client_factory.ts +++ b/lib/client_factory.ts @@ -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"; @@ -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; @@ -54,9 +55,17 @@ export const getOptimizelyInstance = (config: OptimizelyFactoryConfig): Optimize requestHandler, }); + const cmabCache: CacheWithRemove = 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(cacheSize, cacheTtl); + })(); + const cmabService = new DefaultCmabService({ cmabClient, - cmabCache: new InMemoryLruCache(DEFAULT_CMAB_CACHE_SIZE, DEFAULT_CMAB_CACHE_TIMEOUT), + cmabCache, }); const optimizelyOptions = { diff --git a/lib/shared_types.ts b/lib/shared_types.ts index 45e66413b..ef4221db3 100644 --- a/lib/shared_types.ts +++ b/lib/shared_types.ts @@ -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'; @@ -397,6 +398,11 @@ export interface Config { odpManager?: OpaqueOdpManager; vuidManager?: OpaqueVuidManager; disposable?: boolean; + cmab?: { + cacheSize?: number; + cacheTtl?: number; + cache?: CacheWithRemove; + } } export type OptimizelyExperimentsMap = { diff --git a/lib/utils/cache/cache.spec.ts b/lib/utils/cache/cache.spec.ts new file mode 100644 index 000000000..687176d59 --- /dev/null +++ b/lib/utils/cache/cache.spec.ts @@ -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; + let transformedCache: CacheWithRemove; + + beforeEach(() => { + mockCache = getMockSyncCache(); + 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; + let transformedAsyncCache: CacheWithRemove; + + beforeEach(() => { + mockAsyncCache = getMockAsyncCache(); + 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'); + }); + }); +}); \ No newline at end of file diff --git a/lib/utils/cache/cache.ts b/lib/utils/cache/cache.ts index ada8a5ac6..685b43a7b 100644 --- a/lib/utils/cache/cache.ts +++ b/lib/utils/cache/cache.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { OpType, OpValue } from '../../utils/type'; - +import { Transformer } from '../../utils/type'; export interface OpCache { operation: OP; save(key: string, value: V): OpValue; @@ -34,3 +34,37 @@ export interface OpCacheWithRemove extends OpCache export type SyncCacheWithRemove = OpCacheWithRemove<'sync', V>; export type AsyncCacheWithRemove = OpCacheWithRemove<'async', V>; export type CacheWithRemove = SyncCacheWithRemove | AsyncCacheWithRemove; + +export const transformCache = ( + cache: CacheWithRemove, + transformGet: Transformer, + transformSet: Transformer, +): CacheWithRemove => { + const transform = (value: U | undefined, transformer: Transformer): 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; +};