Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ module "supplier_allocator" {
log_subscription_role_arn = local.acct.log_subscription_role_arn

lambda_env_vars = merge(local.common_lambda_env_vars, {
UPSERT_LETTERS_QUEUE_URL = module.sqs_letter_updates.sqs_queue_url,
IDEMPOTENCY_TABLE_NAME = aws_dynamodb_table.idempotency.name
UPSERT_LETTERS_QUEUE_URL = module.sqs_letter_updates.sqs_queue_url,
SUPPLIER_ALLOCATOR_DLQ_URL = module.sqs_supplier_allocator.sqs_dlq_url,
IDEMPOTENCY_TABLE_NAME = aws_dynamodb_table.idempotency.name
})
}

Expand Down Expand Up @@ -83,6 +84,19 @@ data "aws_iam_policy_document" "supplier_allocator_lambda" {
]
}

statement {
sid = "AllowSupplierAllocatorDLQWrite"
effect = "Allow"

actions = [
"sqs:SendMessage"
]

resources = [
module.sqs_supplier_allocator.sqs_dlq_arn
]
}

statement {
sid = "AllowConfigDynamoDBAccess"
effect = "Allow"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Error thrown when a supplier cannot be allocated due to missing supplier config
*/
export default class MissingSupplierConfigError extends Error {
constructor(public readonly message: string) {
super(message);
this.name = "MissingSupplierConfigError";
}
}
1 change: 1 addition & 0 deletions internal/datastore/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ export { default as DBHealthcheck } from "./healthcheck";
export { default as LetterAlreadyExistsError } from "./errors/letter-already-exists-error";
export { default as LetterNotFoundError } from "./errors/letter-not-found-error";
export { default as MiNotFoundError } from "./errors/mi-not-found-error";
export { default as MissingSupplierConfigError } from "./errors/missing-supplier-config-error";
19 changes: 14 additions & 5 deletions internal/datastore/src/supplier-config-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
VolumeGroup,
} from "@nhsdigital/nhs-notify-event-schemas-supplier-config";
import { SupplierConfigEntity } from "./types";
import MissingSupplierConfigError from "./errors/missing-supplier-config-error";

export type SupplierConfigRepositoryConfig = {
supplierConfigTableName: string;
Expand All @@ -40,7 +41,9 @@ export class SupplierConfigRepository {
}),
);
if (!result.Item) {
throw new Error(`No letter variant details found for id ${variantId}`);
throw new MissingSupplierConfigError(
`No letter variant details found for id ${variantId}`,
);
}

return $LetterVariant.parse(result.Item);
Expand All @@ -54,7 +57,9 @@ export class SupplierConfigRepository {
}),
);
if (!result.Item) {
throw new Error(`No volume group details found for id ${groupId}`);
throw new MissingSupplierConfigError(
`No volume group details found for id ${groupId}`,
);
}
return $VolumeGroup.parse(result.Item);
}
Expand All @@ -81,7 +86,7 @@ export class SupplierConfigRepository {
}),
);
if (!result.Items || result.Items.length === 0) {
throw new Error(
throw new MissingSupplierConfigError(
`No active supplier allocations found for volume group id ${groupId}`,
);
}
Expand All @@ -99,7 +104,9 @@ export class SupplierConfigRepository {
}),
);
if (!result.Item) {
throw new Error(`Supplier with id ${supplierId} not found`);
throw new MissingSupplierConfigError(
`Supplier with id ${supplierId} not found`,
);
}
suppliers.push($Supplier.parse(result.Item));
}
Expand Down Expand Up @@ -140,7 +147,9 @@ export class SupplierConfigRepository {
}),
);
if (!result.Item) {
throw new Error(`No pack specification found for id ${packSpecId}`);
throw new MissingSupplierConfigError(
`No pack specification found for id ${packSpecId}`,
);
}
return $PackSpecification.parse(result.Item);
}
Expand Down
16 changes: 10 additions & 6 deletions lambdas/supplier-allocator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,25 @@ Consumes `LetterRequestPrepared` events (v1 and v2) from an SQS queue, chooses a
2. Each record body is parsed and validated as either `$LetterRequestPreparedEventV2` or `$LetterRequestPreparedEvent` (v1 fallback).
3. The allocator loads the relevant supplier configuration from `SUPPLIER_CONFIG_TABLE`, including the letter variant, active volume group, candidate suppliers, and compatible pack details.
4. Candidate suppliers are filtered using pack support and daily capacity, then ranked using quota data from `SUPPLIER_QUOTAS_TABLE`.
5. On success, the handler produces an allocation with `allocationStatus.status = "PENDING"`. If allocation cannot be completed, it produces a REJECTED allocation with a failure reason instead of dropping the message.
6. Each record produces a `{ letterEvent, allocationDetails }` message sent to `UPSERT_LETTERS_QUEUE_URL`.
7. After the batch completes, allocation counters are written back to `SUPPLIER_QUOTAS_TABLE`, and only genuine processing failures are returned as `batchItemFailures`.
5. On success, the handler produces an allocation with `allocationStatus.status = "PENDING"` and sends `{ letterEvent, allocationDetails }` to `UPSERT_LETTERS_QUEUE_URL`.
6. If a failure occurs due to missing or invalid supplier config, the original record is sent directly to `SUPPLIER_ALLOCATOR_DLQ_URL` and acknowledged so it is not retried.
7. If supplier config is valid, but the letter request violates the contraints of all the available supplier packs (for example, too many pages), the handler produces a REJECTED allocation with a failure reason instead of dropping the message.
8. Any other processing error is returned in `batchItemFailures` so SQS retries the record based on the source queue redrive policy.
9. After the batch completes, allocation counters are written back to `SUPPLIER_QUOTAS_TABLE`.

## Key Integration Points

- **SQS**: Input from EventSub, output to the upsert-letter queue (`UPSERT_LETTERS_QUEUE`).
- **SQS**: Input from EventSub, output to the upsert-letter queue (`UPSERT_LETTERS_QUEUE`), and direct publish to the allocator DLQ (`SUPPLIER_ALLOCATOR_DLQ_URL`) for `SupplierConfigValidationError` or `MissingSupplierConfigError`.
- **`SupplierConfigRepository`** from `@internal/datastore` (`SUPPLIER_CONFIG_TABLE`): reads letter variants, volume groups, supplier allocations, pack specifications, and supplier packs.
- **`SupplierQuotasRepository`** from `@internal/datastore` (`SUPPLIER_QUOTAS_TABLE`): reads and writes daily and overall allocation counts per volume group and supplier.
- **Event schemas**: `@nhsdigital/nhs-notify-event-schemas-letter-rendering` (v2) and `@nhsdigital/nhs-notify-event-schemas-letter-rendering-v1` (v1).
- **Downstream consumer**: `upsert-letter` receives `{ letterEvent, allocationDetails }` and persists either a PENDING or REJECTED letter.
- **Downstream consumer**: `upsert-letter` receives `{ letterEvent, allocationDetails }` and persists either PENDING or REJECTED letters.

## Nuances and Peculiarities

- **Failed allocations produce REJECTED letters, not dropped messages.** If the config lookup chain fails for any reason, the handler still sends a message to the upsert queue with `allocationStatus.status = "REJECTED"` and `supplierId = "unknown"`. No letters are silently lost.
- **`SupplierConfigValidationError` and `MissingSupplierConfigError` are treated as terminal for retries.** The handler sends the original message directly to the allocator DLQ and acknowledges the source record.
- **Otherwise failed allocations produce REJECTED letters, not dropped messages.** If no valid supplier packs are found, the handler sends a message to the upsert queue with `allocationStatus.status = "REJECTED"` and `supplierId = "unknown"`.
- **All other failures retain normal retry semantics.** Records that fail for non-supplier-config-related reasons are returned in `batchItemFailures` and retried according to queue configuration.
- **The factor algorithm is a running weighted average across the lifetime of the system, not per-batch.** The `overallAllocation` table accumulates counts since deployment. A supplier that handled a disproportionate share yesterday will have a high factor today and be deprioritised, allowing others to catch up to their target percentage.
- **Daily capacity check uses London timezone.** `format(toZonedTime(new Date(), "Europe/London"), "yyyy-MM-dd")` determines the date key. Capacity resets at midnight London time, not UTC.
- **Quota updates happen after the entire batch completes**, not per-record. This optimisation means concurrent Lambda invocations can transiently over-allocate a supplier before quotas are reconciled.
Expand Down
3 changes: 3 additions & 0 deletions lambdas/supplier-allocator/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ export const baseJestConfig = {
},
],
},
transformIgnorePatterns: [
"node_modules/(?!(@nhsdigital/nhs-notify-event-schemas-supplier-config)/)",
],

// Automatically clear mock calls, instances, contexts and results before every test
clearMocks: true,
Expand Down
9 changes: 9 additions & 0 deletions lambdas/supplier-allocator/src/errors/rejected-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Error thrown when a letter is rejected due to violating pack constraints for all possible supplier packs.
*/
export default class RejectedError extends Error {
constructor(public readonly message: string) {
super(message);
this.name = "RejectedError";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Error thrown when a supplier cannot be allocated due to incorrect supplier config
*/
export default class SupplierConfigValidationError extends Error {
constructor(public readonly message: string) {
super(message);
this.name = "SupplierConfigValidationError";
}
}
Loading
Loading