Skip to content

Commit f823580

Browse files
authored
Fix: [AEA-5722] - Fix cooldown checking bug in notify processor lambda (#2152)
## Summary - Routine Change ### Details A small number of patients are getting lots of notifications. The cooldown check was only grabbing one record from the table - I'd thought that if you sort by ODS code, then the latest record would be at the top. This is not true, so we can be checking against old records when newer ones exist. In some rare cases (approx 1 in 10,000), a patient may get a LOT of PSU requests about their medications. In one case study, a patient had 61 distinct PSU requests pertaining to 6 prescription IDs, of which 24 requests were ready to collect. All of that was from one ODS code. This resulted in 5 SQS messages being posted ~5 minutes apart. However, when the cooldown check came for subsequent requests, it always passed. I believe this is because the cooldown check is essentially a `return true` check once there are enough records. This PR fixes this bug by instead grabbing all records, and then taking the maximum timestamp and checking if that is within the cooldown period.
1 parent 31324c7 commit f823580

2 files changed

Lines changed: 80 additions & 31 deletions

File tree

packages/nhsNotifyLambda/src/utils/dynamo.ts

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,18 @@ export async function addPrescriptionMessagesToNotificationStateStore(
5050
TableName: dynamoTable,
5151
Item: item
5252
}))
53-
logger.info("Upserted prescription")
53+
logger.debug("Upserted prescription")
5454
} catch (err) {
5555
logger.error("Failed to write to DynamoDB", {
5656
error: err,
5757
item
5858
})
5959
throw err
6060
}
61+
logger.info(
62+
"Finished upserting records to the dynamo table",
63+
{nhsNumbers: dataArray.map(el => el.PSUDataItem.PatientNHSNumber)}
64+
)
6165
}
6266
}
6367

@@ -80,17 +84,15 @@ export async function checkCooldownForUpdate(
8084
}
8185

8286
try {
83-
// Retrieve the last notification state for this patient/pharmacy combo
87+
// Retrieve the notification states for this patient/pharmacy combo
8488
const q = new QueryCommand({
8589
TableName: dynamoTable,
8690
IndexName: "PatientPharmacyIndex",
8791
KeyConditionExpression: "NHSNumber = :n AND ODSCode = :o",
8892
ExpressionAttributeValues: {
8993
":n": {S: update.PatientNHSNumber},
9094
":o": {S: update.PharmacyODSCode}
91-
},
92-
ScanIndexForward: false, // descending on the ODSCode key (i.e. newest RequestID)
93-
Limit: 1
95+
}
9496
})
9597

9698
let result
@@ -101,18 +103,41 @@ export async function checkCooldownForUpdate(
101103
throw err
102104
}
103105

104-
const Item = result.Items?.[0]
105-
if (!Item?.LastNotificationRequestTimestamp) {
106-
logger.debug("No previous notification state found. Notification allowed.", {
107-
NHSNumber: update.PatientNHSNumber,
108-
ODSCode: update.PharmacyODSCode,
109-
requestID: update.RequestID
110-
})
106+
const items = result.Items ?? []
107+
108+
if (items.length === 0) {
109+
logger.debug(
110+
"No previous notification states. Notification allowed",
111+
{
112+
NHSNumber: update.PatientNHSNumber,
113+
ODSCode: update.PharmacyODSCode,
114+
requestID: update.RequestID
115+
}
116+
)
111117
return true
112118
}
113119

120+
const timestamps = items
121+
.map(i => i.LastNotificationRequestTimestamp?.S)
122+
.filter((ts): ts is string => Boolean(ts)) // If we get some weird variable type, ignore it
123+
.map(ts => new Date(ts).getTime())
124+
125+
if (!timestamps.length) {
126+
logger.debug(
127+
"No valid timestamps found in the returned records. " +
128+
"Assuming something is broken for this patient, and blocking notifications",
129+
{
130+
LastNotificationRequestTimestamps: items.map(i => i.LastNotificationRequestTimestamp?.S),
131+
NHSNumber: update.PatientNHSNumber,
132+
ODSCode: update.PharmacyODSCode,
133+
requestID: update.RequestID
134+
}
135+
)
136+
return false
137+
}
138+
114139
// Compute seconds since last notification
115-
const lastTs = new Date(Item.LastNotificationRequestTimestamp.S as string).getTime()
140+
const lastTs = Math.max(...timestamps)
116141
const nowTs = Date.now()
117142
const secondsSince = Math.floor((nowTs - lastTs) / 1000)
118143

packages/nhsNotifyLambda/tests/testUtils.test.ts

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -319,11 +319,7 @@ describe("NHS notify lambda helper functions", () => {
319319
addPrescriptionMessagesToNotificationStateStore(logger, [item])
320320
).rejects.toThrow("AWS error")
321321

322-
// first info for count
323-
expect(infoSpy).toHaveBeenCalledWith(
324-
"Attempting to push data to DynamoDB",
325-
{count: 1}
326-
)
322+
expect(sendSpy).toHaveBeenCalledTimes(1)
327323
// error log includes the item that failed, and the error
328324
expect(errorSpy).toHaveBeenCalledWith(
329325
"Failed to write to DynamoDB",
@@ -350,8 +346,6 @@ describe("NHS notify lambda helper functions", () => {
350346
const cmd = sendSpy.mock.calls[0][0] as PutCommand
351347
expect(cmd).toBeInstanceOf(PutCommand)
352348

353-
expect(infoSpy).toHaveBeenCalledWith("Upserted prescription")
354-
355349
// No errors
356350
expect(errorSpy).not.toHaveBeenCalled()
357351
})
@@ -408,21 +402,33 @@ describe("NHS notify lambda helper functions", () => {
408402

409403
it("returns true when last notification is older than default cooldown", async () => {
410404
const pastTs = new Date(Date.now() - (1000 * 901)).toISOString() // 901s ago
411-
sendSpy.mockImplementationOnce(() =>
412-
Promise.resolve({Item: {LastNotificationRequestTimestamp: pastTs}})
413-
)
405+
sendSpy.mockImplementationOnce(() => {
406+
return {
407+
Items: [
408+
{LastNotificationRequestTimestamp: {S: new Date(Date.now() - 1000 * 5000).toISOString()}}, // very old
409+
{LastNotificationRequestTimestamp: {S: pastTs}}
410+
]
411+
}
412+
})
414413

415414
const update = constructPSUDataItemMessage().PSUDataItem
416415
const result = await checkCooldownForUpdate(logger, update, 900)
417416

418417
expect(result).toBe(true)
419418
})
420419

421-
it("returns false when last notification is within default cooldown", async () => {
422-
const recentTs = new Date(Date.now() - (1000 * 300)).toISOString() // 300s ago
423-
sendSpy.mockImplementationOnce(() =>
424-
Promise.resolve({Items: [{LastNotificationRequestTimestamp: recentTs}]})
425-
)
420+
it("returns false when ANY item is within the cooldown window", async () => {
421+
const recentTs = new Date(Date.now() - 1000 * 300).toISOString() // 300s ago
422+
const oldTs = new Date(Date.now() - 1000 * 10_000).toISOString() // old
423+
424+
sendSpy.mockImplementationOnce(() => {
425+
return {
426+
Items: [
427+
{LastNotificationRequestTimestamp: {S: oldTs}},
428+
{LastNotificationRequestTimestamp: {S: recentTs}} // within cooldown → should suppress
429+
]
430+
}
431+
})
426432

427433
const update = constructPSUDataItemMessage().PSUDataItem
428434
const result = await checkCooldownForUpdate(logger, update, 900)
@@ -433,16 +439,34 @@ describe("NHS notify lambda helper functions", () => {
433439
it("honours a custom cooldownPeriod", async () => {
434440
// custom cooldown = 60 seconds, but timestamp is only 30s ago
435441
const recentTs = new Date(Date.now() - 30000).toISOString()
436-
sendSpy.mockImplementationOnce(() =>
437-
Promise.resolve({Items: [{LastNotificationRequestTimestamp: recentTs}]})
438-
)
442+
sendSpy.mockImplementationOnce(() => {
443+
return {
444+
Items: [{LastNotificationRequestTimestamp: {S: recentTs}}]
445+
}
446+
})
439447

440448
const update = constructPSUDataItemMessage().PSUDataItem
441449
const result = await checkCooldownForUpdate(logger, update, 60)
442450

443451
expect(result).toBe(false)
444452
})
445453

454+
it("returns false when items exist but none have valid timestamps", async () => {
455+
sendSpy.mockImplementationOnce(() => {
456+
return {
457+
Items: [
458+
{}, // no timestamp attribute
459+
{SomeOtherField: {S: "foo"}}
460+
]
461+
}
462+
})
463+
464+
const update = constructPSUDataItemMessage().PSUDataItem
465+
const result = await checkCooldownForUpdate(logger, update, 900)
466+
467+
expect(result).toBe(false)
468+
})
469+
446470
it("propagates and logs errors from DynamoDB", async () => {
447471
const awsErr = new Error("DDB failure")
448472
sendSpy.mockImplementationOnce(() => Promise.reject(awsErr))

0 commit comments

Comments
 (0)