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 | VisibilityTimeout is 30s for a 120s Lambda, so an in-progress message can become visible and be delivered again; additionally, one record failure currently fails and retries the entire 10-record batch, including records whose emails were already sent. |
| 2 | One bad order stops the whole pipeline | REAL | Every message uses the single FIFO group "orders", so one poisoned message blocks every order; the source queue also has no DLQ redrive policy, so that poison message never escapes. |
| 3 | Dead-letter queue is always empty | REAL | RedrivePolicy is attached to OrdersDLQ, making OrdersQueue the DLQ for the DLQ; it must be attached to OrdersQueue and target OrdersDLQ. |
Raising reservedConcurrency to 50 would not fix any of these symptoms. Lambda can process FIFO messages concurrently only across distinct MessageGroupId values; this producer assigns every message to the one group, "orders", so the queue has effectively one active group and therefore at most one useful concurrent consumer. More reserved concurrency does not change the 30-second visibility timeout, does not make batch failures acknowledge successful records, and does not add a redrive policy to the source queue. After splitting groups by order, higher reserved concurrency could improve throughput, but it is not the fix for these failures.
1. Duplicate confirmation emails
In serverless.yml, replace:
VisibilityTimeout: 30
with:
VisibilityTimeout: 720
Why: AWS Lambda recommends an SQS visibility timeout of at least six times the function timeout for an SQS event source. Here that is 6 × 120 = 720 seconds. Thirty seconds permits a still-running invocation to lose visibility and have the same message received again.
In serverless.yml, replace:
batchSize: 10
with:
batchSize: 10
functionResponseType: ReportBatchItemFailures
In src/consumer.ts, replace:
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 });
}
with:
export async function handler(event: SQSEvent) {
logger.info("batch received", { count: event.Records.length });
const records = event.Records as SQSRecord[];
for (let index = 0; index < records.length; index += 1) {
const record = records[index];
try {
const order = JSON.parse(record.body) as Order;
await processOrder(order);
} catch {
return {
batchItemFailures: records
.slice(index)
.map(({ messageId }) => ({ itemIdentifier: messageId })),
};
}
}
logger.info("batch complete", { count: records.length });
return { batchItemFailures: [] };
}
Why: with ReportBatchItemFailures, Lambda deletes successful records and retries only the listed records. For FIFO queues, AWS requires processing to stop at the first failure and for the failed and unprocessed records to be returned as failures, preserving order within the message group.
2. One bad order stops the whole pipeline
In src/producer.ts, replace:
MessageGroupId: "orders",
with:
MessageGroupId: order.id,
Why: events for one order remain ordered because they share that order’s group, but a failed order no longer blocks unrelated orders.
In serverless.yml, under OrdersQueue.Properties, replace:
VisibilityTimeout: 30
MessageRetentionPeriod: 345600
with:
VisibilityTimeout: 720
MessageRetentionPeriod: 345600
RedrivePolicy:
deadLetterTargetArn: !GetAtt OrdersDLQ.Arn
maxReceiveCount: 5
In serverless.yml, under OrdersDLQ.Properties, delete:
RedrivePolicy:
deadLetterTargetArn: !GetAtt OrdersQueue.Arn
maxReceiveCount: 5
Why: SQS applies RedrivePolicy to the source queue. After more than maxReceiveCount: 5 receives, SQS moves the poisoned source message to OrdersDLQ, allowing the affected order group to proceed.
3. Dead-letter queue is always empty
Apply the same RedrivePolicy move above: remove it from OrdersDLQ and add it to OrdersQueue with OrdersDLQ as deadLetterTargetArn.
Why: the current policy configures the DLQ to dead-letter into the main queue, while the main queue has no configured dead-letter target.
src/producer.ts:
MessageDeduplicationId: randomUUID(),
A retry of the same logical publish gets a new deduplication ID, so FIFO deduplication cannot suppress it. SQS FIFO deduplicates matching deduplication IDs only within its five-minute deduplication interval; use a stable ID for the logical order event, not a newly generated UUID per send attempt.