generated from NHSDigital/repository-template
-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathhandler.ts
More file actions
211 lines (185 loc) · 7.55 KB
/
handler.ts
File metadata and controls
211 lines (185 loc) · 7.55 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
import { readCachedContentForVaccine } from "@src/_lambda/content-cache-hydrator/content-cache-reader";
import { vitaContentChangedSinceLastApproved } from "@src/_lambda/content-cache-hydrator/content-change-detector";
import { fetchContentForVaccine } from "@src/_lambda/content-cache-hydrator/content-fetcher";
import { writeContentForVaccine } from "@src/_lambda/content-cache-hydrator/content-writer-service";
import { invalidateCacheForVaccine } from "@src/_lambda/content-cache-hydrator/invalidate-cache";
import { VaccineType } from "@src/models/vaccine";
import { getFilteredContentForVaccine } from "@src/services/content-api/parsers/content-filter-service";
import { getStyledContentForVaccine } from "@src/services/content-api/parsers/content-styling-service";
import { VaccinePageContent } from "@src/services/content-api/types";
import config from "@src/utils/config";
import { logger } from "@src/utils/logger";
import { getVaccineTypeFromLowercaseString } from "@src/utils/path";
import { RequestContext, asyncLocalStorage } from "@src/utils/requestContext";
import { Context } from "aws-lambda";
import { delay, retry } from "es-toolkit";
const log = logger.child({ module: "content-cache-hydrator" });
const checkContentPassesStylingAndWriteToCache = async (
vaccineType: VaccineType,
content: string,
filteredContent: VaccinePageContent,
): Promise<void> => {
try {
await getStyledContentForVaccine(filteredContent);
await writeContentForVaccine(vaccineType, content);
log.info({ context: { vaccineType } }, `Content written to cache for vaccine ${vaccineType} `);
} catch (error) {
log.error(
{
context: {
vaccineType,
contentLength: content.length,
},
},
"Vaccine content either failed styling check or encountered write error",
);
throw error;
}
};
interface HydrateCacheStatus {
invalidatedCount: number;
failureCount: number;
}
async function hydrateCacheForVaccine(
vaccineType: VaccineType,
approvalEnabled: boolean,
forceUpdate: boolean,
): Promise<HydrateCacheStatus> {
log.info({ context: { vaccineType } }, "Hydrating cache for given vaccine");
const status: HydrateCacheStatus = { invalidatedCount: 0, failureCount: 0 };
const rateLimitDelayMillis: number = 1000 / ((await config.CONTENT_API_RATE_LIMIT_PER_MINUTE) / 60);
const rateLimitDelayWithMargin: number = 2 * rateLimitDelayMillis; // to keep ourselves well within the budget
try {
const content: string = await retry(async () => fetchContentForVaccine(vaccineType), {
retries: 5,
delay: (attempt: number) => {
const delayMillis = rateLimitDelayWithMargin * Math.pow(2, attempt + 1);
log.warn(
{ context: { vaccineType, attempt, delayMillis } },
"Failed to fetch content for given vaccine, trying again",
);
return delayMillis;
},
});
const filteredContent: VaccinePageContent = await getFilteredContentForVaccine(vaccineType, content);
if (!approvalEnabled) {
await checkContentPassesStylingAndWriteToCache(vaccineType, content, filteredContent);
return status;
}
const { cacheStatus, cacheContent } = await readCachedContentForVaccine(vaccineType);
if (cacheStatus === "empty" || (cacheStatus === "invalidated" && forceUpdate)) {
log.info(
{ context: { vaccineType, cacheStatus, forceUpdate } },
`Cache was ${cacheStatus} previously, writing updated content`,
);
await checkContentPassesStylingAndWriteToCache(vaccineType, content, filteredContent);
return status;
}
if (cacheStatus === "invalidated" && !forceUpdate) {
log.info(
{ context: { vaccineType, cacheStatus, forceUpdate } },
`Content changes detected for vaccine ${vaccineType} : cache is already invalidated, no action taken`,
);
status.invalidatedCount++;
return status;
}
if (cacheStatus === "valid") {
if (
vitaContentChangedSinceLastApproved(
filteredContent,
await getFilteredContentForVaccine(vaccineType, cacheContent),
)
) {
log.info(
{ context: { vaccineType } },
`Content changes detected for vaccine ${vaccineType}; invalidating cache`,
);
await invalidateCacheForVaccine(vaccineType);
status.invalidatedCount++;
return status;
} else {
await checkContentPassesStylingAndWriteToCache(vaccineType, content, filteredContent);
return status;
}
}
} catch (error) {
log.error(
{
context: { vaccineType },
error: error instanceof Error ? { message: error.message, stack: error.stack, cause: error.cause } : error,
},
"Error occurred for vaccine",
);
status.failureCount++;
return status;
}
log.error("Unexpected content hydration scenario, should never have happened");
status.failureCount++;
return status;
}
interface ContentCacheHydratorEvent {
forceUpdate?: boolean;
vaccineToUpdate?: string;
}
// Ref: https://nhsd-confluence.digital.nhs.uk/spaces/Vacc/pages/1113364124/Caching+strategy+for+content+from+NHS.uk+content+API
const runContentCacheHydrator = async (event: ContentCacheHydratorEvent) => {
log.info({ context: { event } }, "Received event, hydrating content cache.");
const forceUpdate = typeof event.forceUpdate === "boolean" ? event.forceUpdate : false;
let vaccinesToRunOn: VaccineType[];
if (event.vaccineToUpdate) {
const vaccineType = getVaccineTypeFromLowercaseString(event.vaccineToUpdate);
if (typeof vaccineType === "undefined") {
const errorMessage = `Bad request: Vaccine name not recognised: ${event.vaccineToUpdate}`;
log.error({ context: { vaccineType: event.vaccineToUpdate } }, errorMessage);
throw new Error(errorMessage);
} else {
vaccinesToRunOn = [vaccineType];
}
} else {
vaccinesToRunOn = Object.values(VaccineType);
}
if (forceUpdate) {
log.info(
{ context: { vaccineType: event.vaccineToUpdate, forceUpdate } },
"Clinical approval received for force update of vaccine",
);
}
let failureCount: number = 0;
let invalidatedCount: number = 0;
const rateLimitDelayMillis: number = 1000 / ((await config.CONTENT_API_RATE_LIMIT_PER_MINUTE) / 60);
const rateLimitDelayWithMargin: number = 2.5 * rateLimitDelayMillis; // to keep ourselves well within the budget
log.info(`Delay used between calls to rate limit content API is ${rateLimitDelayWithMargin}ms`);
for (const vaccine of vaccinesToRunOn) {
const status = await hydrateCacheForVaccine(
vaccine,
await config.CONTENT_CACHE_IS_CHANGE_APPROVAL_ENABLED,
forceUpdate,
);
invalidatedCount += status.invalidatedCount;
failureCount += status.failureCount;
await delay(rateLimitDelayWithMargin); // sleep to rate limit
}
log.info({ context: { failureCount, invalidatedCount } }, "Finished hydrating content cache: report");
if (failureCount > 0) {
throw new Error(`${failureCount} failures`);
}
if (invalidatedCount > 0) {
log.error({ context: { invalidatedCount } }, "Cache invalidation(s) found. Needs approval and force update");
}
};
const flushLogs = () =>
new Promise<void>((resolve) => {
logger.flush(() => resolve());
});
export const handler = async (event: object, context: Context): Promise<void> => {
const requestContext: RequestContext = {
traceId: context.awsRequestId,
nextUrl: "",
sessionId: "content-cache-hydrator",
};
try {
await asyncLocalStorage.run(requestContext, () => runContentCacheHydrator(event));
} finally {
await flushLogs();
}
};