Skip to content

Commit 4f961dd

Browse files
feat: Add cacheTtlMs option (#760)
When the SDK retrieves data from the EdgeKV, it does so using a single sub-request per call to `variation`, `variationDetail`, or `allFlagsState`. The problem is that Akamai imposes a limit of 4 sub-requests per handler event. If a customer evaluates more than 4 distinct flags during the handling of a single event, subsequent flag lookups would fail. To combat this, we now cache the flag values for a specified amount of time. --------- Co-authored-by: Ryan Lamb <[email protected]>
1 parent 9f3e3b6 commit 4f961dd

File tree

6 files changed

+197
-13
lines changed

6 files changed

+197
-13
lines changed

packages/sdk/akamai-edgekv/__tests__/index.test.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import EdgeKVProvider from '../src/edgekv/edgeKVProvider';
2-
import { init as initWithEdgeKV, LDClient, LDContext } from '../src/index';
2+
import { init as initWithEdgeKV, LDClient, LDContext, LDLogger } from '../src/index';
33
import * as testData from './testData.json';
44

55
jest.mock('../src/edgekv/edgekv', () => ({
66
EdgeKV: jest.fn(),
77
}));
88

9+
let logger: LDLogger;
10+
911
const sdkKey = 'test-sdk-key';
1012
const flagKey1 = 'testFlag1';
1113
const flagKey2 = 'testFlag2';
@@ -17,11 +19,22 @@ describe('init', () => {
1719

1820
describe('init with Edge KV', () => {
1921
beforeAll(async () => {
20-
ldClient = initWithEdgeKV({ namespace: 'akamai-test', group: 'Akamai', sdkKey });
22+
ldClient = initWithEdgeKV({
23+
namespace: 'akamai-test',
24+
group: 'Akamai',
25+
sdkKey,
26+
options: { logger },
27+
});
2128
await ldClient.waitForInitialization();
2229
});
2330

2431
beforeEach(() => {
32+
logger = {
33+
error: jest.fn(),
34+
warn: jest.fn(),
35+
info: jest.fn(),
36+
debug: jest.fn(),
37+
};
2538
jest
2639
.spyOn(EdgeKVProvider.prototype, 'get')
2740
.mockImplementation(() => Promise.resolve(JSON.stringify(testData)));
@@ -31,6 +44,12 @@ describe('init', () => {
3144
ldClient.close();
3245
});
3346

47+
it('should not log a warning about initialization', async () => {
48+
const spy = jest.spyOn(logger, 'warn');
49+
await ldClient.variation(flagKey1, context, false);
50+
expect(spy).not.toHaveBeenCalled();
51+
});
52+
3453
describe('flags', () => {
3554
it('variation default', async () => {
3655
const value = await ldClient.variation(flagKey1, context, false);

packages/sdk/akamai-edgekv/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,13 @@ export const init = ({
3434
sdkKey,
3535
}: AkamaiLDClientParams): LDClient => {
3636
const logger = options.logger ?? BasicLogger.get();
37+
const cacheTtlMs = options.cacheTtlMs ?? 100;
3738

3839
const edgekvProvider = new EdgeKVProvider({ namespace, group, logger });
3940

4041
return initEdge({
4142
sdkKey,
42-
options: { ...options, logger },
43+
options: { ...options, logger, cacheTtlMs },
4344
featureStoreProvider: edgekvProvider,
4445
platformName: 'Akamai EdgeWorker',
4546
sdkName: '@launchdarkly/akamai-server-edgekv-sdk',
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { EdgeProvider } from '../../src/featureStore';
2+
import CacheableStoreProvider from '../../src/featureStore/cacheableStoreProvider';
3+
import * as testData from '../testData.json';
4+
5+
describe('given a mock edge provider with test data', () => {
6+
const mockEdgeProvider: EdgeProvider = {
7+
get: jest.fn(),
8+
};
9+
const mockGet = mockEdgeProvider.get as jest.Mock;
10+
11+
beforeEach(() => {
12+
jest.useFakeTimers();
13+
mockGet.mockImplementation(() => Promise.resolve(JSON.stringify(testData)));
14+
});
15+
16+
afterEach(() => {
17+
jest.resetAllMocks();
18+
});
19+
20+
describe('without cache TTL', () => {
21+
it('caches initial request', async () => {
22+
const cacheProvider = new CacheableStoreProvider(mockEdgeProvider, 'rootKey');
23+
await cacheProvider.get('rootKey');
24+
await cacheProvider.get('rootKey');
25+
expect(mockGet).toHaveBeenCalledTimes(1);
26+
});
27+
28+
it('can force a refresh', async () => {
29+
const cacheProvider = new CacheableStoreProvider(mockEdgeProvider, 'rootKey');
30+
await cacheProvider.get('rootKey');
31+
await cacheProvider.get('rootKey');
32+
expect(mockGet).toHaveBeenCalledTimes(1);
33+
34+
await cacheProvider.prefetchPayloadFromOriginStore();
35+
await cacheProvider.get('rootKey');
36+
expect(mockGet).toHaveBeenCalledTimes(2);
37+
});
38+
});
39+
40+
describe('with infinite cache ttl', () => {
41+
it('caches initial request', async () => {
42+
const cacheProvider = new CacheableStoreProvider(mockEdgeProvider, 'rootKey', 0);
43+
await cacheProvider.get('rootKey');
44+
await cacheProvider.get('rootKey');
45+
expect(mockGet).toHaveBeenCalledTimes(1);
46+
});
47+
48+
it('does not reset on prefetch', async () => {
49+
const cacheProvider = new CacheableStoreProvider(mockEdgeProvider, 'rootKey', 0);
50+
await cacheProvider.get('rootKey');
51+
await cacheProvider.get('rootKey');
52+
expect(mockGet).toHaveBeenCalledTimes(1);
53+
54+
await cacheProvider.prefetchPayloadFromOriginStore();
55+
await cacheProvider.get('rootKey');
56+
expect(mockGet).toHaveBeenCalledTimes(1);
57+
});
58+
});
59+
60+
describe('with finite cache ttl', () => {
61+
it('caches initial request', async () => {
62+
const cacheProvider = new CacheableStoreProvider(mockEdgeProvider, 'rootKey', 50);
63+
await cacheProvider.get('rootKey');
64+
await cacheProvider.get('rootKey');
65+
expect(mockGet).toHaveBeenCalledTimes(1);
66+
});
67+
68+
it('caches expires after duration', async () => {
69+
jest.spyOn(Date, 'now').mockImplementation(() => 0);
70+
const cacheProvider = new CacheableStoreProvider(mockEdgeProvider, 'rootKey', 50);
71+
await cacheProvider.get('rootKey');
72+
await cacheProvider.get('rootKey');
73+
expect(mockGet).toHaveBeenCalledTimes(1);
74+
75+
jest.spyOn(Date, 'now').mockImplementation(() => 20);
76+
await cacheProvider.get('rootKey');
77+
expect(mockGet).toHaveBeenCalledTimes(1);
78+
79+
jest.spyOn(Date, 'now').mockImplementation(() => 50);
80+
await cacheProvider.get('rootKey');
81+
expect(mockGet).toHaveBeenCalledTimes(2);
82+
});
83+
84+
it('prefetch respects cache TTL', async () => {
85+
jest.spyOn(Date, 'now').mockImplementation(() => 0);
86+
const cacheProvider = new CacheableStoreProvider(mockEdgeProvider, 'rootKey', 50);
87+
await cacheProvider.get('rootKey');
88+
await cacheProvider.get('rootKey');
89+
expect(mockGet).toHaveBeenCalledTimes(1);
90+
91+
await cacheProvider.prefetchPayloadFromOriginStore();
92+
await cacheProvider.get('rootKey');
93+
expect(mockGet).toHaveBeenCalledTimes(1);
94+
95+
jest.spyOn(Date, 'now').mockImplementation(() => 50);
96+
await cacheProvider.prefetchPayloadFromOriginStore();
97+
await cacheProvider.get('rootKey');
98+
expect(mockGet).toHaveBeenCalledTimes(2);
99+
});
100+
});
101+
});

packages/shared/akamai-edgeworker-sdk/src/api/LDClient.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ class LDClient extends LDClientImpl {
3434
this._cacheableStoreProvider = storeProvider;
3535
}
3636

37+
override initialized(): boolean {
38+
return true;
39+
}
40+
3741
override waitForInitialization(): Promise<LDClientType> {
3842
// we need to resolve the promise immediately because Akamai's runtime doesnt
3943
// have a setimeout so everything executes synchronously.
Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,34 @@
11
import { EdgeProvider } from '.';
22

33
/**
4-
* Wraps around an edge provider to cache a copy of the sdk payload locally an explicit request is made to refetch data from the origin.
5-
* The wrapper is neccessary to ensure that we dont make redundant sub-requests from Akamai to fetch an entire environment payload.
4+
* Wraps around an edge provider to cache a copy of the SDK payload locally.
5+
*
6+
* If a cacheTtlMs is specified, then the cacheable store provider will cache
7+
* results for that specified duration. If the data lookup fails after that
8+
* interval, previously stored values will be retained. The lookup will be
9+
* retried again after the TTL.
10+
*
11+
* If no cacheTtlMs is specified, the cache will be stored for the lifetime of
12+
* the object. The cache can be manually refreshed by calling
13+
* `prefetchPayloadFromOriginStore`.
14+
*
15+
* The wrapper is necessary to ensure that we don't make redundant sub-requests
16+
* from Akamai to fetch an entire environment payload. At the time of this writing,
17+
* the Akamai documentation (https://techdocs.akamai.com/edgeworkers/docs/resource-tier-limitations)
18+
* limits the number of sub-requests to:
19+
*
20+
* - 2 for basic compute
21+
* - 4 for dynamic compute
22+
* - 10 for enterprise
623
*/
724
export default class CacheableStoreProvider implements EdgeProvider {
8-
cache: string | null | undefined;
25+
cache: Promise<string | null | undefined> | null | undefined;
26+
cachedAt: number | undefined;
927

1028
constructor(
1129
private readonly _edgeProvider: EdgeProvider,
1230
private readonly _rootKey: string,
31+
private readonly _cacheTtlMs?: number,
1332
) {}
1433

1534
/**
@@ -18,22 +37,47 @@ export default class CacheableStoreProvider implements EdgeProvider {
1837
* @returns
1938
*/
2039
async get(rootKey: string): Promise<string | null | undefined> {
21-
if (!this.cache) {
22-
this.cache = await this._edgeProvider.get(rootKey);
40+
if (!this._isCacheValid()) {
41+
this.cache = this._edgeProvider.get(rootKey);
42+
this.cachedAt = Date.now();
2343
}
2444

2545
return this.cache;
2646
}
2747

2848
/**
29-
* Invalidates cache and fetch environment payload data from origin. The result of this data is cached in memory.
49+
* Fetches environment payload data from the origin in accordance with the caching configuration.
50+
*
3051
* You should only call this function within a feature store to pre-fetch and cache payload data in environments
3152
* where its expensive to make multiple outbound requests to the origin
3253
* @param rootKey
3354
* @returns
3455
*/
3556
async prefetchPayloadFromOriginStore(rootKey?: string): Promise<string | null | undefined> {
36-
this.cache = undefined; // clear the cache so that new data can be fetched from the origin
57+
if (this._cacheTtlMs === undefined) {
58+
this.cache = undefined; // clear the cache so that new data can be fetched from the origin
59+
}
60+
3761
return this.get(rootKey || this._rootKey);
3862
}
63+
64+
/**
65+
* Internal helper to determine if the cached values are still considered valid.
66+
*/
67+
private _isCacheValid(): boolean {
68+
// If we don't have a cache, or we don't know how old the cache is, we have
69+
// to consider it is invalid.
70+
if (!this.cache || this.cachedAt === undefined) {
71+
return false;
72+
}
73+
74+
// If the cache provider was configured without a TTL, then the cache is
75+
// always considered valid.
76+
if (!this._cacheTtlMs) {
77+
return true;
78+
}
79+
80+
// Otherwise, it all depends on the time.
81+
return Date.now() - this.cachedAt < this._cacheTtlMs;
82+
}
3983
}

packages/shared/akamai-edgeworker-sdk/src/index.ts

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ import { validateOptions } from './utils';
1212
* supported. sendEvents is unsupported and is only included as a beta
1313
* preview.
1414
*/
15-
type LDOptions = Pick<LDOptionsCommon, 'logger' | 'sendEvents'>;
15+
type LDOptions = {
16+
/**
17+
* The time-to-live for the cache in milliseconds. The default is 100ms. A
18+
* value of 0 will cache indefinitely.
19+
*/
20+
cacheTtlMs?: number;
21+
} & Pick<LDOptionsCommon, 'logger' | 'sendEvents'>;
1622

1723
/**
1824
* The internal options include featureStore because that's how the LDClient
@@ -33,13 +39,22 @@ type BaseSDKParams = {
3339
};
3440

3541
export const init = (params: BaseSDKParams): LDClient => {
36-
const { sdkKey, options = {}, featureStoreProvider, platformName, sdkName, sdkVersion } = params;
42+
const {
43+
sdkKey,
44+
options: inputOptions = {},
45+
featureStoreProvider,
46+
platformName,
47+
sdkName,
48+
sdkVersion,
49+
} = params;
3750

38-
const logger = options.logger ?? BasicLogger.get();
51+
const logger = inputOptions.logger ?? BasicLogger.get();
52+
const { cacheTtlMs, ...options } = inputOptions as any;
3953

4054
const cachableStoreProvider = new CacheableStoreProvider(
4155
featureStoreProvider,
4256
buildRootKey(sdkKey),
57+
cacheTtlMs,
4358
);
4459
const featureStore = new EdgeFeatureStore(cachableStoreProvider, sdkKey, 'Akamai', logger);
4560

0 commit comments

Comments
 (0)