Skip to content

Commit aabf50d

Browse files
committed
Introduce idempotency: replayed update with same ID will be ignored
1 parent d74a0bd commit aabf50d

File tree

5 files changed

+103
-30
lines changed

5 files changed

+103
-30
lines changed

internal/datastore/src/__test__/letter-repository.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,12 @@ function createLetter(
1414
supplierId: string,
1515
letterId: string,
1616
status: Letter["status"] = "PENDING",
17+
eventId?: string,
1718
): InsertLetter {
1819
const now = new Date().toISOString();
1920
return {
2021
id: letterId,
22+
eventId,
2123
supplierId,
2224
specificationId: "specification1",
2325
groupId: "group1",
@@ -168,6 +170,7 @@ describe("LetterRepository", () => {
168170

169171
const updateLetter: UpdateLetter = {
170172
id: "letter1",
173+
eventId: "event1",
171174
supplierId: "supplier1",
172175
status: "REJECTED",
173176
reasonCode: "R01",
@@ -199,6 +202,7 @@ describe("LetterRepository", () => {
199202
jest.setSystemTime(new Date(2020, 1, 2));
200203
const letterDto: UpdateLetter = {
201204
id: "letter1",
205+
eventId: "event1",
202206
supplierId: "supplier1",
203207
status: "DELIVERED",
204208
};
@@ -215,6 +219,7 @@ describe("LetterRepository", () => {
215219
test("can't update a letter that does not exist", async () => {
216220
const updateLetter: UpdateLetter = {
217221
id: "letter1",
222+
eventId: "event1",
218223
supplierId: "supplier1",
219224
status: "DELIVERED",
220225
};
@@ -233,6 +238,7 @@ describe("LetterRepository", () => {
233238

234239
const updateLetter: UpdateLetter = {
235240
id: "letter1",
241+
eventId: "event1",
236242
supplierId: "supplier1",
237243
status: "DELIVERED",
238244
};
@@ -241,6 +247,52 @@ describe("LetterRepository", () => {
241247
).rejects.toThrow("Cannot do operations on a non-existent table");
242248
});
243249

250+
test("does not update a letter if the same eventId is used", async () => {
251+
const letter = createLetter("supplier1", "letter1", "DELIVERED", "event1");
252+
await letterRepository.putLetter(letter);
253+
254+
const duplicateUpdate: UpdateLetter = {
255+
id: "letter1",
256+
eventId: "event1",
257+
supplierId: "supplier1",
258+
status: "REJECTED",
259+
reasonCode: "R01",
260+
};
261+
const result = await letterRepository.updateLetterStatus(duplicateUpdate);
262+
263+
expect(result).toBeUndefined();
264+
const unchangedLetter = await letterRepository.getLetterById(
265+
"supplier1",
266+
"letter1",
267+
);
268+
expect(unchangedLetter.status).toBe("DELIVERED");
269+
expect(unchangedLetter.eventId).toBe("event1");
270+
expect(unchangedLetter.reasonCode).toBeUndefined();
271+
});
272+
273+
test("updates a letter if a different eventId is used", async () => {
274+
const letter = createLetter("supplier1", "letter1", "DELIVERED", "event1");
275+
await letterRepository.putLetter(letter);
276+
277+
const duplicateUpdate: UpdateLetter = {
278+
id: "letter1",
279+
eventId: "event2",
280+
supplierId: "supplier1",
281+
status: "REJECTED",
282+
reasonCode: "R01",
283+
};
284+
const result = await letterRepository.updateLetterStatus(duplicateUpdate);
285+
286+
expect(result).toBeDefined();
287+
const changedLetter = await letterRepository.getLetterById(
288+
"supplier1",
289+
"letter1",
290+
);
291+
expect(changedLetter.status).toBe("REJECTED");
292+
expect(changedLetter.eventId).toBe("event2");
293+
expect(changedLetter.reasonCode).toBe("R01");
294+
});
295+
244296
test("should return a list of letters matching status", async () => {
245297
await letterRepository.putLetter(createLetter("supplier1", "letter1"));
246298
await letterRepository.putLetter(createLetter("supplier1", "letter2"));
@@ -278,6 +330,7 @@ describe("LetterRepository", () => {
278330

279331
const updateLetter: UpdateLetter = {
280332
id: "letter1",
333+
eventId: "event1",
281334
supplierId: "supplier1",
282335
status: "DELIVERED",
283336
};

internal/datastore/src/letter-repository.ts

Lines changed: 45 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
UpdateCommand,
88
UpdateCommandOutput,
99
} from "@aws-sdk/lib-dynamodb";
10+
import { ConditionalCheckFailedException } from "@aws-sdk/client-dynamodb";
1011
import { Logger } from "pino";
1112
import { z } from "zod";
1213
import {
@@ -163,32 +164,16 @@ export class LetterRepository {
163164
};
164165
}
165166

166-
async updateLetterStatus(letterToUpdate: UpdateLetter): Promise<Letter> {
167+
async updateLetterStatus(
168+
letterToUpdate: UpdateLetter,
169+
): Promise<Letter | undefined> {
167170
this.log.debug(
168171
`Updating letter ${letterToUpdate.id} to status ${letterToUpdate.status}`,
169172
);
170173
let result: UpdateCommandOutput;
171174
try {
172-
let updateExpression =
173-
"set #status = :status, updatedAt = :updatedAt, supplierStatus = :supplierStatus, #ttl = :ttl";
174-
const expressionAttributeValues: Record<string, any> = {
175-
":status": letterToUpdate.status,
176-
":updatedAt": new Date().toISOString(),
177-
":supplierStatus": `${letterToUpdate.supplierId}#${letterToUpdate.status}`,
178-
":ttl": Math.floor(
179-
Date.now() / 1000 + 60 * 60 * this.config.lettersTtlHours,
180-
),
181-
};
182-
183-
if (letterToUpdate.reasonCode) {
184-
updateExpression += ", reasonCode = :reasonCode";
185-
expressionAttributeValues[":reasonCode"] = letterToUpdate.reasonCode;
186-
}
187-
188-
if (letterToUpdate.reasonText) {
189-
updateExpression += ", reasonText = :reasonText";
190-
expressionAttributeValues[":reasonText"] = letterToUpdate.reasonText;
191-
}
175+
const { expressionAttributeValues, updateExpression } =
176+
this.buildUpdateExpression(letterToUpdate);
192177

193178
result = await this.ddbClient.send(
194179
new UpdateCommand({
@@ -198,31 +183,61 @@ export class LetterRepository {
198183
supplierId: letterToUpdate.supplierId,
199184
},
200185
UpdateExpression: updateExpression,
201-
ConditionExpression: "attribute_exists(id)", // Ensure letter exists
186+
ConditionExpression:
187+
"attribute_exists(id) AND (attribute_not_exists(eventId) OR eventId <> :eventId)",
202188
ExpressionAttributeNames: {
203189
"#status": "status",
204190
"#ttl": "ttl",
205191
},
206192
ExpressionAttributeValues: expressionAttributeValues,
207193
ReturnValues: "ALL_NEW",
194+
ReturnValuesOnConditionCheckFailure: "ALL_OLD",
208195
}),
209196
);
197+
198+
this.log.debug(
199+
`Updated letter ${letterToUpdate.id} to status ${letterToUpdate.status}`,
200+
);
201+
return LetterSchema.parse(result.Attributes);
210202
} catch (error) {
211-
if (
212-
error instanceof Error &&
213-
error.name === "ConditionalCheckFailedException"
214-
) {
203+
if (error instanceof ConditionalCheckFailedException) {
204+
if (error.Item?.eventId.S === letterToUpdate.eventId) {
205+
this.log.warn(
206+
`Skipping update for letter ${letterToUpdate.id}: eventId ${letterToUpdate.eventId} already processed`,
207+
);
208+
return undefined;
209+
}
215210
throw new Error(
216211
`Letter with id ${letterToUpdate.id} not found for supplier ${letterToUpdate.supplierId}`,
217212
);
218213
}
219214
throw error;
220215
}
216+
}
221217

222-
this.log.debug(
223-
`Updated letter ${letterToUpdate.id} to status ${letterToUpdate.status}`,
224-
);
225-
return LetterSchema.parse(result.Attributes);
218+
private buildUpdateExpression(letterToUpdate: UpdateLetter) {
219+
let updateExpression =
220+
"set #status = :status, updatedAt = :updatedAt, supplierStatus = :supplierStatus, #ttl = :ttl, eventId = :eventId";
221+
const expressionAttributeValues: Record<string, any> = {
222+
":status": letterToUpdate.status,
223+
":updatedAt": new Date().toISOString(),
224+
":supplierStatus": `${letterToUpdate.supplierId}#${letterToUpdate.status}`,
225+
":ttl": Math.floor(
226+
Date.now() / 1000 + 60 * 60 * this.config.lettersTtlHours,
227+
),
228+
":eventId": letterToUpdate.eventId,
229+
};
230+
231+
if (letterToUpdate.reasonCode) {
232+
updateExpression += ", reasonCode = :reasonCode";
233+
expressionAttributeValues[":reasonCode"] = letterToUpdate.reasonCode;
234+
}
235+
236+
if (letterToUpdate.reasonText) {
237+
updateExpression += ", reasonText = :reasonText";
238+
expressionAttributeValues[":reasonText"] = letterToUpdate.reasonText;
239+
}
240+
return { updateExpression, expressionAttributeValues };
226241
}
227242

228243
async getLettersBySupplier(

internal/datastore/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const LetterSchemaBase = z.object({
4242

4343
export const LetterSchema = LetterSchemaBase.extend({
4444
supplierId: idRef(SupplierSchema, "id"),
45+
eventId: z.string().optional(),
4546
url: z.url(),
4647
createdAt: z.string(),
4748
updatedAt: z.string(),
@@ -67,6 +68,7 @@ export type InsertLetter = Omit<
6768
>;
6869
export type UpdateLetter = {
6970
id: string;
71+
eventId: string;
7072
supplierId: string;
7173
status: Letter["status"];
7274
reasonCode?: string;

lambdas/upsert-letter/src/handler/__tests__/upsert-handler.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ describe("createUpsertLetterHandler", () => {
237237
const firstArg = (mockedDeps.letterRepo.putLetter as jest.Mock).mock
238238
.calls[0][0];
239239
expect(firstArg.id).toBe("letter1");
240+
expect(firstArg.eventId).toBe("7b9a03ca-342a-4150-b56b-989109c45613");
240241
expect(firstArg.supplierId).toBe("supplier1");
241242
expect(firstArg.specificationId).toBe("spec1");
242243
expect(firstArg.url).toBe("s3://letterDataBucket/letter1.pdf");

lambdas/upsert-letter/src/handler/upsert-handler.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ function mapToInsertLetter(
7474
const now = new Date().toISOString();
7575
return {
7676
id: upsertRequest.data.domainId,
77+
eventId: upsertRequest.id,
7778
supplierId: supplier,
7879
status: "PENDING",
7980
specificationId: spec,
@@ -93,6 +94,7 @@ function mapToInsertLetter(
9394
function mapToUpdateLetter(upsertRequest: LetterEvent): UpdateLetter {
9495
return {
9596
id: upsertRequest.data.domainId,
97+
eventId: upsertRequest.id,
9698
supplierId: upsertRequest.data.supplierId,
9799
status: upsertRequest.data.status,
98100
reasonCode: upsertRequest.data.reasonCode,

0 commit comments

Comments
 (0)