From cfe2a73ca38f9a18fc0deec36b1c1003f61351da Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Fri, 21 Feb 2025 11:25:18 -0500 Subject: [PATCH 1/6] feat!: Replace prefetch behavior with simple TTL cache Previously, the LDClient would issue a call to prime the store with data, which would be retained for the lifetime of the variation or all flags call. This priming call is being removed in favor of a simple TTL cache. The cache will be populated on the initial usage of the SDK, and then periodically as it is detected to be expired. The TTL can be configured with: - Positive value representing the time to cache the value - 0 to cache the value indefinitely. This allows a customer to initialize the SDK within an EdgeWorker handler, and get a "snapshot" of the view for the lifetime of the SDK. - Negative value representing no cache. This value is highly discouraged as usage restrictions in Akamai make it ineffective. --- .../__tests__/featureStore/cache.test.ts | 113 ++++++++++++++++++ .../featureStore/cacheableStore.test.ts | 101 ---------------- .../__tests__/featureStore/index.test.ts | 8 +- .../__tests__/index.test.ts | 11 +- .../__tests__/platform/requests.test.ts | 2 +- .../__tests__/utils/validateOptions.test.ts | 2 +- .../akamai-edgeworker-sdk/src/api/LDClient.ts | 49 +------- .../src/featureStore/cache-item.ts | 10 ++ .../src/featureStore/cache.ts | 38 ++++++ .../featureStore/cacheableStoreProvider.ts | 83 ------------- .../src/featureStore/index.ts | 58 +++++---- .../shared/akamai-edgeworker-sdk/src/index.ts | 14 +-- 12 files changed, 220 insertions(+), 269 deletions(-) create mode 100644 packages/shared/akamai-edgeworker-sdk/__tests__/featureStore/cache.test.ts delete mode 100644 packages/shared/akamai-edgeworker-sdk/__tests__/featureStore/cacheableStore.test.ts create mode 100644 packages/shared/akamai-edgeworker-sdk/src/featureStore/cache-item.ts create mode 100644 packages/shared/akamai-edgeworker-sdk/src/featureStore/cache.ts delete mode 100644 packages/shared/akamai-edgeworker-sdk/src/featureStore/cacheableStoreProvider.ts diff --git a/packages/shared/akamai-edgeworker-sdk/__tests__/featureStore/cache.test.ts b/packages/shared/akamai-edgeworker-sdk/__tests__/featureStore/cache.test.ts new file mode 100644 index 0000000000..669dc92cb2 --- /dev/null +++ b/packages/shared/akamai-edgeworker-sdk/__tests__/featureStore/cache.test.ts @@ -0,0 +1,113 @@ +import { AsyncStoreFacade, LDFeatureStore } from '@launchdarkly/js-server-sdk-common'; + +import { EdgeFeatureStore, EdgeProvider } from '../../src/featureStore'; +import * as testData from '../testData.json'; + +describe('EdgeFeatureStore', () => { + const sdkKey = 'sdkKey'; + const mockLogger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + const mockEdgeProvider: EdgeProvider = { + get: jest.fn(), + }; + const mockGet = mockEdgeProvider.get as jest.Mock; + let featureStore: LDFeatureStore; + let asyncFeatureStore: AsyncStoreFacade; + + describe('with infinite cache', () => { + beforeEach(() => { + mockGet.mockImplementation(() => Promise.resolve(JSON.stringify(testData))); + featureStore = new EdgeFeatureStore( + mockEdgeProvider, + sdkKey, + 'MockEdgeProvider', + mockLogger, + 0, + ); + asyncFeatureStore = new AsyncStoreFacade(featureStore); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('will cache the initial request', async () => { + await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); + await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); + await asyncFeatureStore.all({ namespace: 'features' }); + + expect(mockGet).toHaveBeenCalledTimes(1); + }); + }); + + describe('with cache disabled', () => { + beforeEach(() => { + mockGet.mockImplementation(() => Promise.resolve(JSON.stringify(testData))); + featureStore = new EdgeFeatureStore( + mockEdgeProvider, + sdkKey, + 'MockEdgeProvider', + mockLogger, + -1, + ); + asyncFeatureStore = new AsyncStoreFacade(featureStore); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('caches nothing', async () => { + await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); + await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); + await asyncFeatureStore.all({ namespace: 'features' }); + + expect(mockGet).toHaveBeenCalledTimes(3); + }); + }); + + describe('with finite cache', () => { + beforeEach(() => { + mockGet.mockImplementation(() => Promise.resolve(JSON.stringify(testData))); + featureStore = new EdgeFeatureStore( + mockEdgeProvider, + sdkKey, + 'MockEdgeProvider', + mockLogger, + 100, + ); + asyncFeatureStore = new AsyncStoreFacade(featureStore); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('expires are configured duration', async () => { + jest.spyOn(Date, 'now').mockImplementation(() => 0); + await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); + await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); + await asyncFeatureStore.all({ namespace: 'features' }); + + expect(mockGet).toHaveBeenCalledTimes(1); + + jest.spyOn(Date, 'now').mockImplementation(() => 99); + await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); + await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); + await asyncFeatureStore.all({ namespace: 'features' }); + + expect(mockGet).toHaveBeenCalledTimes(1); + + jest.spyOn(Date, 'now').mockImplementation(() => 100); + await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); + await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); + await asyncFeatureStore.all({ namespace: 'features' }); + + expect(mockGet).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/packages/shared/akamai-edgeworker-sdk/__tests__/featureStore/cacheableStore.test.ts b/packages/shared/akamai-edgeworker-sdk/__tests__/featureStore/cacheableStore.test.ts deleted file mode 100644 index 593e9eb026..0000000000 --- a/packages/shared/akamai-edgeworker-sdk/__tests__/featureStore/cacheableStore.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { EdgeProvider } from '../../src/featureStore'; -import CacheableStoreProvider from '../../src/featureStore/cacheableStoreProvider'; -import * as testData from '../testData.json'; - -describe('given a mock edge provider with test data', () => { - const mockEdgeProvider: EdgeProvider = { - get: jest.fn(), - }; - const mockGet = mockEdgeProvider.get as jest.Mock; - - beforeEach(() => { - jest.useFakeTimers(); - mockGet.mockImplementation(() => Promise.resolve(JSON.stringify(testData))); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - describe('without cache TTL', () => { - it('caches initial request', async () => { - const cacheProvider = new CacheableStoreProvider(mockEdgeProvider, 'rootKey'); - await cacheProvider.get('rootKey'); - await cacheProvider.get('rootKey'); - expect(mockGet).toHaveBeenCalledTimes(1); - }); - - it('can force a refresh', async () => { - const cacheProvider = new CacheableStoreProvider(mockEdgeProvider, 'rootKey'); - await cacheProvider.get('rootKey'); - await cacheProvider.get('rootKey'); - expect(mockGet).toHaveBeenCalledTimes(1); - - await cacheProvider.prefetchPayloadFromOriginStore(); - await cacheProvider.get('rootKey'); - expect(mockGet).toHaveBeenCalledTimes(2); - }); - }); - - describe('with infinite cache ttl', () => { - it('caches initial request', async () => { - const cacheProvider = new CacheableStoreProvider(mockEdgeProvider, 'rootKey', 0); - await cacheProvider.get('rootKey'); - await cacheProvider.get('rootKey'); - expect(mockGet).toHaveBeenCalledTimes(1); - }); - - it('does not reset on prefetch', async () => { - const cacheProvider = new CacheableStoreProvider(mockEdgeProvider, 'rootKey', 0); - await cacheProvider.get('rootKey'); - await cacheProvider.get('rootKey'); - expect(mockGet).toHaveBeenCalledTimes(1); - - await cacheProvider.prefetchPayloadFromOriginStore(); - await cacheProvider.get('rootKey'); - expect(mockGet).toHaveBeenCalledTimes(1); - }); - }); - - describe('with finite cache ttl', () => { - it('caches initial request', async () => { - const cacheProvider = new CacheableStoreProvider(mockEdgeProvider, 'rootKey', 50); - await cacheProvider.get('rootKey'); - await cacheProvider.get('rootKey'); - expect(mockGet).toHaveBeenCalledTimes(1); - }); - - it('caches expires after duration', async () => { - jest.spyOn(Date, 'now').mockImplementation(() => 0); - const cacheProvider = new CacheableStoreProvider(mockEdgeProvider, 'rootKey', 50); - await cacheProvider.get('rootKey'); - await cacheProvider.get('rootKey'); - expect(mockGet).toHaveBeenCalledTimes(1); - - jest.spyOn(Date, 'now').mockImplementation(() => 20); - await cacheProvider.get('rootKey'); - expect(mockGet).toHaveBeenCalledTimes(1); - - jest.spyOn(Date, 'now').mockImplementation(() => 50); - await cacheProvider.get('rootKey'); - expect(mockGet).toHaveBeenCalledTimes(2); - }); - - it('prefetch respects cache TTL', async () => { - jest.spyOn(Date, 'now').mockImplementation(() => 0); - const cacheProvider = new CacheableStoreProvider(mockEdgeProvider, 'rootKey', 50); - await cacheProvider.get('rootKey'); - await cacheProvider.get('rootKey'); - expect(mockGet).toHaveBeenCalledTimes(1); - - await cacheProvider.prefetchPayloadFromOriginStore(); - await cacheProvider.get('rootKey'); - expect(mockGet).toHaveBeenCalledTimes(1); - - jest.spyOn(Date, 'now').mockImplementation(() => 50); - await cacheProvider.prefetchPayloadFromOriginStore(); - await cacheProvider.get('rootKey'); - expect(mockGet).toHaveBeenCalledTimes(2); - }); - }); -}); diff --git a/packages/shared/akamai-edgeworker-sdk/__tests__/featureStore/index.test.ts b/packages/shared/akamai-edgeworker-sdk/__tests__/featureStore/index.test.ts index ce243c6ed3..df51cd6107 100644 --- a/packages/shared/akamai-edgeworker-sdk/__tests__/featureStore/index.test.ts +++ b/packages/shared/akamai-edgeworker-sdk/__tests__/featureStore/index.test.ts @@ -21,7 +21,13 @@ describe('EdgeFeatureStore', () => { beforeEach(() => { mockGet.mockImplementation(() => Promise.resolve(JSON.stringify(testData))); - featureStore = new EdgeFeatureStore(mockEdgeProvider, sdkKey, 'MockEdgeProvider', mockLogger); + featureStore = new EdgeFeatureStore( + mockEdgeProvider, + sdkKey, + 'MockEdgeProvider', + mockLogger, + 0, + ); asyncFeatureStore = new AsyncStoreFacade(featureStore); }); diff --git a/packages/shared/akamai-edgeworker-sdk/__tests__/index.test.ts b/packages/shared/akamai-edgeworker-sdk/__tests__/index.test.ts index 9b0264f820..411905af00 100644 --- a/packages/shared/akamai-edgeworker-sdk/__tests__/index.test.ts +++ b/packages/shared/akamai-edgeworker-sdk/__tests__/index.test.ts @@ -6,6 +6,7 @@ const createClient = (sdkKey: string, mockLogger: LDLogger, mockEdgeProvider: Ed sdkKey, options: { logger: mockLogger, + cacheTtlMs: 0, }, featureStoreProvider: mockEdgeProvider, platformName: 'platform-name', @@ -40,14 +41,6 @@ describe('EdgeWorker', () => { it('should call edge providers get method only once', async () => { const client = createClient(sdkKey, mockLogger, mockEdgeProvider); await client.waitForInitialization(); - await client.allFlagsState({ kind: 'multi', l: { key: 'key' } }); - - expect(mockGet).toHaveBeenCalledTimes(1); - }); - - it('should call edge providers get method only 3 times', async () => { - const client = createClient(sdkKey, mockLogger, mockEdgeProvider); - await client.waitForInitialization(); const context: LDMultiKindContext = { kind: 'multi', l: { key: 'key' } }; @@ -55,7 +48,7 @@ describe('EdgeWorker', () => { await client.variation('testFlag1', context, false); await client.variationDetail('testFlag1', context, false); - expect(mockGet).toHaveBeenCalledTimes(3); + expect(mockGet).toHaveBeenCalledTimes(1); }); it('should successfully return data for allFlagsState', async () => { diff --git a/packages/shared/akamai-edgeworker-sdk/__tests__/platform/requests.test.ts b/packages/shared/akamai-edgeworker-sdk/__tests__/platform/requests.test.ts index e70836b070..fbd2555992 100644 --- a/packages/shared/akamai-edgeworker-sdk/__tests__/platform/requests.test.ts +++ b/packages/shared/akamai-edgeworker-sdk/__tests__/platform/requests.test.ts @@ -5,7 +5,7 @@ import EdgeRequests from '../../src/platform/requests'; const TEXT_RESPONSE = ''; const JSON_RESPONSE = {}; -describe('given a default instance of requets', () => { +describe('given a default instance of requests', () => { const requests = new EdgeRequests(); describe('fetch', () => { diff --git a/packages/shared/akamai-edgeworker-sdk/__tests__/utils/validateOptions.test.ts b/packages/shared/akamai-edgeworker-sdk/__tests__/utils/validateOptions.test.ts index 4126a9fe81..8953cb6a4a 100644 --- a/packages/shared/akamai-edgeworker-sdk/__tests__/utils/validateOptions.test.ts +++ b/packages/shared/akamai-edgeworker-sdk/__tests__/utils/validateOptions.test.ts @@ -26,7 +26,7 @@ const mockOptions = ({ }) => { const mockLogger = logger ?? BasicLogger.get(); const mockFeatureStore = - featureStore ?? new EdgeFeatureStore(edgeProvider, SDK_KEY, 'validationTest', mockLogger); + featureStore ?? new EdgeFeatureStore(edgeProvider, SDK_KEY, 'validationTest', mockLogger, 0); return { featureStore: allowEmptyFS ? undefined : mockFeatureStore, diff --git a/packages/shared/akamai-edgeworker-sdk/src/api/LDClient.ts b/packages/shared/akamai-edgeworker-sdk/src/api/LDClient.ts index b29d2a9221..b5f9cf9fec 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/api/LDClient.ts +++ b/packages/shared/akamai-edgeworker-sdk/src/api/LDClient.ts @@ -2,15 +2,9 @@ import { LDClientImpl, LDClient as LDClientType, - LDContext, - LDEvaluationDetail, - LDFlagsState, - LDFlagsStateOptions, - LDFlagValue, LDOptions, } from '@launchdarkly/js-server-sdk-common'; -import CacheableStoreProvider from '../featureStore/cacheableStoreProvider'; import EdgePlatform from '../platform'; import { createCallbacks, createOptions } from '../utils'; @@ -20,18 +14,10 @@ export interface CustomLDOptions extends LDOptions {} * The LaunchDarkly Akamai SDK edge client object. */ class LDClient extends LDClientImpl { - private _cacheableStoreProvider!: CacheableStoreProvider; - // sdkKey is only used to query featureStore, not to initialize with LD servers - constructor( - sdkKey: string, - platform: EdgePlatform, - options: LDOptions, - storeProvider: CacheableStoreProvider, - ) { + constructor(sdkKey: string, platform: EdgePlatform, options: LDOptions) { const finalOptions = createOptions(options); super(sdkKey, platform, finalOptions, createCallbacks(finalOptions.logger)); - this._cacheableStoreProvider = storeProvider; } override initialized(): boolean { @@ -39,39 +25,10 @@ class LDClient extends LDClientImpl { } override waitForInitialization(): Promise { - // we need to resolve the promise immediately because Akamai's runtime doesnt - // have a setimeout so everything executes synchronously. + // we need to resolve the promise immediately because Akamai's runtime doesn't + // have a setTimeout so everything executes synchronously. return Promise.resolve(this); } - - override async variation( - key: string, - context: LDContext, - defaultValue: LDFlagValue, - callback?: (err: any, res: LDFlagValue) => void, - ): Promise { - await this._cacheableStoreProvider.prefetchPayloadFromOriginStore(); - return super.variation(key, context, defaultValue, callback); - } - - override async variationDetail( - key: string, - context: LDContext, - defaultValue: LDFlagValue, - callback?: (err: any, res: LDEvaluationDetail) => void, - ): Promise { - await this._cacheableStoreProvider.prefetchPayloadFromOriginStore(); - return super.variationDetail(key, context, defaultValue, callback); - } - - override async allFlagsState( - context: LDContext, - options?: LDFlagsStateOptions, - callback?: (err: Error | null, res: LDFlagsState) => void, - ): Promise { - await this._cacheableStoreProvider.prefetchPayloadFromOriginStore(); - return super.allFlagsState(context, options, callback); - } } export default LDClient; diff --git a/packages/shared/akamai-edgeworker-sdk/src/featureStore/cache-item.ts b/packages/shared/akamai-edgeworker-sdk/src/featureStore/cache-item.ts new file mode 100644 index 0000000000..0aad8f87b9 --- /dev/null +++ b/packages/shared/akamai-edgeworker-sdk/src/featureStore/cache-item.ts @@ -0,0 +1,10 @@ +export class CacheItem { + private _cachedAt: number; + constructor(public readonly value: any) { + this._cachedAt = Date.now(); + } + + fresh(ttl: number): boolean { + return Date.now() - this._cachedAt < ttl; + } +} diff --git a/packages/shared/akamai-edgeworker-sdk/src/featureStore/cache.ts b/packages/shared/akamai-edgeworker-sdk/src/featureStore/cache.ts new file mode 100644 index 0000000000..15c499ad75 --- /dev/null +++ b/packages/shared/akamai-edgeworker-sdk/src/featureStore/cache.ts @@ -0,0 +1,38 @@ +import { CacheItem } from './cache-item'; + +export default class Cache { + cache: CacheItem | undefined; + cachedAt: number | undefined; + + constructor(private readonly _cacheTtlMs: number) {} + + get(): any | undefined { + // If the cacheTtlMs is less than 0, the cache is disabled. + if (this._cacheTtlMs < 0) { + return undefined; + } + + // If there isn't a cached item, we must return undefined. + if (this.cache === undefined) { + return undefined; + } + + // A cacheTtlMs of 0 is infinite caching, so we can always return the + // value. + // + // We also want to return it if the cache is still considered fresh. + if (this._cacheTtlMs === 0 || this.cache.fresh(this._cacheTtlMs)) { + return this.cache.value; + } + + // If you have gotten this far, the cache is stale. Better to drop it as a + // way to short-circuit checking the freshness again. + this.cache = undefined; + + return undefined; + } + + set(value: any): void { + this.cache = new CacheItem(value); + } +} diff --git a/packages/shared/akamai-edgeworker-sdk/src/featureStore/cacheableStoreProvider.ts b/packages/shared/akamai-edgeworker-sdk/src/featureStore/cacheableStoreProvider.ts deleted file mode 100644 index 7839d04ddc..0000000000 --- a/packages/shared/akamai-edgeworker-sdk/src/featureStore/cacheableStoreProvider.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { EdgeProvider } from '.'; - -/** - * Wraps around an edge provider to cache a copy of the SDK payload locally. - * - * If a cacheTtlMs is specified, then the cacheable store provider will cache - * results for that specified duration. If the data lookup fails after that - * interval, previously stored values will be retained. The lookup will be - * retried again after the TTL. - * - * If no cacheTtlMs is specified, the cache will be stored for the lifetime of - * the object. The cache can be manually refreshed by calling - * `prefetchPayloadFromOriginStore`. - * - * The wrapper is necessary to ensure that we don't make redundant sub-requests - * from Akamai to fetch an entire environment payload. At the time of this writing, - * the Akamai documentation (https://techdocs.akamai.com/edgeworkers/docs/resource-tier-limitations) - * limits the number of sub-requests to: - * - * - 2 for basic compute - * - 4 for dynamic compute - * - 10 for enterprise - */ -export default class CacheableStoreProvider implements EdgeProvider { - cache: Promise | null | undefined; - cachedAt: number | undefined; - - constructor( - private readonly _edgeProvider: EdgeProvider, - private readonly _rootKey: string, - private readonly _cacheTtlMs?: number, - ) {} - - /** - * Get data from the edge provider feature store. - * @param rootKey - * @returns - */ - async get(rootKey: string): Promise { - if (!this._isCacheValid()) { - this.cache = this._edgeProvider.get(rootKey); - this.cachedAt = Date.now(); - } - - return this.cache; - } - - /** - * Fetches environment payload data from the origin in accordance with the caching configuration. - * - * You should only call this function within a feature store to pre-fetch and cache payload data in environments - * where its expensive to make multiple outbound requests to the origin - * @param rootKey - * @returns - */ - async prefetchPayloadFromOriginStore(rootKey?: string): Promise { - if (this._cacheTtlMs === undefined) { - this.cache = undefined; // clear the cache so that new data can be fetched from the origin - } - - return this.get(rootKey || this._rootKey); - } - - /** - * Internal helper to determine if the cached values are still considered valid. - */ - private _isCacheValid(): boolean { - // If we don't have a cache, or we don't know how old the cache is, we have - // to consider it is invalid. - if (!this.cache || this.cachedAt === undefined) { - return false; - } - - // If the cache provider was configured without a TTL, then the cache is - // always considered valid. - if (!this._cacheTtlMs) { - return true; - } - - // Otherwise, it all depends on the time. - return Date.now() - this.cachedAt < this._cacheTtlMs; - } -} diff --git a/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts b/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts index 18960e1f75..f861f24bee 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts +++ b/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts @@ -8,6 +8,8 @@ import type { } from '@launchdarkly/js-server-sdk-common'; import { deserializePoll, noop } from '@launchdarkly/js-server-sdk-common'; +import Cache from './cache'; + export interface EdgeProvider { get: (rootKey: string) => Promise; } @@ -21,14 +23,17 @@ export const buildRootKey = (sdkKey: string) => `LD-Env-${sdkKey}`; export class EdgeFeatureStore implements LDFeatureStore { private readonly _rootKey: string; + private _cache: Cache; constructor( private readonly _edgeProvider: EdgeProvider, private readonly _sdkKey: string, private readonly _description: string, private _logger: LDLogger, + _cacheTtlMs: number, ) { this._rootKey = buildRootKey(this._sdkKey); + this._cache = new Cache(_cacheTtlMs); } async get( @@ -41,23 +46,17 @@ export class EdgeFeatureStore implements LDFeatureStore { this._logger.debug(`Requesting ${dataKey} from ${this._rootKey}.${kindKey}`); try { - const i = await this._edgeProvider.get(this._rootKey); - - if (!i) { - throw new Error(`${this._rootKey}.${kindKey} is not found in KV.`); - } - - const item = deserializePoll(i); - if (!item) { - throw new Error(`Error deserializing ${kindKey}`); + const storePayload = await this._getStorePayload(); + if (!storePayload) { + throw new Error(`Error deserializing ${this._rootKey}`); } switch (namespace) { case 'features': - callback(item.flags[dataKey]); + callback(storePayload.flags[dataKey]); break; case 'segments': - callback(item.segments[dataKey]); + callback(storePayload.segments[dataKey]); break; default: callback(null); @@ -73,22 +72,17 @@ export class EdgeFeatureStore implements LDFeatureStore { const kindKey = namespace === 'features' ? 'flags' : namespace; this._logger.debug(`Requesting all from ${this._rootKey}.${kindKey}`); try { - const i = await this._edgeProvider.get(this._rootKey); - if (!i) { + const storePayload = await this._getStorePayload(); + if (!storePayload) { throw new Error(`${this._rootKey}.${kindKey} is not found in KV.`); } - const item = deserializePoll(i); - if (!item) { - throw new Error(`Error deserializing ${kindKey}`); - } - switch (namespace) { case 'features': - callback(item.flags); + callback(storePayload.flags); break; case 'segments': - callback(item.segments); + callback(storePayload.segments); break; default: throw new Error(`Unsupported DataKind: ${namespace}`); @@ -99,6 +93,30 @@ export class EdgeFeatureStore implements LDFeatureStore { } } + // This method is used to retrieve the environment payload from the edge + // provider. It will cache the payload for the duration of the cacheTtlMs. + private async _getStorePayload(): Promise> { + let item = this._cache.get(); + if (item !== undefined) { + return item; + } + + const i = await this._edgeProvider.get(this._rootKey); + + if (!i) { + throw new Error(`${this._rootKey} is not found in KV.`); + } + + item = deserializePoll(i); + if (!item) { + throw new Error(`Error deserializing ${this._rootKey}`); + } + + this._cache.set(item); + + return item; + } + async initialized(callback: (isInitialized: boolean) => void = noop): Promise { const config = await this._edgeProvider.get(this._rootKey); const result = config !== null; diff --git a/packages/shared/akamai-edgeworker-sdk/src/index.ts b/packages/shared/akamai-edgeworker-sdk/src/index.ts index e38d0280b8..36c4225aae 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/index.ts +++ b/packages/shared/akamai-edgeworker-sdk/src/index.ts @@ -1,8 +1,7 @@ import { BasicLogger, LDOptions as LDOptionsCommon } from '@launchdarkly/js-server-sdk-common'; import LDClient from './api/LDClient'; -import { buildRootKey, EdgeFeatureStore, EdgeProvider } from './featureStore'; -import CacheableStoreProvider from './featureStore/cacheableStoreProvider'; +import { EdgeFeatureStore, EdgeProvider } from './featureStore'; import EdgePlatform from './platform'; import createPlatformInfo from './platform/info'; import { validateOptions } from './utils'; @@ -51,12 +50,13 @@ export const init = (params: BaseSDKParams): LDClient => { const logger = inputOptions.logger ?? BasicLogger.get(); const { cacheTtlMs, ...options } = inputOptions as any; - const cachableStoreProvider = new CacheableStoreProvider( + const featureStore = new EdgeFeatureStore( featureStoreProvider, - buildRootKey(sdkKey), - cacheTtlMs, + sdkKey, + 'Akamai', + logger, + cacheTtlMs ?? 100, ); - const featureStore = new EdgeFeatureStore(cachableStoreProvider, sdkKey, 'Akamai', logger); const ldOptions: LDOptionsCommon = { featureStore, @@ -68,5 +68,5 @@ export const init = (params: BaseSDKParams): LDClient => { validateOptions(params.sdkKey, ldOptions); const platform = createPlatformInfo(platformName, sdkName, sdkVersion); - return new LDClient(sdkKey, new EdgePlatform(platform), ldOptions, cachableStoreProvider); + return new LDClient(sdkKey, new EdgePlatform(platform), ldOptions); }; From 53dc62b5d2dcf1df265db20e24f1e8f5f17a7406 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Fri, 21 Feb 2025 15:27:15 -0500 Subject: [PATCH 2/6] Rename test --- .../akamai-edgeworker-sdk/__tests__/featureStore/cache.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/akamai-edgeworker-sdk/__tests__/featureStore/cache.test.ts b/packages/shared/akamai-edgeworker-sdk/__tests__/featureStore/cache.test.ts index 669dc92cb2..1e4212769b 100644 --- a/packages/shared/akamai-edgeworker-sdk/__tests__/featureStore/cache.test.ts +++ b/packages/shared/akamai-edgeworker-sdk/__tests__/featureStore/cache.test.ts @@ -87,7 +87,7 @@ describe('EdgeFeatureStore', () => { jest.resetAllMocks(); }); - it('expires are configured duration', async () => { + it('expires after configured duration', async () => { jest.spyOn(Date, 'now').mockImplementation(() => 0); await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); await asyncFeatureStore.get({ namespace: 'features' }, 'testFlag1'); From 883eb9a2993566c596f2bc2698c38fded192490a Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Fri, 21 Feb 2025 15:27:27 -0500 Subject: [PATCH 3/6] Use interface instead of separate class --- .../src/featureStore/cache-item.ts | 10 -------- .../src/featureStore/cache.ts | 23 +++++++++++-------- .../src/featureStore/index.ts | 18 +++++++-------- 3 files changed, 23 insertions(+), 28 deletions(-) delete mode 100644 packages/shared/akamai-edgeworker-sdk/src/featureStore/cache-item.ts diff --git a/packages/shared/akamai-edgeworker-sdk/src/featureStore/cache-item.ts b/packages/shared/akamai-edgeworker-sdk/src/featureStore/cache-item.ts deleted file mode 100644 index 0aad8f87b9..0000000000 --- a/packages/shared/akamai-edgeworker-sdk/src/featureStore/cache-item.ts +++ /dev/null @@ -1,10 +0,0 @@ -export class CacheItem { - private _cachedAt: number; - constructor(public readonly value: any) { - this._cachedAt = Date.now(); - } - - fresh(ttl: number): boolean { - return Date.now() - this._cachedAt < ttl; - } -} diff --git a/packages/shared/akamai-edgeworker-sdk/src/featureStore/cache.ts b/packages/shared/akamai-edgeworker-sdk/src/featureStore/cache.ts index 15c499ad75..804dfff318 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/featureStore/cache.ts +++ b/packages/shared/akamai-edgeworker-sdk/src/featureStore/cache.ts @@ -1,8 +1,10 @@ -import { CacheItem } from './cache-item'; +interface CacheItem { + value: any, + expiration: number, +} export default class Cache { - cache: CacheItem | undefined; - cachedAt: number | undefined; + _cache: CacheItem | undefined; constructor(private readonly _cacheTtlMs: number) {} @@ -13,26 +15,29 @@ export default class Cache { } // If there isn't a cached item, we must return undefined. - if (this.cache === undefined) { + if (this._cache === undefined) { return undefined; } // A cacheTtlMs of 0 is infinite caching, so we can always return the // value. // - // We also want to return it if the cache is still considered fresh. - if (this._cacheTtlMs === 0 || this.cache.fresh(this._cacheTtlMs)) { - return this.cache.value; + // We also want to return the value if it hasn't expired. + if (this._cacheTtlMs === 0 || Date.now() < this._cache.expiration) { + return this._cache.value; } // If you have gotten this far, the cache is stale. Better to drop it as a // way to short-circuit checking the freshness again. - this.cache = undefined; + this._cache = undefined; return undefined; } set(value: any): void { - this.cache = new CacheItem(value); + this._cache = { + value: value, + expiration: Date.now() + this._cacheTtlMs, + } } } diff --git a/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts b/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts index f861f24bee..1339cc58ca 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts +++ b/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts @@ -96,25 +96,25 @@ export class EdgeFeatureStore implements LDFeatureStore { // This method is used to retrieve the environment payload from the edge // provider. It will cache the payload for the duration of the cacheTtlMs. private async _getStorePayload(): Promise> { - let item = this._cache.get(); - if (item !== undefined) { - return item; + let payload = this._cache.get(); + if (payload !== undefined) { + return payload; } - const i = await this._edgeProvider.get(this._rootKey); + const providerData = await this._edgeProvider.get(this._rootKey); - if (!i) { + if (!providerData) { throw new Error(`${this._rootKey} is not found in KV.`); } - item = deserializePoll(i); - if (!item) { + payload = deserializePoll(providerData); + if (!payload) { throw new Error(`Error deserializing ${this._rootKey}`); } - this._cache.set(item); + this._cache.set(payload); - return item; + return payload; } async initialized(callback: (isInitialized: boolean) => void = noop): Promise { From 1aea342a56a134f95c42a33bb356d0121fe0f031 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Fri, 21 Feb 2025 15:40:31 -0500 Subject: [PATCH 4/6] Fix TS doc --- .../akamai-edgeworker-sdk/src/featureStore/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts b/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts index 1339cc58ca..336c98eb86 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts +++ b/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts @@ -95,6 +95,14 @@ export class EdgeFeatureStore implements LDFeatureStore { // This method is used to retrieve the environment payload from the edge // provider. It will cache the payload for the duration of the cacheTtlMs. + + + /** + * This method is used to retrieve the environment payload from the edge + * provider. It will cache the payload for the duration of the cacheTtlMs. + * + * @returns + */ private async _getStorePayload(): Promise> { let payload = this._cache.get(); if (payload !== undefined) { From 6a2ab55a215d7f830cabb863bdd6535f45714b6d Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Fri, 21 Feb 2025 15:42:58 -0500 Subject: [PATCH 5/6] Modify base sdk example to force major version release --- packages/sdk/akamai-base/example/ldClient.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/sdk/akamai-base/example/ldClient.ts b/packages/sdk/akamai-base/example/ldClient.ts index 36f92d2098..dc6750e742 100644 --- a/packages/sdk/akamai-base/example/ldClient.ts +++ b/packages/sdk/akamai-base/example/ldClient.ts @@ -54,6 +54,9 @@ export const evaluateFlagFromCustomFeatureStore = async ( const client = init({ sdkKey: 'Your-launchdarkly-environment-client-id', featureStoreProvider: new MyCustomStoreProvider(), + options: { + cacheTtlMs: 1_000, + } }); return client.variation(flagKey, context, defaultValue); From f414e115267793e6ca826e4bd4e4a8a1655808d1 Mon Sep 17 00:00:00 2001 From: Matthew Keeler Date: Fri, 21 Feb 2025 16:38:07 -0500 Subject: [PATCH 6/6] Fix linting issue --- packages/sdk/akamai-base/example/ldClient.ts | 2 +- .../akamai-edgeworker-sdk/src/featureStore/cache.ts | 10 +++++----- .../akamai-edgeworker-sdk/src/featureStore/index.ts | 11 +++++------ 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/packages/sdk/akamai-base/example/ldClient.ts b/packages/sdk/akamai-base/example/ldClient.ts index dc6750e742..46fc1fa020 100644 --- a/packages/sdk/akamai-base/example/ldClient.ts +++ b/packages/sdk/akamai-base/example/ldClient.ts @@ -56,7 +56,7 @@ export const evaluateFlagFromCustomFeatureStore = async ( featureStoreProvider: new MyCustomStoreProvider(), options: { cacheTtlMs: 1_000, - } + }, }); return client.variation(flagKey, context, defaultValue); diff --git a/packages/shared/akamai-edgeworker-sdk/src/featureStore/cache.ts b/packages/shared/akamai-edgeworker-sdk/src/featureStore/cache.ts index 804dfff318..3e8b021b3a 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/featureStore/cache.ts +++ b/packages/shared/akamai-edgeworker-sdk/src/featureStore/cache.ts @@ -1,10 +1,10 @@ interface CacheItem { - value: any, - expiration: number, + value: any; + expiration: number; } export default class Cache { - _cache: CacheItem | undefined; + private _cache: CacheItem | undefined; constructor(private readonly _cacheTtlMs: number) {} @@ -36,8 +36,8 @@ export default class Cache { set(value: any): void { this._cache = { - value: value, + value, expiration: Date.now() + this._cacheTtlMs, - } + }; } } diff --git a/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts b/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts index 336c98eb86..a87bcad193 100644 --- a/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts +++ b/packages/shared/akamai-edgeworker-sdk/src/featureStore/index.ts @@ -96,13 +96,12 @@ export class EdgeFeatureStore implements LDFeatureStore { // This method is used to retrieve the environment payload from the edge // provider. It will cache the payload for the duration of the cacheTtlMs. - /** - * This method is used to retrieve the environment payload from the edge - * provider. It will cache the payload for the duration of the cacheTtlMs. - * - * @returns - */ + * This method is used to retrieve the environment payload from the edge + * provider. It will cache the payload for the duration of the cacheTtlMs. + * + * @returns + */ private async _getStorePayload(): Promise> { let payload = this._cache.get(); if (payload !== undefined) {