Skip to content

Commit 68f1c78

Browse files
authored
fix: Fix issue that caused the feature store get function impl to be called twice (#178)
Story details: https://app.shortcut.com/launchdarkly/story/207639 This PR adds a new `CacheableStoreProvider` wrapper to Akamai Edge SDK to prevent redundant requests from being made to the origin store. This cacheable store provider ensure we're making only a single call per request thus removing duplicate sub-requests call which could be costly to our customers. With this fix, there will be only a single call made per method call `variation`, `variationDetail` or `allFlagsState` is called. Also added some unit test to validate duplicate calls are not made.
1 parent 6771a1c commit 68f1c78

File tree

8 files changed

+238
-36
lines changed

8 files changed

+238
-36
lines changed

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

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,7 @@ import {
1414
LDClient,
1515
LDOptions,
1616
EdgeProvider,
17-
EdgeFeatureStore,
1817
} from '@launchdarkly/akamai-edgeworker-sdk-common';
19-
import { BasicLogger } from '@launchdarkly/js-server-sdk-common';
2018

2119
export * from '@launchdarkly/akamai-edgeworker-sdk-common';
2220

@@ -35,15 +33,12 @@ export const init = ({
3533
options = {},
3634
sdkKey,
3735
featureStoreProvider,
38-
}: AkamaiLDClientParams): LDClient => {
39-
const logger = options.logger ?? BasicLogger.get();
40-
41-
return initEdge({
36+
}: AkamaiLDClientParams): LDClient =>
37+
initEdge({
4238
sdkKey,
4339
options,
44-
edgeFeatureStore: new EdgeFeatureStore(featureStoreProvider, sdkKey, 'Akamai', logger),
40+
featureStoreProvider,
4541
platformName: 'Akamai EdgeWorker',
4642
sdkName: '@launchdarkly/akamai-server-base-sdk',
4743
sdkVersion: '__LD_VERSION__',
4844
});
49-
};

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

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,7 @@
99
* @packageDocumentation
1010
*/
1111

12-
import {
13-
init as initEdge,
14-
LDClient,
15-
LDOptions,
16-
EdgeFeatureStore,
17-
} from '@launchdarkly/akamai-edgeworker-sdk-common';
18-
import { BasicLogger } from '@launchdarkly/js-server-sdk-common';
12+
import { init as initEdge, LDClient, LDOptions } from '@launchdarkly/akamai-edgeworker-sdk-common';
1913
import EdgeKVProvider from './edgekv/edgeKVProvider';
2014

2115
export * from '@launchdarkly/akamai-edgeworker-sdk-common';
@@ -38,14 +32,12 @@ export const init = ({
3832
options = {},
3933
sdkKey,
4034
}: AkamaiLDClientParams): LDClient => {
41-
const logger = options.logger ?? BasicLogger.get();
4235
const edgekvProvider = new EdgeKVProvider({ namespace, group });
43-
const featureStore = new EdgeFeatureStore(edgekvProvider, sdkKey, 'Akamai', logger);
4436

4537
return initEdge({
4638
sdkKey,
4739
options,
48-
edgeFeatureStore: featureStore,
40+
featureStoreProvider: edgekvProvider,
4941
platformName: 'Akamai EdgeWorker',
5042
sdkName: '@launchdarkly/akamai-server-edgekv-sdk',
5143
sdkVersion: '__LD_VERSION__',
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { EdgeProvider, LDLogger, LDMultiKindContext, LDSingleKindContext, init } from '../..';
2+
3+
import * as testData from './testData.json';
4+
5+
const createClient = (sdkKey: string, mockLogger: LDLogger, mockEdgeProvider: EdgeProvider) =>
6+
init({
7+
sdkKey,
8+
options: {
9+
logger: mockLogger,
10+
},
11+
featureStoreProvider: mockEdgeProvider,
12+
platformName: 'platform-name',
13+
sdkName: 'Akamai',
14+
sdkVersion: '0.0.1',
15+
});
16+
17+
describe('EdgeWorker', () => {
18+
const sdkKey = 'sdkKey';
19+
20+
const mockLogger: LDLogger = {
21+
error: jest.fn(),
22+
warn: jest.fn(),
23+
info: jest.fn(),
24+
debug: jest.fn(),
25+
};
26+
27+
const mockEdgeProvider: EdgeProvider = {
28+
get: jest.fn(),
29+
};
30+
31+
const mockGet = mockEdgeProvider.get as jest.Mock;
32+
33+
beforeEach(() => {
34+
mockGet.mockImplementation(() => Promise.resolve(JSON.stringify(testData)));
35+
});
36+
37+
afterEach(() => {
38+
jest.resetAllMocks();
39+
});
40+
41+
it('should call edge providers get method only once', async () => {
42+
const client = createClient(sdkKey, mockLogger, mockEdgeProvider);
43+
await client.waitForInitialization();
44+
await client.allFlagsState({ kind: 'multi', l: { key: 'key' } });
45+
46+
expect(mockGet).toHaveBeenCalledTimes(1);
47+
});
48+
49+
it('should call edge providers get method only 3 times', async () => {
50+
const client = createClient(sdkKey, mockLogger, mockEdgeProvider);
51+
await client.waitForInitialization();
52+
53+
const context: LDMultiKindContext = { kind: 'multi', l: { key: 'key' } };
54+
55+
await client.allFlagsState(context, { clientSideOnly: true });
56+
await client.variation('testFlag1', context, false);
57+
await client.variationDetail('testFlag1', context, false);
58+
59+
expect(mockGet).toHaveBeenCalledTimes(3);
60+
});
61+
62+
it('should successfully return data for allFlagsState', async () => {
63+
const client = createClient(sdkKey, mockLogger, mockEdgeProvider);
64+
await client.waitForInitialization();
65+
66+
const context: LDMultiKindContext = { kind: 'multi', l: { key: 'key' } };
67+
68+
const allFlags = await client.allFlagsState(context, { clientSideOnly: true });
69+
expect(allFlags.toJSON()).toEqual({
70+
$flagsState: {
71+
testFlag1: { debugEventsUntilDate: 2000, variation: 0, version: 2 },
72+
testFlag2: { debugEventsUntilDate: 2000, variation: 0, version: 2 },
73+
},
74+
$valid: true,
75+
testFlag1: true,
76+
testFlag2: true,
77+
});
78+
});
79+
80+
it('should should successfully evaluate flags using a flag key', async () => {
81+
const client = createClient(sdkKey, mockLogger, mockEdgeProvider);
82+
await client.waitForInitialization();
83+
84+
const context: LDMultiKindContext = { kind: 'multi', l: { key: 'key' } };
85+
86+
const flagValue = await client.variation('testFlag1', context, false);
87+
expect(flagValue).toEqual(true);
88+
});
89+
90+
it('should should successfully return flag evaluation details', async () => {
91+
const client = createClient(sdkKey, mockLogger, mockEdgeProvider);
92+
await client.waitForInitialization();
93+
94+
const context: LDMultiKindContext = { kind: 'multi', l: { key: 'key' } };
95+
96+
const detail = await client.variationDetail('testFlag1', context, false);
97+
expect(detail).toEqual({ reason: { kind: 'FALLTHROUGH' }, value: true, variationIndex: 0 });
98+
});
99+
100+
it('should should successfully evaluate flags with segment data', async () => {
101+
const client = createClient(sdkKey, mockLogger, mockEdgeProvider);
102+
await client.waitForInitialization();
103+
104+
const context: LDSingleKindContext = {
105+
kind: 'user',
106+
key: 'return-false-for-segment-target',
107+
};
108+
109+
const flagValue = await client.variation('testFlag3', context, false);
110+
expect(flagValue).toEqual(false);
111+
});
112+
});

packages/shared/akamai-edgeworker-sdk/src/__tests__/testData.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@
9999
"usingMobileKey": true,
100100
"usingEnvironmentId": true
101101
},
102-
"clientSide": true,
102+
"clientSide": false,
103103
"salt": "aef830243d6640d0a973be89988e008d",
104104
"trackEvents": false,
105105
"trackEventsFallthrough": false,
@@ -114,7 +114,7 @@
114114
"tags": [],
115115
"creationDate": 1676063792158,
116116
"key": "testSegment1",
117-
"included": [],
117+
"included": ["return-false-for-segment-target"],
118118
"excluded": [],
119119
"includedContexts": [],
120120
"excludedContexts": [],
Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,71 @@
11
// eslint-disable-next-line max-classes-per-file
2-
import { LDClientImpl, LDOptions } from '@launchdarkly/js-server-sdk-common';
2+
import {
3+
LDClient as LDClientType,
4+
LDClientImpl,
5+
LDOptions,
6+
LDContext,
7+
LDFlagValue,
8+
LDEvaluationDetail,
9+
LDFlagsStateOptions,
10+
LDFlagsState,
11+
} from '@launchdarkly/js-server-sdk-common';
312
import EdgePlatform from '../platform';
413
import { createCallbacks, createOptions } from '../utils';
14+
import CacheableStoreProvider from '../featureStore/cacheableStoreProvider';
15+
16+
export interface CustomLDOptions extends LDOptions {}
517

618
/**
719
* The LaunchDarkly Akamai SDK edge client object.
820
*/
9-
export default class LDClient extends LDClientImpl {
21+
class LDClient extends LDClientImpl {
22+
private cacheableStoreProvider!: CacheableStoreProvider;
23+
1024
// sdkKey is only used to query featureStore, not to initialize with LD servers
11-
constructor(sdkKey: string, platform: EdgePlatform, options: LDOptions) {
25+
constructor(
26+
sdkKey: string,
27+
platform: EdgePlatform,
28+
options: LDOptions,
29+
storeProvider: CacheableStoreProvider
30+
) {
1231
super(sdkKey, platform, createOptions(options), createCallbacks());
32+
this.cacheableStoreProvider = storeProvider;
33+
}
34+
35+
override waitForInitialization(): Promise<LDClientType> {
36+
// we need to resolve the promise immediately because Akamai's runtime doesnt
37+
// have a setimeout so everything executes synchronously.
38+
return Promise.resolve(this);
39+
}
40+
41+
override async variation(
42+
key: string,
43+
context: LDContext,
44+
defaultValue: LDFlagValue,
45+
callback?: (err: any, res: LDFlagValue) => void
46+
): Promise<LDFlagValue> {
47+
await this.cacheableStoreProvider.prefetchPayloadFromOriginStore();
48+
return super.variation(key, context, defaultValue, callback);
49+
}
50+
51+
override async variationDetail(
52+
key: string,
53+
context: LDContext,
54+
defaultValue: LDFlagValue,
55+
callback?: (err: any, res: LDEvaluationDetail) => void
56+
): Promise<LDEvaluationDetail> {
57+
await this.cacheableStoreProvider.prefetchPayloadFromOriginStore();
58+
return super.variationDetail(key, context, defaultValue, callback);
59+
}
60+
61+
override async allFlagsState(
62+
context: LDContext,
63+
options?: LDFlagsStateOptions,
64+
callback?: (err: Error | null, res: LDFlagsState) => void
65+
): Promise<LDFlagsState> {
66+
await this.cacheableStoreProvider.prefetchPayloadFromOriginStore();
67+
return super.allFlagsState(context, options, callback);
1368
}
1469
}
70+
71+
export default LDClient;
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { EdgeProvider } from '.';
2+
3+
/**
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.
6+
*/
7+
export default class CacheableStoreProvider implements EdgeProvider {
8+
cache: string | null | undefined;
9+
10+
constructor(private readonly edgeProvider: EdgeProvider, private readonly rootKey: string) {}
11+
12+
/**
13+
* Get data from the edge provider feature store.
14+
* @param rootKey
15+
* @returns
16+
*/
17+
async get(rootKey: string): Promise<string | null | undefined> {
18+
if (!this.cache) {
19+
this.cache = await this.edgeProvider.get(rootKey);
20+
}
21+
22+
return this.cache;
23+
}
24+
25+
/**
26+
* Invalidates cache and fetch environment payload data from origin. The result of this data is cached in memory.
27+
* You should only call this function within a feature store to pre-fetch and cache payload data in environments
28+
* where its expensive to make multiple outbound requests to the origin
29+
* @param rootKey
30+
* @returns
31+
*/
32+
async prefetchPayloadFromOriginStore(rootKey?: string): Promise<string | null | undefined> {
33+
this.cache = undefined; // clear the cache so that new data can be fetched from the origin
34+
return this.get(rootKey || this.rootKey);
35+
}
36+
}

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ export interface EdgeProvider {
1212
get: (rootKey: string) => Promise<string | null | undefined>;
1313
}
1414

15+
/**
16+
* Builds the root key needed to retrieve environment payload from the feature store
17+
* @param sdkKey string
18+
* @returns
19+
*/
20+
export const buildRootKey = (sdkKey: string) => `LD-Env-${sdkKey}`;
21+
1522
export class EdgeFeatureStore implements LDFeatureStore {
1623
private readonly rootKey: string;
1724

@@ -21,7 +28,7 @@ export class EdgeFeatureStore implements LDFeatureStore {
2128
private readonly description: string,
2229
private logger: LDLogger
2330
) {
24-
this.rootKey = `LD-Env-${sdkKey}`;
31+
this.rootKey = buildRootKey(this.sdkKey);
2532
}
2633

2734
async get(

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

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,10 @@
1-
import {
2-
BasicLogger,
3-
LDFeatureStore,
4-
LDOptions as LDOptionsCommon,
5-
} from '@launchdarkly/js-server-sdk-common';
1+
import { BasicLogger, LDOptions as LDOptionsCommon } from '@launchdarkly/js-server-sdk-common';
62
import { validateOptions } from './utils';
73
import LDClient from './api/LDClient';
84
import EdgePlatform from './platform';
95
import createPlatformInfo from './platform/info';
10-
import type { EdgeProvider } from './featureStore';
11-
import { EdgeFeatureStore } from './featureStore';
6+
import { EdgeProvider, buildRootKey, EdgeFeatureStore } from './featureStore';
7+
import CacheableStoreProvider from './featureStore/cacheableStoreProvider';
128

139
/**
1410
* The Launchdarkly Edge SDKs configuration options. Only logger is officially
@@ -29,18 +25,25 @@ export { EdgeFeatureStore, EdgeProvider, LDOptions, LDOptionsInternal };
2925
type BaseSDKParams = {
3026
sdkKey: string;
3127
options?: LDOptions;
32-
edgeFeatureStore: LDFeatureStore;
28+
featureStoreProvider: EdgeProvider;
3329
platformName: string;
3430
sdkName: string;
3531
sdkVersion: string;
3632
};
3733

3834
export const init = (params: BaseSDKParams): LDClient => {
39-
const { sdkKey, options = {}, edgeFeatureStore, platformName, sdkName, sdkVersion } = params;
35+
const { sdkKey, options = {}, featureStoreProvider, platformName, sdkName, sdkVersion } = params;
4036

4137
const logger = options.logger ?? BasicLogger.get();
42-
const ldOptions = {
43-
featureStore: edgeFeatureStore,
38+
39+
const cachableStoreProvider = new CacheableStoreProvider(
40+
featureStoreProvider,
41+
buildRootKey(sdkKey)
42+
);
43+
const featureStore = new EdgeFeatureStore(cachableStoreProvider, sdkKey, 'Akamai', logger);
44+
45+
const ldOptions: LDOptionsCommon = {
46+
featureStore,
4447
logger,
4548
...options,
4649
};
@@ -49,5 +52,5 @@ export const init = (params: BaseSDKParams): LDClient => {
4952
validateOptions(params.sdkKey, ldOptions);
5053
const platform = createPlatformInfo(platformName, sdkName, sdkVersion);
5154

52-
return new LDClient(sdkKey, new EdgePlatform(platform), ldOptions);
55+
return new LDClient(sdkKey, new EdgePlatform(platform), ldOptions, cachableStoreProvider);
5356
};

0 commit comments

Comments
 (0)