Skip to content

Commit 2faf1ea

Browse files
VIA-537 AS/SB Refactor content hydrator lambda, remove configProvider
1 parent 2d35652 commit 2faf1ea

File tree

10 files changed

+78
-93
lines changed

10 files changed

+78
-93
lines changed

src/_lambda/content-cache-hydrator/content-cache-reader.test.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import {
55
import { VaccineInfo, VaccineType } from "@src/models/vaccine";
66
import { readContentFromCache } from "@src/services/content-api/gateway/content-reader-service";
77
import { InvalidatedCacheError, S3NoSuchKeyError } from "@src/services/content-api/gateway/exceptions";
8-
import { AppConfig, configProvider } from "@src/utils/config";
8+
import lazyConfig from "@src/utils/lazy-config";
9+
import { AsyncConfigMock, lazyConfigBuilder } from "@test-data/config/builders";
910

10-
jest.mock("@src/utils/config");
1111
jest.mock("@src/services/content-api/gateway/content-reader-service");
1212
jest.mock("sanitize-data", () => ({ sanitize: jest.fn() }));
1313

@@ -16,13 +16,11 @@ const mockContentCachePath = "wiremock/__files/";
1616
describe("readCachedContentForVaccine", () => {
1717
const mockCacheFileContents = "mock-cache-file-contents";
1818
const vaccineType = VaccineType.RSV;
19+
const mockedConfig = lazyConfig as AsyncConfigMock;
1920

2021
beforeEach(() => {
21-
(configProvider as jest.Mock).mockImplementation(
22-
(): Partial<AppConfig> => ({
23-
CONTENT_CACHE_PATH: mockContentCachePath,
24-
}),
25-
);
22+
const defaultConfig = lazyConfigBuilder().withContentCachePath(mockContentCachePath).build();
23+
Object.assign(mockedConfig, defaultConfig);
2624
(readContentFromCache as jest.Mock).mockImplementation((): string => mockCacheFileContents);
2725
});
2826

src/_lambda/content-cache-hydrator/content-cache-reader.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { Filename, VaccineInfo, VaccineType } from "@src/models/vaccine";
22
import { readContentFromCache } from "@src/services/content-api/gateway/content-reader-service";
33
import { InvalidatedCacheError, S3NoSuchKeyError } from "@src/services/content-api/gateway/exceptions";
4-
import { AppConfig, configProvider } from "@src/utils/config";
4+
import lazyConfig from "@src/utils/lazy-config";
55
import { logger } from "@src/utils/logger";
66

77
const log = logger.child({ module: "content-cache-reader" });
@@ -12,12 +12,15 @@ export interface ReadCachedContentResult {
1212
}
1313

1414
const readCachedContentForVaccine = async (vaccineType: VaccineType): Promise<ReadCachedContentResult> => {
15-
const config: AppConfig = await configProvider();
1615
const cacheFilename: Filename = VaccineInfo[vaccineType].cacheFilename;
1716
let cachedContent: string;
1817

1918
try {
20-
cachedContent = await readContentFromCache(config.CONTENT_CACHE_PATH, cacheFilename, vaccineType);
19+
cachedContent = await readContentFromCache(
20+
(await lazyConfig.CONTENT_CACHE_PATH) as string,
21+
cacheFilename,
22+
vaccineType,
23+
);
2124
} catch (error) {
2225
if (error instanceof S3NoSuchKeyError) {
2326
return { cacheStatus: "empty", cacheContent: "" };

src/_lambda/content-cache-hydrator/content-fetcher.test.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,24 @@
11
import { CONTENT_API_PATH_PREFIX, fetchContentForVaccine } from "@src/_lambda/content-cache-hydrator/content-fetcher";
22
import { VaccineInfo, VaccineType } from "@src/models/vaccine";
3-
import { AppConfig, configProvider } from "@src/utils/config";
3+
import lazyConfig from "@src/utils/lazy-config";
4+
import { AsyncConfigMock, lazyConfigBuilder } from "@test-data/config/builders";
45
import axios from "axios";
56

6-
jest.mock("@src/utils/config");
77
jest.mock("axios");
88
jest.mock("sanitize-data", () => ({ sanitize: jest.fn() }));
99

1010
describe("fetchContentForVaccine", () => {
1111
const testApiKey: string = "test-key";
1212
const testApiEndpoint: URL = new URL("https://test-endpoint/");
1313
const testApiContent = { test: "content" };
14+
const mockedConfig = lazyConfig as AsyncConfigMock;
1415

1516
beforeEach(() => {
16-
(configProvider as jest.Mock).mockImplementation(
17-
(): Partial<AppConfig> => ({
18-
CONTENT_CACHE_PATH: "",
19-
CONTENT_API_KEY: testApiKey,
20-
CONTENT_API_ENDPOINT: testApiEndpoint,
21-
}),
22-
);
17+
const defaultConfig = lazyConfigBuilder()
18+
.withContentApiKey(testApiKey)
19+
.andContentApiEndpoint(testApiEndpoint)
20+
.build();
21+
Object.assign(mockedConfig, defaultConfig);
2322
});
2423

2524
it("should fetch content for vaccine", async () => {

src/_lambda/content-cache-hydrator/content-fetcher.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
11
import { VaccineInfo, VaccineType } from "@src/models/vaccine";
2-
import { AppConfig, configProvider } from "@src/utils/config";
2+
import lazyConfig from "@src/utils/lazy-config";
33
import { logger } from "@src/utils/logger";
44
import axios, { AxiosError, AxiosResponse } from "axios";
55

66
const log = logger.child({ module: "content-fetcher" });
77
const CONTENT_API_PATH_PREFIX = "nhs-website-content/";
88

99
const fetchContentForVaccine = async (vaccineType: VaccineType): Promise<string> => {
10-
const config: AppConfig = await configProvider();
11-
12-
const apiEndpoint: URL = config.CONTENT_API_ENDPOINT;
10+
const apiEndpoint: URL = (await lazyConfig.CONTENT_API_ENDPOINT) as URL;
1311
const vaccinePath = VaccineInfo[vaccineType].contentPath;
14-
const apiKey: string = config.CONTENT_API_KEY;
12+
const apiKey: string = (await lazyConfig.CONTENT_API_KEY) as string;
1513

1614
const uri: string = `${apiEndpoint}${CONTENT_API_PATH_PREFIX}${vaccinePath}`;
1715
let response: AxiosResponse;

src/_lambda/content-cache-hydrator/content-writer-service.test.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ import {
88
writeContentForVaccine,
99
} from "@src/_lambda/content-cache-hydrator/content-writer-service";
1010
import { Filename, VaccineInfo, VaccineType } from "@src/models/vaccine";
11-
import { configProvider } from "@src/utils/config";
11+
import lazyConfig from "@src/utils/lazy-config";
12+
import { AsyncConfigMock, lazyConfigBuilder } from "@test-data/config/builders";
1213
import { writeFile } from "node:fs/promises";
1314

14-
jest.mock("@src/utils/config");
1515
jest.mock("node:fs/promises");
1616

1717
let mockSend: jest.Mock = jest.fn();
@@ -29,9 +29,12 @@ describe("Content Writer Service", () => {
2929
const location: string = "test-location/";
3030
const path: Filename = "test-filename.json" as Filename;
3131
const content: string = "test-data";
32+
const mockedConfig = lazyConfig as AsyncConfigMock;
3233

3334
beforeEach(() => {
3435
mockSend = jest.fn();
36+
const defaultConfig = lazyConfigBuilder().withContentCachePath(location).build();
37+
Object.assign(mockedConfig, defaultConfig);
3538
});
3639

3740
describe("_writeFileS3", () => {
@@ -74,10 +77,6 @@ describe("Content Writer Service", () => {
7477
});
7578

7679
describe("writeContentForVaccine()", () => {
77-
(configProvider as jest.Mock).mockImplementation(() => ({
78-
CONTENT_CACHE_PATH: location,
79-
}));
80-
8180
it("should return response for rsv vaccine from content cache", async () => {
8281
const vaccine: VaccineType = VaccineType.RSV;
8382
await writeContentForVaccine(vaccine, content);

src/_lambda/content-cache-hydrator/content-writer-service.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
22
import { Filename, VaccineInfo, VaccineType } from "@src/models/vaccine";
3-
import { AppConfig, configProvider } from "@src/utils/config";
43
import { AWS_PRIMARY_REGION } from "@src/utils/constants";
4+
import lazyConfig from "@src/utils/lazy-config";
55
import { logger } from "@src/utils/logger";
66
import { S3_PREFIX, isS3Path } from "@src/utils/path";
77
import { writeFile } from "node:fs/promises";
@@ -38,9 +38,8 @@ const _writeContentToCache = async (
3838
};
3939

4040
const writeContentForVaccine = async (vaccineType: VaccineType, vaccineContent: string) => {
41-
const config: AppConfig = await configProvider();
4241
const cacheFilename = VaccineInfo[vaccineType].cacheFilename;
43-
await _writeContentToCache(config.CONTENT_CACHE_PATH, cacheFilename, vaccineContent);
42+
await _writeContentToCache((await lazyConfig.CONTENT_CACHE_PATH) as string, cacheFilename, vaccineContent);
4443
};
4544

4645
export { _writeFileS3, _writeContentToCache, writeContentForVaccine };

src/_lambda/content-cache-hydrator/handler.test.ts

Lines changed: 11 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,16 @@ import {
55
import { vitaContentChangedSinceLastApproved } from "@src/_lambda/content-cache-hydrator/content-change-detector";
66
import { fetchContentForVaccine } from "@src/_lambda/content-cache-hydrator/content-fetcher";
77
import { writeContentForVaccine } from "@src/_lambda/content-cache-hydrator/content-writer-service";
8-
import { _getConfigsThatThrowsOnColdStarts, handler } from "@src/_lambda/content-cache-hydrator/handler";
8+
import { handler } from "@src/_lambda/content-cache-hydrator/handler";
99
import { invalidateCacheForVaccine } from "@src/_lambda/content-cache-hydrator/invalidate-cache";
1010
import { VaccineType } from "@src/models/vaccine";
1111
import { getFilteredContentForVaccine } from "@src/services/content-api/parsers/content-filter-service";
1212
import { getStyledContentForVaccine } from "@src/services/content-api/parsers/content-styling-service";
13-
import { configProvider } from "@src/utils/config";
13+
import lazyConfig from "@src/utils/lazy-config";
1414
import { RequestContext, asyncLocalStorage } from "@src/utils/requestContext";
15+
import { AsyncConfigMock, lazyConfigBuilder } from "@test-data/config/builders";
1516
import { Context } from "aws-lambda";
1617

17-
jest.mock("@src/utils/config");
1818
jest.mock("@src/_lambda/content-cache-hydrator/content-writer-service");
1919
jest.mock("@src/_lambda/content-cache-hydrator/content-fetcher");
2020
jest.mock("@src/_lambda/content-cache-hydrator/content-cache-reader");
@@ -52,28 +52,12 @@ describe("Lambda Handler", () => {
5252
(writeContentForVaccine as jest.Mock).mockResolvedValue(undefined);
5353
});
5454

55-
describe("when config provider is flaky on cold starts", () => {
56-
it("tries 3 times and throws when configProvider fails", async () => {
57-
(configProvider as jest.Mock).mockRejectedValue(undefined);
58-
59-
await expect(_getConfigsThatThrowsOnColdStarts(0)).rejects.toThrow("Failed to get configs");
60-
61-
expect(configProvider).toHaveBeenCalledTimes(3);
62-
});
63-
it("returns configs when configProvider succeeds", async () => {
64-
const testConfig = { test: "test" };
65-
(configProvider as jest.Mock).mockResolvedValue(testConfig);
66-
67-
const configs = await _getConfigsThatThrowsOnColdStarts(0);
68-
expect(configs).toBe(testConfig);
69-
70-
expect(configProvider).toHaveBeenCalledTimes(1);
71-
});
72-
});
73-
7455
describe("when content-change-approval-needed feature disabled", () => {
56+
const mockedConfig = lazyConfig as AsyncConfigMock;
57+
7558
beforeEach(() => {
76-
mockConfigProviderWithChangeApprovalSetTo(false);
59+
const defaultConfig = lazyConfigBuilder().withContentCacheIsChangeApprovalEnabled(false).build();
60+
Object.assign(mockedConfig, defaultConfig);
7761
});
7862

7963
it("saves new vaccine content when cache was empty", async () => {
@@ -116,8 +100,11 @@ describe("Lambda Handler", () => {
116100
});
117101

118102
describe("when content-change-approval-needed feature enabled", () => {
103+
const mockedConfig = lazyConfig as AsyncConfigMock;
104+
119105
beforeEach(() => {
120-
mockConfigProviderWithChangeApprovalSetTo(true);
106+
const defaultConfig = lazyConfigBuilder().withContentCacheIsChangeApprovalEnabled(true).build();
107+
Object.assign(mockedConfig, defaultConfig);
121108
});
122109

123110
it("overwrites invalidated cache with new updated content when forceUpdate is true in inbound event", async () => {
@@ -230,12 +217,6 @@ describe("Lambda Handler", () => {
230217
});
231218
});
232219

233-
const mockConfigProviderWithChangeApprovalSetTo = (changeApprovalEnabled: boolean) => {
234-
(configProvider as jest.Mock).mockImplementation(() => ({
235-
CONTENT_CACHE_IS_CHANGE_APPROVAL_ENABLED: changeApprovalEnabled,
236-
}));
237-
};
238-
239220
const mockReadCachedContentForVaccineWith = (mockInvalidatedCacheReadResult: ReadCachedContentResult) => {
240221
(readCachedContentForVaccine as jest.Mock).mockResolvedValue(mockInvalidatedCacheReadResult);
241222
};

src/_lambda/content-cache-hydrator/handler.ts

Lines changed: 6 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { VaccineType } from "@src/models/vaccine";
77
import { getFilteredContentForVaccine } from "@src/services/content-api/parsers/content-filter-service";
88
import { getStyledContentForVaccine } from "@src/services/content-api/parsers/content-styling-service";
99
import { VaccinePageContent } from "@src/services/content-api/types";
10-
import { AppConfig, configProvider } from "@src/utils/config";
10+
import lazyConfig from "@src/utils/lazy-config";
1111
import { logger } from "@src/utils/logger";
1212
import { getVaccineTypeFromLowercaseString } from "@src/utils/path";
1313
import { RequestContext, asyncLocalStorage } from "@src/utils/requestContext";
@@ -117,29 +117,6 @@ interface ContentCacheHydratorEvent {
117117
vaccineToUpdate?: string;
118118
}
119119

120-
// currently whenever the cache hydrator lambda has a cold start (initialises)
121-
// the call to SSM fails with HTTP 400: Bad Request. It succeeds when lambda retries after 1min
122-
const _getConfigsThatThrowsOnColdStarts = async (waitBetweenTriesMillis: number = 5000): Promise<AppConfig> => {
123-
const MaxTries: number = 3;
124-
let tryCount: number = 1;
125-
126-
while (tryCount <= MaxTries) {
127-
try {
128-
return await configProvider();
129-
} catch (error) {
130-
log.warn(
131-
{
132-
error: error instanceof Error ? { message: error.message } : error,
133-
},
134-
`Failed to get configs. Sleeping for ${waitBetweenTriesMillis}ms and trying again. Tried ${tryCount}/${MaxTries}`,
135-
);
136-
await new Promise((resolve) => setTimeout(resolve, waitBetweenTriesMillis));
137-
}
138-
tryCount++;
139-
}
140-
throw new Error("Failed to get configs");
141-
};
142-
143120
// Ref: https://nhsd-confluence.digital.nhs.uk/spaces/Vacc/pages/1113364124/Caching+strategy+for+content+from+NHS.uk+content+API
144121
const runContentCacheHydrator = async (event: ContentCacheHydratorEvent) => {
145122
log.info({ context: { event } }, "Received event, hydrating content cache.");
@@ -167,13 +144,15 @@ const runContentCacheHydrator = async (event: ContentCacheHydratorEvent) => {
167144
);
168145
}
169146

170-
const config: AppConfig = await _getConfigsThatThrowsOnColdStarts();
171-
172147
let failureCount: number = 0;
173148
let invalidatedCount: number = 0;
174149

175150
for (const vaccine of vaccinesToRunOn) {
176-
const status = await hydrateCacheForVaccine(vaccine, config.CONTENT_CACHE_IS_CHANGE_APPROVAL_ENABLED, forceUpdate);
151+
const status = await hydrateCacheForVaccine(
152+
vaccine,
153+
(await lazyConfig.CONTENT_CACHE_IS_CHANGE_APPROVAL_ENABLED) as boolean,
154+
forceUpdate,
155+
);
177156
invalidatedCount += status.invalidatedCount;
178157
failureCount += status.failureCount;
179158
}
@@ -195,5 +174,3 @@ export const handler = async (event: object, context: Context): Promise<void> =>
195174

196175
await asyncLocalStorage.run(requestContext, () => runContentCacheHydrator(event));
197176
};
198-
199-
export { _getConfigsThatThrowsOnColdStarts };

src/utils/lazy-config.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,14 +115,18 @@ class LazyConfig {
115115

116116
private async getFromEnvironmentOrSSM(key: string): Promise<string> {
117117
let value = process.env[key];
118+
const initialDelayMillis = 100;
118119

119120
if (value === undefined || value === null) {
120121
const ssmPrefix = await this.getSsmPrefix();
121122

122123
log.debug({ context: { key, ssmPrefix } }, "getting from SSM");
124+
// Get value from SSM, on failure retry won 100ms initially, with retry delays doubling each time
125+
// 100ms -> 200ms -> 400ms etc
126+
// Total ~ 100s
123127
value = await retry(() => getSSMParam(`${ssmPrefix}${key}`), {
124128
retries: 10,
125-
delay: (attempt) => 100 * Math.pow(2, attempt - 1),
129+
delay: (attempt) => initialDelayMillis * Math.pow(2, attempt - 1),
126130
});
127131
}
128132

test-data/config/builders.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,33 @@ class LazyConfigBuilder {
120120
return this;
121121
}
122122

123+
public withContentApiKey(value: string): this {
124+
this._configValues.CONTENT_API_KEY = value;
125+
return this;
126+
}
127+
128+
public andContentApiKey(value: string): this {
129+
return this.withContentApiKey(value);
130+
}
131+
132+
public withContentApiEndpoint(value: URL): this {
133+
this._configValues.CONTENT_API_ENDPOINT = value;
134+
return this;
135+
}
136+
137+
public andContentApiEndpoint(value: URL): this {
138+
return this.withContentApiEndpoint(value);
139+
}
140+
141+
public withContentCacheIsChangeApprovalEnabled(value: boolean): this {
142+
this._configValues.CONTENT_CACHE_IS_CHANGE_APPROVAL_ENABLED = value;
143+
return this;
144+
}
145+
146+
public andContentCacheIsChangeApprovalEnabled(value: boolean): this {
147+
return this.withContentCacheIsChangeApprovalEnabled(value);
148+
}
149+
123150
public withEligibilityApiEndpoint(value: URL): this {
124151
this._configValues.ELIGIBILITY_API_ENDPOINT = value;
125152
return this;

0 commit comments

Comments
 (0)