Skip to content

Commit c1de485

Browse files
authored
feat: Add TTL caching for data store (#801)
1 parent a76d196 commit c1de485

File tree

6 files changed

+223
-96
lines changed

6 files changed

+223
-96
lines changed

packages/sdk/cloudflare/__tests__/index.test.ts

+133-65
Original file line numberDiff line numberDiff line change
@@ -21,87 +21,155 @@ const namespace = 'LD_KV';
2121
const rootEnvKey = `LD-Env-${clientSideID}`;
2222

2323
describe('init', () => {
24-
let kv: KVNamespace;
25-
let ldClient: LDClient;
26-
27-
beforeAll(async () => {
28-
kv = (await mf.getKVNamespace(namespace)) as unknown as KVNamespace;
29-
await kv.put(rootEnvKey, JSON.stringify(allFlagsSegments));
30-
ldClient = init(clientSideID, kv);
31-
await ldClient.waitForInitialization();
32-
});
33-
34-
afterAll(() => {
35-
ldClient.close();
36-
});
24+
describe('without caching', () => {
25+
let kv: KVNamespace;
26+
let ldClient: LDClient;
27+
28+
beforeAll(async () => {
29+
kv = (await mf.getKVNamespace(namespace)) as unknown as KVNamespace;
30+
await kv.put(rootEnvKey, JSON.stringify(allFlagsSegments));
31+
ldClient = init(clientSideID, kv);
32+
await ldClient.waitForInitialization();
33+
});
3734

38-
describe('flags', () => {
39-
test('variation default', async () => {
40-
const value = await ldClient.variation(flagKey1, context, false);
41-
expect(value).toBeTruthy();
35+
afterAll(() => {
36+
ldClient.close();
4237
});
4338

44-
test('variation default rollout', async () => {
45-
const contextWithEmail = { ...context, email: '[email protected]' };
46-
const value = await ldClient.variation(flagKey2, contextWithEmail, false);
47-
const detail = await ldClient.variationDetail(flagKey2, contextWithEmail, false);
39+
describe('flags', () => {
40+
it('variation default', async () => {
41+
const value = await ldClient.variation(flagKey1, context, false);
42+
expect(value).toBeTruthy();
43+
});
4844

49-
expect(detail).toEqual({ reason: { kind: 'FALLTHROUGH' }, value: true, variationIndex: 0 });
50-
expect(value).toBeTruthy();
51-
});
45+
it('variation default rollout', async () => {
46+
const contextWithEmail = { ...context, email: '[email protected]' };
47+
const value = await ldClient.variation(flagKey2, contextWithEmail, false);
48+
const detail = await ldClient.variationDetail(flagKey2, contextWithEmail, false);
5249

53-
test('rule match', async () => {
54-
const contextWithEmail = { ...context, email: '[email protected]' };
55-
const value = await ldClient.variation(flagKey1, contextWithEmail, false);
56-
const detail = await ldClient.variationDetail(flagKey1, contextWithEmail, false);
50+
expect(detail).toEqual({ reason: { kind: 'FALLTHROUGH' }, value: true, variationIndex: 0 });
51+
expect(value).toBeTruthy();
52+
});
5753

58-
expect(detail).toEqual({
59-
reason: { kind: 'RULE_MATCH', ruleId: 'rule1', ruleIndex: 0 },
60-
value: false,
61-
variationIndex: 1,
54+
it('rule match', async () => {
55+
const contextWithEmail = { ...context, email: '[email protected]' };
56+
const value = await ldClient.variation(flagKey1, contextWithEmail, false);
57+
const detail = await ldClient.variationDetail(flagKey1, contextWithEmail, false);
58+
59+
expect(detail).toEqual({
60+
reason: { kind: 'RULE_MATCH', ruleId: 'rule1', ruleIndex: 0 },
61+
value: false,
62+
variationIndex: 1,
63+
});
64+
expect(value).toBeFalsy();
6265
});
63-
expect(value).toBeFalsy();
64-
});
6566

66-
test('fallthrough', async () => {
67-
const contextWithEmail = { ...context, email: '[email protected]' };
68-
const value = await ldClient.variation(flagKey1, contextWithEmail, false);
69-
const detail = await ldClient.variationDetail(flagKey1, contextWithEmail, false);
67+
it('fallthrough', async () => {
68+
const contextWithEmail = { ...context, email: '[email protected]' };
69+
const value = await ldClient.variation(flagKey1, contextWithEmail, false);
70+
const detail = await ldClient.variationDetail(flagKey1, contextWithEmail, false);
7071

71-
expect(detail).toEqual({ reason: { kind: 'FALLTHROUGH' }, value: true, variationIndex: 0 });
72-
expect(value).toBeTruthy();
72+
expect(detail).toEqual({ reason: { kind: 'FALLTHROUGH' }, value: true, variationIndex: 0 });
73+
expect(value).toBeTruthy();
74+
});
75+
76+
it('allFlags fallthrough', async () => {
77+
const allFlags = await ldClient.allFlagsState(context);
78+
79+
expect(allFlags).toBeDefined();
80+
expect(allFlags.toJSON()).toEqual({
81+
$flagsState: {
82+
testFlag1: { debugEventsUntilDate: 2000, variation: 0, version: 2 },
83+
testFlag2: { debugEventsUntilDate: 2000, variation: 0, version: 2 },
84+
testFlag3: { debugEventsUntilDate: 2000, variation: 0, version: 2 },
85+
},
86+
$valid: true,
87+
testFlag1: true,
88+
testFlag2: true,
89+
testFlag3: true,
90+
});
91+
});
7392
});
7493

75-
test('allFlags fallthrough', async () => {
76-
const allFlags = await ldClient.allFlagsState(context);
77-
78-
expect(allFlags).toBeDefined();
79-
expect(allFlags.toJSON()).toEqual({
80-
$flagsState: {
81-
testFlag1: { debugEventsUntilDate: 2000, variation: 0, version: 2 },
82-
testFlag2: { debugEventsUntilDate: 2000, variation: 0, version: 2 },
83-
testFlag3: { debugEventsUntilDate: 2000, variation: 0, version: 2 },
84-
},
85-
$valid: true,
86-
testFlag1: true,
87-
testFlag2: true,
88-
testFlag3: true,
94+
describe('segments', () => {
95+
it('segment by country', async () => {
96+
const contextWithCountry = { ...context, country: 'australia' };
97+
const value = await ldClient.variation(flagKey3, contextWithCountry, false);
98+
const detail = await ldClient.variationDetail(flagKey3, contextWithCountry, false);
99+
100+
expect(detail).toEqual({
101+
reason: { kind: 'RULE_MATCH', ruleId: 'rule1', ruleIndex: 0 },
102+
value: false,
103+
variationIndex: 1,
104+
});
105+
expect(value).toBeFalsy();
89106
});
90107
});
91108
});
92109

93-
describe('segments', () => {
94-
test('segment by country', async () => {
95-
const contextWithCountry = { ...context, country: 'australia' };
96-
const value = await ldClient.variation(flagKey3, contextWithCountry, false);
97-
const detail = await ldClient.variationDetail(flagKey3, contextWithCountry, false);
110+
describe('with caching', () => {
111+
it('will cache across multiple variation calls', async () => {
112+
const kv = (await mf.getKVNamespace(namespace)) as unknown as KVNamespace;
113+
await kv.put(rootEnvKey, JSON.stringify(allFlagsSegments));
114+
const ldClient = init(clientSideID, kv, { cache: { ttl: 60, checkInterval: 600 } });
98115

99-
expect(detail).toEqual({
100-
reason: { kind: 'RULE_MATCH', ruleId: 'rule1', ruleIndex: 0 },
101-
value: false,
102-
variationIndex: 1,
103-
});
104-
expect(value).toBeFalsy();
116+
await ldClient.waitForInitialization();
117+
const spy = jest.spyOn(kv, 'get');
118+
await ldClient.variation(flagKey1, context, false);
119+
await ldClient.variation(flagKey2, context, false);
120+
ldClient.close();
121+
122+
expect(spy).toHaveBeenCalledTimes(1);
123+
});
124+
125+
it('will cache across multiple allFlags calls', async () => {
126+
const kv = (await mf.getKVNamespace(namespace)) as unknown as KVNamespace;
127+
await kv.put(rootEnvKey, JSON.stringify(allFlagsSegments));
128+
const ldClient = init(clientSideID, kv, { cache: { ttl: 60, checkInterval: 600 } });
129+
130+
await ldClient.waitForInitialization();
131+
const spy = jest.spyOn(kv, 'get');
132+
await ldClient.allFlagsState(context);
133+
await ldClient.allFlagsState(context);
134+
ldClient.close();
135+
136+
expect(spy).toHaveBeenCalledTimes(1);
137+
});
138+
139+
it('will cache between allFlags and variation', async () => {
140+
const kv = (await mf.getKVNamespace(namespace)) as unknown as KVNamespace;
141+
await kv.put(rootEnvKey, JSON.stringify(allFlagsSegments));
142+
const ldClient = init(clientSideID, kv, { cache: { ttl: 60, checkInterval: 600 } });
143+
144+
await ldClient.waitForInitialization();
145+
const spy = jest.spyOn(kv, 'get');
146+
await ldClient.variation(flagKey1, context, false);
147+
await ldClient.allFlagsState(context);
148+
ldClient.close();
149+
150+
expect(spy).toHaveBeenCalledTimes(1);
151+
});
152+
153+
it('will eventually expire', async () => {
154+
jest.spyOn(Date, 'now').mockImplementation(() => 0);
155+
156+
const kv = (await mf.getKVNamespace(namespace)) as unknown as KVNamespace;
157+
await kv.put(rootEnvKey, JSON.stringify(allFlagsSegments));
158+
const ldClient = init(clientSideID, kv, { cache: { ttl: 60, checkInterval: 600 } });
159+
160+
await ldClient.waitForInitialization();
161+
const spy = jest.spyOn(kv, 'get');
162+
await ldClient.variation(flagKey1, context, false);
163+
await ldClient.variation(flagKey2, context, false);
164+
165+
expect(spy).toHaveBeenCalledTimes(1);
166+
167+
jest.spyOn(Date, 'now').mockImplementation(() => 60 * 1000 + 1);
168+
169+
await ldClient.variation(flagKey2, context, false);
170+
expect(spy).toHaveBeenCalledTimes(2);
171+
172+
ldClient.close();
105173
});
106174
});
107175
});

packages/sdk/cloudflare/src/index.ts

+21-4
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,28 @@ import {
1414
BasicLogger,
1515
EdgeFeatureStore,
1616
init as initEdge,
17+
internalServer,
1718
type LDClient,
18-
type LDOptions,
19+
type LDOptions as LDOptionsCommon,
1920
} from '@launchdarkly/js-server-sdk-common-edge';
2021

2122
import createPlatformInfo from './createPlatformInfo';
2223

2324
export * from '@launchdarkly/js-server-sdk-common-edge';
2425

26+
export type TtlCacheOptions = internalServer.TtlCacheOptions;
27+
28+
/**
29+
* The Launchdarkly Edge SDKs configuration options.
30+
*/
31+
type LDOptions = {
32+
/**
33+
* Optional TTL cache configuration which allows for caching feature flags in
34+
* memory.
35+
*/
36+
cache?: TtlCacheOptions;
37+
} & LDOptionsCommon;
38+
2539
export type { LDClient };
2640

2741
/**
@@ -41,7 +55,7 @@ export type { LDClient };
4155
* @param kvNamespace
4256
* The Cloudflare KV configured for LaunchDarkly.
4357
* @param options
44-
* Optional configuration settings. The only supported option is logger.
58+
* Optional configuration settings.
4559
* @return
4660
* The new {@link LDClient} instance.
4761
*/
@@ -51,9 +65,12 @@ export const init = (
5165
options: LDOptions = {},
5266
): LDClient => {
5367
const logger = options.logger ?? BasicLogger.get();
68+
69+
const { cache: _cacheOptions, ...rest } = options;
70+
const cache = options.cache ? new internalServer.TtlCache(options.cache) : undefined;
5471
return initEdge(clientSideID, createPlatformInfo(), {
55-
featureStore: new EdgeFeatureStore(kvNamespace, clientSideID, 'Cloudflare', logger),
72+
featureStore: new EdgeFeatureStore(kvNamespace, clientSideID, 'Cloudflare', logger, cache),
5673
logger,
57-
...options,
74+
...rest,
5875
});
5976
};

packages/shared/sdk-server-edge/src/api/EdgeFeatureStore.ts

+41-24
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import type {
88
} from '@launchdarkly/js-server-sdk-common';
99
import { deserializePoll, noop } from '@launchdarkly/js-server-sdk-common';
1010

11+
import Cache from './cache';
12+
1113
export interface EdgeProvider {
1214
get: (rootKey: string) => Promise<string | null | undefined>;
1315
}
@@ -20,6 +22,7 @@ export class EdgeFeatureStore implements LDFeatureStore {
2022
sdkKey: string,
2123
private readonly _description: string,
2224
private _logger: LDLogger,
25+
private _cache?: Cache,
2326
) {
2427
this._rootKey = `LD-Env-${sdkKey}`;
2528
}
@@ -34,23 +37,14 @@ export class EdgeFeatureStore implements LDFeatureStore {
3437
this._logger.debug(`Requesting ${dataKey} from ${this._rootKey}.${kindKey}`);
3538

3639
try {
37-
const i = await this._edgeProvider.get(this._rootKey);
38-
39-
if (!i) {
40-
throw new Error(`${this._rootKey}.${kindKey} is not found in KV.`);
41-
}
42-
43-
const item = deserializePoll(i);
44-
if (!item) {
45-
throw new Error(`Error deserializing ${kindKey}`);
46-
}
40+
const storePayload = await this._getStorePayload();
4741

4842
switch (namespace) {
4943
case 'features':
50-
callback(item.flags[dataKey]);
44+
callback(storePayload.flags[dataKey]);
5145
break;
5246
case 'segments':
53-
callback(item.segments[dataKey]);
47+
callback(storePayload.segments[dataKey]);
5448
break;
5549
default:
5650
callback(null);
@@ -66,22 +60,14 @@ export class EdgeFeatureStore implements LDFeatureStore {
6660
const kindKey = namespace === 'features' ? 'flags' : namespace;
6761
this._logger.debug(`Requesting all from ${this._rootKey}.${kindKey}`);
6862
try {
69-
const i = await this._edgeProvider.get(this._rootKey);
70-
if (!i) {
71-
throw new Error(`${this._rootKey}.${kindKey} is not found in KV.`);
72-
}
73-
74-
const item = deserializePoll(i);
75-
if (!item) {
76-
throw new Error(`Error deserializing ${kindKey}`);
77-
}
63+
const storePayload = await this._getStorePayload();
7864

7965
switch (namespace) {
8066
case 'features':
81-
callback(item.flags);
67+
callback(storePayload.flags);
8268
break;
8369
case 'segments':
84-
callback(item.segments);
70+
callback(storePayload.segments);
8571
break;
8672
default:
8773
callback({});
@@ -92,6 +78,34 @@ export class EdgeFeatureStore implements LDFeatureStore {
9278
}
9379
}
9480

81+
/**
82+
* This method is used to retrieve the environment payload from the edge
83+
* provider. If a cache is provided, it will serve from that.
84+
*/
85+
private async _getStorePayload(): Promise<
86+
Exclude<ReturnType<typeof deserializePoll>, undefined>
87+
> {
88+
let payload = this._cache?.get(this._rootKey);
89+
if (payload !== undefined) {
90+
return payload;
91+
}
92+
93+
const providerData = await this._edgeProvider.get(this._rootKey);
94+
95+
if (!providerData) {
96+
throw new Error(`${this._rootKey} is not found in KV.`);
97+
}
98+
99+
payload = deserializePoll(providerData);
100+
if (!payload) {
101+
throw new Error(`Error deserializing ${this._rootKey}`);
102+
}
103+
104+
this._cache?.set(this._rootKey, payload);
105+
106+
return payload;
107+
}
108+
95109
async initialized(callback: (isInitialized: boolean) => void = noop): Promise<void> {
96110
const config = await this._edgeProvider.get(this._rootKey);
97111
const result = config !== null;
@@ -107,8 +121,11 @@ export class EdgeFeatureStore implements LDFeatureStore {
107121
return this._description;
108122
}
109123

124+
close(): void {
125+
return this._cache?.close();
126+
}
127+
110128
// unused
111-
close = noop;
112129

113130
delete = noop;
114131

0 commit comments

Comments
 (0)