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: (1) VisibilityTimeout: 30 is less than the function timeout: 120, so the same records are delivered to another invoke while the first is still running; (2) the handler throws on the first failed record and never reports per-item failures, so SQS retries the whole batch — including orders that already sent mail. |
| 2 | One bad order stops the whole pipeline | REAL | Every message is published with MessageGroupId: "orders", so FIFO head-of-line blocking applies to the entire queue, not to one customer or order. |
| 3 | Dead-letter queue is always empty | REAL | RedrivePolicy is attached to OrdersDLQ and deadLetterTargetArn is the source queue; OrdersQueue has no redrive policy, so nothing is ever moved to the DLQ. |
Raising reservedConcurrency to 50 would not fix any of the three symptoms. For an SQS FIFO event source, Lambda runs at most one batch at a time per MessageGroupId (Lambda Developer Guide, “Using Lambda with Amazon SQS”). Every message uses the group "orders", so effective concurrency is already 1 — the spare reserved slots, whether 1 or 49, stay idle. Duplicates are redelivery (visibility timeout and all-or-nothing batch retry), not a backlog. The stall is that single group’s in-flight/failed message blocking every subsequent receive; extra workers are not allowed to jump the group. The empty DLQ is an inverted redrive policy, which concurrency cannot attach. If visibility expires and a second receive happens, a higher ceiling can only create more overlapping invokes of the same records and make OPS-882 worse.
OPS-882 cause (1) — visibility shorter than the invoke
Wrong (serverless.yml):
VisibilityTimeout: 30
Replacement:
VisibilityTimeout: 720
Why: the queue visibility timeout must be at least the function timeout, and AWS documents setting it to six times the function timeout so a still-running invoke cannot have its records delivered again (Lambda Developer Guide, “Using Lambda with Amazon SQS”; 6 × 120s = 720s).
OPS-882 cause (2) — whole-batch retry after a successful email
Wrong (serverless.yml):
batchSize: 10
Replacement:
batchSize: 10
functionResponseType: ReportBatchItemFailures
Wrong (src/consumer.ts):
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 (let i = 0; i < event.Records.length; i++) {
const record = event.Records[i] as SQSRecord;
try {
const order = JSON.parse(record.body) as Order;
await processOrder(order);
} catch {
// FIFO: fail this record and every later one so order is preserved
for (const rest of event.Records.slice(i)) {
batchItemFailures.push({ itemIdentifier: rest.messageId });
}
break;
}
}
logger.info("batch complete", { count: event.Records.length });
return { batchItemFailures };
}
Why: with ReportBatchItemFailures, Lambda deletes only the records that succeeded and leaves the failed suffix on the queue (Lambda Developer Guide, “Using Lambda with Amazon SQS”, partial batch responses). Already-emailed orders are not delivered again just because a later order in the batch failed.
OPS-884 — one jam stops every customer
Wrong (src/producer.ts):
MessageGroupId: "orders",
Replacement:
MessageGroupId: order.id,
Why: FIFO returns no further messages for a group while one is in flight or visible-and-unprocessed (SQS Developer Guide, FIFO queue logic). Grouping by order.id keeps an order’s events in order (the reason given in the producer comment) and stops one poison order from blocking every other customer.
Empty DLQ — redrive is backwards
Wrong (serverless.yml, on OrdersDLQ):
RedrivePolicy:
deadLetterTargetArn: !GetAtt OrdersQueue.Arn
maxReceiveCount: 5
Replacement: delete those three lines from OrdersDLQ. Add this to OrdersQueue Properties:
RedrivePolicy:
deadLetterTargetArn: !GetAtt OrdersDLQ.Arn
maxReceiveCount: 5
Why: a redrive policy must be set on the source queue and must target the DLQ (SQS Developer Guide, “Amazon SQS dead-letter queues”). After maxReceiveCount receives, a poison message then leaves OrdersQueue instead of sitting at the head of the FIFO group until someone purges.
MessageDeduplicationId: randomUUID() (src/producer.ts) — FIFO’s 5-minute deduplication interval never collapses a retried SendMessage of the same order (SQS Developer Guide, FIFO deduplication), so producer-side retries enqueue a second job.
await chargeCard(order); (src/consumer.ts) — processOrder is not idempotent; the same redeliveries that double the confirmation email will charge the card again.