Skip to content

Commit 97339d9

Browse files
authored
feat(parameters): AppConfigProvider to return the last valid value when the API returns empty value on subsequent calls (#1365)
* Closes 1363 Add a local cache to AppConfigProvider to handle the scenario where the maxAge is expired, but the AppConfig session still has the latest configuration. In this case, AppConfig returns an empty value. We don't want to return empty values to our callers. This uses the same data structure we have in place to track NextPollConfigurationToken. This approach roughly the Python library's behavior. * Add E2E test coverage checking return values pre- and post-expiration Includes update to e2e lambda config type that allows test writers to specify a test function duration. Needed because to wait for parameter expiration, we needed a longer-than-default timeout. * Use timestamp/millisecond based math for checking expiration. * Revert "Use timestamp/millisecond based math for checking expiration." This reverts commit 3fea0d7. Further testing shows that the original algorithm was correct. * Update appConfig integration tests to use single log line. Drop wait, use maxAge instead. Add comment for Test case 7. Fix test case naming (test '4' was skipped on one of the files). * Revert increase of Lambda function duration back to default. Not needed anymore since we're not waiting for Test 7. * Focus value test integration tests on Test 7 * Comma formatting * Add unit tests for ExpirableValue Doesn't refactor ExpirableValue to take an injection of Date.now, just implements tests based on what we can do with the existing interface. * Address formatting feedback and updates comments to indicate we're no longer waiting during Test 7 runs. * Adjust log message structure to match pattern Specfically test/value as top-level properties. * Move client reset to afterEach for consistency. * Drop old reset
1 parent ce50659 commit 97339d9

File tree

5 files changed

+176
-7
lines changed

5 files changed

+176
-7
lines changed

Diff for: packages/commons/tests/utils/e2eUtils.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* E2E utils is used by e2e tests. They are helper function that calls either CDK or SDK
66
* to interact with services.
77
*/
8-
import { App, CfnOutput, Stack } from 'aws-cdk-lib';
8+
import { App, CfnOutput, Stack, Duration } from 'aws-cdk-lib';
99
import {
1010
NodejsFunction,
1111
NodejsFunctionProps
@@ -37,6 +37,7 @@ export type StackWithLambdaFunctionOptions = {
3737
runtime: string
3838
bundling?: NodejsFunctionProps['bundling']
3939
layers?: NodejsFunctionProps['layers']
40+
timeout?: Duration
4041
};
4142

4243
type FunctionPayload = {[key: string]: string | boolean | number};
@@ -55,6 +56,7 @@ export const createStackWithLambdaFunction = (params: StackWithLambdaFunctionOpt
5556
bundling: params.bundling,
5657
layers: params.layers,
5758
logRetention: RetentionDays.ONE_DAY,
59+
timeout: params.timeout
5860
});
5961

6062
if (params.logGroupOutputKey) {

Diff for: packages/parameters/src/appconfig/AppConfigProvider.ts

+18-2
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
class AppConfigProvider extends BaseProvider {
1414
public client: AppConfigDataClient;
1515
protected configurationTokenStore: Map<string, string> = new Map();
16+
protected valueStore: Map<string, Uint8Array> = new Map();
1617
private application?: string;
1718
private environment: string;
1819

@@ -79,6 +80,10 @@ class AppConfigProvider extends BaseProvider {
7980
* The new AppConfig APIs require two API calls to return the configuration
8081
* First we start the session and after that we retrieve the configuration
8182
* We need to store { name: token } pairs to use in the next execution
83+
* We also need to store { name : value } pairs because AppConfig returns
84+
* an empty value if the session already has the latest configuration
85+
* but, we don't want to return an empty value to our callers.
86+
* {@link https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-retrieving-the-configuration.html}
8287
**/
8388

8489
if (!this.configurationTokenStore.has(name)) {
@@ -106,14 +111,25 @@ class AppConfigProvider extends BaseProvider {
106111
});
107112

108113
const response = await this.client.send(getConfigurationCommand);
109-
114+
110115
if (response.NextPollConfigurationToken) {
111116
this.configurationTokenStore.set(name, response.NextPollConfigurationToken);
112117
} else {
113118
this.configurationTokenStore.delete(name);
114119
}
115120

116-
return response.Configuration;
121+
/** When the response is not empty, stash the result locally before returning
122+
* See AppConfig docs:
123+
* {@link https://docs.aws.amazon.com/appconfig/latest/userguide/appconfig-retrieving-the-configuration.html}
124+
**/
125+
if (response.Configuration !== undefined && response.Configuration?.length > 0 ) {
126+
this.valueStore.set(name, response.Configuration);
127+
128+
return response.Configuration;
129+
}
130+
131+
// Otherwise, use a stashed value
132+
return this.valueStore.get(name);
117133
}
118134

119135
/**

Diff for: packages/parameters/tests/e2e/appConfigProvider.class.test.functionCode.ts

+34-3
Original file line numberDiff line numberDiff line change
@@ -73,10 +73,10 @@ export const handler = async (_event: unknown, _context: Context): Promise<void>
7373
// Test 3 - get a free-form base64-encoded plain text and apply binary transformation (should return a decoded string)
7474
await _call_get(freeFormBase64encodedPlainText, 'get-freeform-base64-plaintext-binary', { transform: 'binary' });
7575

76-
// Test 5 - get a feature flag and apply json transformation (should return an object)
76+
// Test 4 - get a feature flag and apply json transformation (should return an object)
7777
await _call_get(featureFlagName, 'get-feature-flag-binary', { transform: 'json' });
7878

79-
// Test 6
79+
// Test 5
8080
// get parameter twice with middleware, which counts the number of requests, we check later if we only called AppConfig API once
8181
try {
8282
providerWithMiddleware.clearCache();
@@ -94,7 +94,7 @@ export const handler = async (_event: unknown, _context: Context): Promise<void>
9494
});
9595
}
9696

97-
// Test 7
97+
// Test 6
9898
// get parameter twice, but force fetch 2nd time, we count number of SDK requests and check that we made two API calls
9999
try {
100100
providerWithMiddleware.clearCache();
@@ -111,4 +111,35 @@ export const handler = async (_event: unknown, _context: Context): Promise<void>
111111
error: err.message
112112
});
113113
}
114+
// Test 7
115+
// get parameter twice, using maxAge to avoid primary cache, count SDK calls and return values
116+
try {
117+
providerWithMiddleware.clearCache();
118+
middleware.counter = 0;
119+
const expiredResult1 = await providerWithMiddleware.get(
120+
freeFormBase64encodedPlainText, {
121+
maxAge: 0,
122+
transform: 'base64'
123+
}
124+
);
125+
const expiredResult2 = await providerWithMiddleware.get(
126+
freeFormBase64encodedPlainText, {
127+
maxAge: 0,
128+
transform: 'base64'
129+
}
130+
);
131+
logger.log({
132+
test: 'get-expired',
133+
value: {
134+
counter: middleware.counter, // should be 2
135+
result1: expiredResult1,
136+
result2: expiredResult2
137+
},
138+
});
139+
} catch (err) {
140+
logger.log({
141+
test: 'get-expired',
142+
error: err.message
143+
});
144+
}
114145
};

Diff for: packages/parameters/tests/e2e/appConfigProvider.class.test.ts

+28-1
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ const freeFormJsonValue = {
5454
const freeFormYamlValue = `foo: bar
5555
`;
5656
const freeFormPlainTextValue = 'foo';
57+
const freeFormBase64PlainTextValue = toBase64(new TextEncoder().encode(freeFormPlainTextValue));
5758
const featureFlagValue = {
5859
version: '1',
5960
flags: {
@@ -111,6 +112,12 @@ let stack: Stack;
111112
* Test 6
112113
* get parameter twice, but force fetch 2nd time, we count number of SDK requests and
113114
* check that we made two API calls
115+
* check that we got matching results
116+
*
117+
* Test 7
118+
* get parameter twice, using maxAge to avoid primary cache
119+
* we count number of SDK requests and check that we made two API calls
120+
* and check that the values match
114121
*
115122
* Note: To avoid race conditions, we add a dependency between each pair of configuration profiles.
116123
* This allows us to influence the order of creation and ensure that each configuration profile
@@ -191,7 +198,7 @@ describe(`parameters E2E tests (appConfigProvider) for runtime ${runtime}`, () =
191198
name: freeFormBase64PlainTextName,
192199
type: 'AWS.Freeform',
193200
content: {
194-
content: toBase64(new TextEncoder().encode(freeFormPlainTextValue)),
201+
content: freeFormBase64PlainTextValue,
195202
contentType: 'text/plain',
196203
}
197204
});
@@ -314,6 +321,26 @@ describe(`parameters E2E tests (appConfigProvider) for runtime ${runtime}`, () =
314321

315322
}, TEST_CASE_TIMEOUT);
316323

324+
// Test 7 - get parameter twice, using maxAge to avoid primary cache
325+
// we count number of SDK requests and check that we made two API calls
326+
// and check that the values match
327+
it('should retrieve single parameter twice, with expiration between and matching values', async () => {
328+
329+
const logs = invocationLogs[0].getFunctionLogs();
330+
const testLog = InvocationLogs.parseFunctionLog(logs[6]);
331+
const result = freeFormBase64PlainTextValue;
332+
333+
expect(testLog).toStrictEqual({
334+
test: 'get-expired',
335+
value: {
336+
counter: 2,
337+
result1: result,
338+
result2: result
339+
}
340+
});
341+
342+
}, TEST_CASE_TIMEOUT);
343+
317344
});
318345

319346
afterAll(async () => {

Diff for: packages/parameters/tests/unit/AppConfigProvider.test.ts

+93
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
* @group unit/parameters/AppConfigProvider/class
55
*/
66
import { AppConfigProvider } from '../../src/appconfig/index';
7+
import { ExpirableValue } from '../../src/ExpirableValue';
78
import { AppConfigProviderOptions } from '../../src/types/AppConfigProvider';
89
import {
910
AppConfigDataClient,
@@ -21,6 +22,10 @@ describe('Class: AppConfigProvider', () => {
2122
jest.clearAllMocks();
2223
});
2324

25+
afterEach(() => {
26+
client.reset();
27+
});
28+
2429
describe('Method: constructor', () => {
2530
test('when the class instantiates without SDK client and client config it has default options', async () => {
2631
// Prepare
@@ -225,6 +230,49 @@ describe('Class: AppConfigProvider', () => {
225230
// Act & Assess
226231
await expect(provider.get(name)).rejects.toThrow();
227232
});
233+
234+
test('when session returns an empty configuration on the second call, it returns the last value', async () => {
235+
236+
// Prepare
237+
const options: AppConfigProviderOptions = {
238+
application: 'MyApp',
239+
environment: 'MyAppProdEnv',
240+
};
241+
const provider = new AppConfigProvider(options);
242+
const name = 'MyAppFeatureFlag';
243+
244+
const fakeInitialToken = 'aW5pdGlhbFRva2Vu';
245+
const fakeNextToken1 = 'bmV4dFRva2Vu';
246+
const fakeNextToken2 = 'bmV4dFRva2Vq';
247+
const mockData = encoder.encode('myAppConfiguration');
248+
249+
client
250+
.on(StartConfigurationSessionCommand)
251+
.resolves({
252+
InitialConfigurationToken: fakeInitialToken,
253+
})
254+
.on(GetLatestConfigurationCommand)
255+
.resolvesOnce({
256+
Configuration: mockData,
257+
NextPollConfigurationToken: fakeNextToken1,
258+
})
259+
.resolvesOnce({
260+
Configuration: undefined,
261+
NextPollConfigurationToken: fakeNextToken2,
262+
});
263+
264+
// Act
265+
266+
// Load local cache
267+
const result1 = await provider.get(name, { forceFetch: true });
268+
269+
// Read from local cache, given empty response from service
270+
const result2 = await provider.get(name, { forceFetch: true });
271+
272+
// Assess
273+
expect(result1).toBe(mockData);
274+
expect(result2).toBe(mockData);
275+
});
228276
});
229277

230278
describe('Method: _getMultiple', () => {
@@ -243,3 +291,48 @@ describe('Class: AppConfigProvider', () => {
243291
});
244292
});
245293
});
294+
295+
describe('Class: ExpirableValue', () => {
296+
beforeEach(() => {
297+
jest.clearAllMocks();
298+
});
299+
300+
describe('Method: constructor', () => {
301+
test('when created, it has ttl set to at least maxAge seconds from test start', () => {
302+
// Prepare
303+
const seconds = 10;
304+
const nowTimestamp = Date.now();
305+
const futureTimestampSeconds = nowTimestamp/1000+(seconds);
306+
307+
// Act
308+
const expirableValue = new ExpirableValue('foo', seconds);
309+
310+
// Assess
311+
expect(expirableValue.ttl).toBeGreaterThan(futureTimestampSeconds);
312+
});
313+
});
314+
315+
describe('Method: isExpired', () => {
316+
test('when called, it returns true when maxAge is in the future', () => {
317+
// Prepare
318+
const seconds = 60;
319+
320+
// Act
321+
const expirableValue = new ExpirableValue('foo', seconds);
322+
323+
// Assess
324+
expect(expirableValue.isExpired()).toBeFalsy();
325+
});
326+
327+
test('when called, it returns false when maxAge is in the past', () => {
328+
// Prepare
329+
const seconds = -60;
330+
331+
// Act
332+
const expirableValue = new ExpirableValue('foo', seconds);
333+
334+
// Assess
335+
expect(expirableValue.isExpired()).toBeTruthy();
336+
});
337+
});
338+
});

0 commit comments

Comments
 (0)