You are the engineer on call. Two tickets have come in against the order pipeline — an SQS FIFO queue feeding a Lambda consumer — plus one theory from the platform team.
Your job is triage and a minimal fix — not a rewrite.
OPS-882 — opened by Ama (Support)
Customers are getting their order confirmation email twice.
On a normal quiet day it is maybe one order in fifty. Nobody would have noticed except one of them replied to complain.
On a bad day it is not one in fifty. Tuesday morning we sent just over 400 confirmation emails for 51 orders. Tuesday was also the morning the payments provider was flapping, if that is relevant.
OPS-884 — opened by Dev (Ops)
When one order jams, everything stops. Not that customer's orders — everything, for every customer, in every region, until somebody goes into the console and purges the message by hand. We did it twice last week.
Second thing, and I do not understand it: our dead-letter queue has never had a single message in it. Not one, since we deployed in March. Orders definitely fail. Where are they going?
Comment on OPS-884 from Marcus (Platform), yesterday
This is a concurrency ceiling, nothing more. We have
reservedConcurrency: 2on the consumer, which is absurd for a queue this size. Take it to 50 and both of these tickets go away — the backlog will clear faster than a jam can build up. I can raise the PR this afternoon.
serverless.yml (the parts that matter):
service: order-pipeline
provider:
name: aws
runtime: nodejs22.x
region: eu-west-1
functions:
consumeOrders:
handler: src/consumer.handler
timeout: 120
memorySize: 1024
reservedConcurrency: 2
events:
- sqs:
arn: !GetAtt OrdersQueue.Arn
batchSize: 10
resources:
Resources:
OrdersQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: orders.fifo
FifoQueue: true
ContentBasedDeduplication: false
VisibilityTimeout: 30
MessageRetentionPeriod: 345600
OrdersDLQ:
Type: AWS::SQS::Queue
Properties:
QueueName: orders-dlq.fifo
FifoQueue: true
MessageRetentionPeriod: 1209600
RedrivePolicy:
deadLetterTargetArn: !GetAtt OrdersQueue.Arn
maxReceiveCount: 5
src/producer.ts:
import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";
import { randomUUID } from "node:crypto";
import type { Order } from "./types";
const sqs = new SQSClient({});
const QUEUE_URL = process.env.ORDERS_QUEUE_URL!;
/**
* Publish an order for downstream processing. FIFO, because finance need
* an order's events to arrive in the order they happened.
*/
export async function publishOrder(order: Order): Promise<void> {
await sqs.send(
new SendMessageCommand({
QueueUrl: QUEUE_URL,
MessageBody: JSON.stringify(order),
MessageGroupId: "orders",
MessageDeduplicationId: randomUUID(),
})
);
}
src/consumer.ts:
import type { SQSEvent, SQSRecord } from "aws-lambda";
import { chargeCard } from "./payments";
import { reserveStock } from "./inventory";
import { sendConfirmationEmail } from "./email";
import { logger } from "./logger";
import type { Order } from "./types";
/**
* One order, end to end. Typically 400ms–1.5s. The payment provider's p99 is a
* lot worse than that — several seconds — and reserveStock retries internally.
*/
async function processOrder(order: Order): Promise<void> {
await chargeCard(order);
await reserveStock(order);
await sendConfirmationEmail(order);
logger.info("order processed", { orderId: order.id });
}
export async function handler(event: SQSEvent): Promise<void> {
logger.info("batch received", { count: event.Records.length });
for (const record of event.Records as SQSRecord[]) {
const order = JSON.parse(record.body) as Order;
await processOrder(order);
}
logger.info("batch complete", { count: event.Records.length });
}
A table, one row per reported symptom. There are three — treat the empty dead-letter queue as its own row.
| # | Symptom | Verdict | Cause |
|---|---|---|---|
| 1 | Duplicate confirmation emails | ||
| 2 | One bad order stops the whole pipeline | ||
| 3 | Dead-letter queue is always empty |
Verdict is exactly one of REAL or NOT A BUG. Cause is one line. If a symptom has more than one independent cause in this configuration, say so and name each — one of these does, and a single cause is a partial answer.
One paragraph on Marcus's concurrency proposal. Would raising reservedConcurrency to 50 change any of the three symptoms? Be specific about the mechanism, not the intent.
For each symptom you marked REAL:
Do not give me the whole file back. Minimal changes, reviewable in a diff.
At most two further defects nobody has reported, each one you can quote a line for.
If there is nothing else worth raising, write exactly Nothing else.
Every claim should be checkable against the files above or against documented AWS behaviour you can state precisely. If a fix depends on a specific documented rule about how these services interact, give the rule and the number — I am going to look it up. A confident diagnosis of a problem this configuration does not have costs you more than missing one it does.
| # | Symptom | Verdict | Cause |
|---|---|---|---|
| 1 | Duplicate confirmation emails | REAL | Two independent causes. (a) VisibilityTimeout: 30 is shorter than the Lambda timeout: 120, so messages become visible again while the first invocation is still processing them; a second invocation picks them up and processes them a second time. (b) MessageDeduplicationId: randomUUID() means every publish is unique to SQS, so if the calling service retries the publish (e.g. an HTTP timeout), the same order enters the queue twice and is processed twice. |
| 2 | One bad order stops the whole pipeline | REAL | The for loop in the handler has no per-message error isolation: a single exception aborts the entire batch, the function returns a failure, and no messages in that batch are deleted. Because every message shares MessageGroupId: "orders", the failed message is always the oldest in the group and is redelivered first, permanently head-of-line blocking all other traffic. |
| 3 | Dead-letter queue is always empty | REAL | The RedrivePolicy is attached to the DLQ with deadLetterTargetArn pointing back to the source queue. The source queue (OrdersQueue) has no redrive policy at all, so no message is ever forwarded to the DLQ regardless of how many times it is received. |
Raising reservedConcurrency to 50 changes none of the three symptoms.
For duplicates: the mechanism is that a single invocation's processing time exceeds the 30-second visibility timeout. More concurrent invocations does not shorten the time any one invocation takes; the visibility timeout is a property of the queue, not of the concurrency setting. The race between "function still running" and "message visible again" is unchanged.
For the pipeline stop: the mechanism is that a poisoned message is always the oldest in the single FIFO group and is therefore always the first message in the next batch. More concurrent invocations all receive the same head-of-line message (one at a time, since it's a single group), all fail on it, and all redeliver their batches. Concurrency adds parallelism across groups; with one group there is nothing to parallelise.
For the empty DLQ: the redrive policy is on the wrong resource. No number of concurrent invocations can fix a configuration that points the policy at the wrong queue.
Marcus's change would increase throughput in the happy path (more batches in flight), which is fine, but it addresses none of the three root causes.
Cause (a): visibility timeout shorter than function timeout
File: serverless.yml
Wrong line:
VisibilityTimeout: 30
Replacement:
VisibilityTimeout: 300
Why: AWS requires the visibility timeout to be greater than the function timeout (120 s) so that messages remain invisible for the entire duration a single invocation might run. At 300 s there is comfortable margin above the 120 s timeout.
Cause (b): non-deterministic deduplication ID
File: src/producer.ts
Wrong line:
MessageDeduplicationId: randomUUID(),
Replacement:
MessageDeduplicationId: order.id,
Why: A FIFO queue with ContentBasedDeduplication: false deduplicates on the MessageDeduplicationId within a 5-minute window. Using the order ID means a retried publish of the same order is rejected by SQS rather than enqueued a second time. (The randomUUID import can be removed if it is unused elsewhere.)
File: src/consumer.ts
Wrong lines:
export async function handler(event: SQSEvent): Promise<void> {
logger.info("batch received", { count: event.Records.length });
for (const record of event.Records as SQSRecord[]) {
const order = JSON.parse(record.body) as Order;
await processOrder(order);
}
logger.info("batch complete", { count: event.Records.length });
}
Replacement:
export async function handler(
event: SQSEvent
): Promise<{ batchItemFailures: { itemIdentifier: string }[] }> {
logger.info("batch received", { count: event.Records.length });
const batchItemFailures: { itemIdentifier: string }[] = [];
for (const record of event.Records as SQSRecord[]) {
const order = JSON.parse(record.body) as Order;
try {
await processOrder(order);
} catch (err) {
logger.error("order processing failed", { orderId: order.id, err });
batchItemFailures.push({ itemIdentifier: record.messageId! });
}
}
logger.info("batch complete", {
count: event.Records.length,
failures: batchItemFailures.length,
});
return { batchItemFailures };
}
Why: SQS batch response reporting (available since 2021) lets the function report which specific messages failed. Only those messages are retained for redelivery; all others are deleted. A single poisoned message no longer aborts the batch or causes already-processed messages to be redelivered. The failed message will be retried up to maxReceiveCount (5) and then forwarded to the DLQ (once symptom 3 is fixed).
File: serverless.yml
Wrong lines (the entire OrdersDLQ resource and the missing policy on OrdersQueue):
OrdersQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: orders.fifo
FifoQueue: true
ContentBasedDeduplication: false
VisibilityTimeout: 30
MessageRetentionPeriod: 345600
OrdersDLQ:
Type: AWS::SQS::Queue
Properties:
QueueName: orders-dlq.fifo
FifoQueue: true
MessageRetentionPeriod: 1209600
RedrivePolicy:
deadLetterTargetArn: !GetAtt OrdersQueue.Arn
maxReceiveCount: 5
Replacement:
OrdersQueue:
Type: AWS::SQS::Queue
Properties:
QueueName: orders.fifo
FifoQueue: true
ContentBasedDeduplication: false
VisibilityTimeout: 300
MessageRetentionPeriod: 345600
RedrivePolicy:
deadLetterTargetArn: !GetAtt OrdersDLQ.Arn
maxReceiveCount: 5
OrdersDLQ:
Type: AWS::SQS::Queue
Properties:
QueueName: orders-dlq.fifo
FifoQueue: true
MessageRetentionPeriod: 1209600
Why: The RedrivePolicy must live on the source queue and point to the DLQ. As written, the source queue has no redrive policy, so SQS has no instruction to move messages after maxReceiveCount receives. The policy on the DLQ (pointing back to the source) is inert because nothing is ever delivered to the DLQ.
No idempotency in processOrder. File src/consumer.ts, line:
await chargeCard(order);
At-least-once delivery guarantees that some messages will be processed more than once (Lambda can be snapshotted and terminated after the side-effect completes but before the response is acknowledged). Without an idempotency key passed to the payments provider (e.g. the order ID), a redelivered message results in a double charge. This is a financial exposure independent of the duplicate-email issue.
Single MessageGroupId serialises the entire queue. File src/producer.ts, line:
MessageGroupId: "orders",
The stated requirement is per-order ordering ("an order's events to arrive in the order they happened"). That only requires MessageGroupId: order.id. With a single group, the FIFO queue delivers one message at a time globally — there is zero parallelism, and any slow message (not just a failing one) creates a backlog behind it for every customer in every region.