AWS SQS · 2026-10-29 · SQS Advanced arc
SQS Advanced Patterns for MCP Server Monitoring Pipelines: FIFO Deduplication, Visibility Timeout, Long Polling, and Batch Operations
SQS becomes genuinely reliable for MCP monitoring pipelines only when you get four details right — and each one has a non-obvious failure mode that looks like correct operation until the first production incident. FIFO message deduplication prevents a probe pipeline from processing the same result twice when a producer retries a failed send — but the choice between ContentBasedDeduplication and explicit MessageDeduplicationId is wrong by default for almost every probe payload: ContentBasedDeduplication computes a SHA-256 hash of the raw message body and silently drops any message whose body is identical to one sent within the last 5 minutes — any payload that includes a timestamp, a probeRunId, or any non-deterministic field will never match its own hash and thus never be deduplicated (making the feature a no-op for retry protection), while two healthy-probe results for the same endpoint that happen to share an identical body content will be silently deduplicated, dropping one completely, and the 5-minute deduplication window is not configurable; for probe-result payloads, explicit MessageDeduplicationId keyed on {endpointId}-{probeRunId} — pre-computed before the first send and held stable across retries — is the only safe approach. Visibility timeout tuning determines whether a probe result is processed exactly once or duplicated when a consumer takes longer than expected — the critical constraint is that if a Lambda function's execution time exceeds the queue's visibility timeout, the message reappears in the queue while the original function is still running, creating a race condition on the verdict store and a duplicate alert; AWS's documented recommendation is to set visibility timeout to at least 6× the Lambda function timeout; Lambda's Event Source Mapping automatically calls ChangeMessageVisibility at roughly 2T/3 through the function execution, extending visibility without any consumer-side code, but this automatic extension fires only once per invocation and does not help for non-Lambda consumers — an ECS probe worker must implement a heartbeat that calls ChangeMessageVisibility every timeout/3 seconds and catches ReceiptHandleIsInvalid (the message was picked up by another consumer) to stop the loop. Long polling eliminates the dominant cost driver for continuously polling SQS queues — a monitoring pipeline that uses short polling (WaitTimeSeconds=0) on an idle queue between 60-second probe cycles generates thousands of billable empty ReceiveMessage calls per hour; long polling holds the connection open for up to 20 seconds, queries all SQS servers rather than a subset (eliminating false-empty responses on non-empty queues), and returns as soon as a message arrives; Lambda ESM already uses WaitTimeSeconds=20 by default so no configuration is needed for Lambda consumers; for ECS consumers, set either WaitTimeSeconds: 20 on each ReceiveMessage call or ReceiveMessageWaitTimeSeconds: 20 as a queue attribute default, and configure the HTTP client socket timeout to at least 25 seconds to prevent ECONNRESET errors on proxies with short idle timeouts. Batch operations reduce API cost and Lambda invocation count by an order of magnitude — sending 10 probe results as individual SendMessage calls costs 10× what a single SendMessageBatch costs (max 10 messages, 256 KB total), ReceiveMessage defaults to returning at most 1 message unless MaxNumberOfMessages: 10 is set explicitly (the most commonly missed default in all of SQS), and the critical operational detail for Lambda consumers is reportBatchItemFailures: without it, any unhandled exception in a Lambda processing a 10-message batch causes all 10 messages to retry — including the 9 that succeeded — which for FIFO queues means each failed item in a message group blocks all subsequent messages in that group until the failed item is either processed or transferred to the DLQ. This guide synthesises all four patterns. Pattern 1 covers FIFO deduplication — the 5-minute window, ContentBasedDeduplication failure modes, explicit ID format best practices, high-throughput FIFO scope, and DLQ replay constraints — synthesised from SQS FIFO Message Deduplication for MCP Servers. Pattern 2 covers visibility timeout — at-least-once delivery mechanics, Lambda 6× rule, ESM automatic extension behavior, ChangeMessageVisibility heartbeat for ECS consumers, and the 12-hour cap — synthesised from SQS Visibility Timeout for MCP Servers. Pattern 3 covers long polling — short vs long polling mechanics, WaitTimeSeconds configuration, Lambda ESM default, HTTP client timeout, and when short polling is actually correct — synthesised from SQS Long Polling for MCP Servers. Pattern 4 covers batch operations — SendMessageBatch limits, ReceiveMessage MaxNumberOfMessages, DeleteMessageBatch, Lambda ESM batchSize and maximumBatchingWindowInSeconds, reportBatchItemFailures response format, and FIFO partial failure blast radius — synthesised from SQS Batch Operations for MCP Servers.
TL;DR
- FIFO deduplication — explicit MessageDeduplicationId for probe payloads: Enable
ContentBasedDeduplicationonly if message bodies are fully deterministic (no timestamps, no UUIDs). Probe-result payloads always include aprobeRunIdortimestamp, making content-based deduplication a no-op for retry protection. Use explicitMessageDeduplicationIdkeyed on{endpointId}-{probeRunId}; pre-compute the ID before the first send and keep it stable across retries. The 5-minute deduplication window is not configurable — DLQ replay must wait for the window to expire. For high-throughput FIFO (DeduplicationScope=messageGroup), deduplication is scoped to the message group, so the same short ID can be reused across different endpoints. - Visibility timeout — 6× Lambda timeout, ESM handles it for Lambda: Set queue
visibilityTimeoutto at least 6× the Lambda function timeout (e.g., 30s function → 3-minute queue timeout). Lambda ESM automatically extends visibility at ~2T/3 through execution — no consumer-side code required. For ECS consumers, implement a heartbeat: callChangeMessageVisibilityeveryvisibilityTimeout/3seconds; catchReceiptHandleIsInvalidand stop the heartbeat loop if thrown. Maximum visibility timeout and maximum single extension are both 12 hours; the 12-hour cap is the total across all extensions for one receive attempt. - Long polling — WaitTimeSeconds=20, Lambda ESM already does this: Lambda ESM uses
WaitTimeSeconds=20by default — no configuration needed for Lambda consumers. For ECS or Node.js polling loops, setWaitTimeSeconds: 20per ReceiveMessage call orReceiveMessageWaitTimeSeconds: 20as the queue default. Set HTTP client socket timeout to 25+ seconds to avoid ECONNRESET on proxies with short idle timeouts. Short polling is appropriate only for non-blocking diagnostic checks (useGetQueueAttributesfor depth checks instead) or always-saturated high-throughput loops. Long polling does not change per-message pricing — it only eliminates empty-response API calls. - Batch operations — always batch, enable reportBatchItemFailures: Always set
MaxNumberOfMessages: 10on ReceiveMessage calls (default is 1). Always useDeleteMessageBatchinstead of individual DeleteMessage calls. Lambda ESM: setreportBatchItemFailures: trueand return{ batchItemFailures: [{ itemIdentifier: messageId }] }for per-message error handling — without it, any single exception retries the entire batch. FIFO queues with partial failures have higher blast radius: a failed item blocks all subsequent messages in the same message group.batchSizefor FIFO queues is capped at 10 (vs 10,000 for standard queues).
Pattern 1 — FIFO Deduplication: MessageDeduplicationId vs ContentBasedDeduplication
Why FIFO deduplication matters for MCP probe pipelines
When a probe worker sends a result to a FIFO queue and the HTTP connection drops before the acknowledgement arrives, the worker cannot distinguish a network failure (message was never delivered) from a timeout (message was delivered, ack was lost). The safe behavior is to retry the send. Without deduplication, that retry enqueues a second copy of the same probe result — which downstream consumers process twice, potentially firing a duplicate alert, double-counting a latency sample, or inserting two identical rows into a verdict store with slightly different processing timestamps.
SQS FIFO deduplication solves this by recording a deduplication token and the arrival time for every message. Any subsequent message with the same token received within 5 minutes of the first is silently accepted (the call returns success) but not enqueued. The consumer sees only one copy. After 5 minutes, the token expires and the same token is treated as a new, distinct message.
ContentBasedDeduplication: the silent no-op
When ContentBasedDeduplication is enabled on the queue, SQS computes a SHA-256 hash of the raw message body and uses that as the deduplication token automatically — the producer does not supply a MessageDeduplicationId. This works correctly only when message bodies are fully deterministic for logically identical events.
For MCP probe payloads, this is almost never the case. A probe result body typically contains a probeRunId (UUID generated per run), a timestamp (the moment the probe completed), and a latencyMs field (which varies between runs even for the same endpoint). Because each probe result is unique at the byte level, no two sends will ever produce the same SHA-256 hash — content-based deduplication is a complete no-op. The retry protection you think you have does not exist.
| Payload pattern | ContentBasedDeduplication safe? | Why |
|---|---|---|
Pure command: {"action":"ping","endpoint":"..."} (no timestamps, no UUIDs) | Yes | Same logical command always produces the same hash |
Probe result with probeRunId UUID field | No | New UUID per run — hash never matches between retries |
Probe result with timestamp field | No | Different millisecond per run — hash never matches |
| Hourly health summary (deterministic for given hour + endpoint) | Yes, with care | Same hour + endpoint always produces same body — but float precision differences can break this across architectures |
JSON with non-stable key ordering (JSON.stringify on an object whose properties were inserted in different orders) | No | Same logical payload serialises to different byte sequences → different hashes |
Explicit MessageDeduplicationId: format and stability requirements
An explicit MessageDeduplicationId is a string up to 128 characters. For MCP probe pipelines, use a composite key derived from the logical identity of the event — not from the message content and not from a new UUID generated at send time:
// Probe result: one per endpoint per run
const deduplicationId = `${endpointId}-${probeRunId}`;
// Example: "server-abc123-run-9f4e2b"
// Alert dispatch: one per endpoint per incident
const deduplicationId = `alert-${endpointId}-${incidentId}`;
// Example: "alert-server-abc123-inc-20261029T120000Z"
// Minute-aggregated verdict: one per endpoint per minute
const minute = new Date().toISOString().slice(0, 16).replace(':', '');
const deduplicationId = `verdict-${endpointId}-${minute}`;
// Example: "verdict-server-abc123-2026-10-29T1200"
The ID must be pre-computed before the first send and held stable across all retries of the same logical event. If a retry generates a new UUID component, it produces a different ID — the second copy enqueues, and deduplication is bypassed entirely. Store the ID alongside the probe result in your local state before attempting the first send.
DLQ replay and the 5-minute window
The deduplication window is fixed at exactly 5 minutes and cannot be configured. This creates a specific constraint for DLQ replay: if you replay a message from the DLQ within 5 minutes of the original send, SQS deduplicates it — the consumer never receives the replayed message. Wait for the 5-minute window to expire before replaying DLQ messages that should re-enter the pipeline.
One exception: if the original event's semantics have changed (e.g., you're replaying to correct a downstream bug, not to retry the original processing), use a different MessageDeduplicationId for the replay to force it through the window.
Pattern 2 — Visibility Timeout: Lambda 6× Rule, ESM Auto-Extension, Heartbeat for ECS
What visibility timeout controls
When a consumer calls ReceiveMessage, SQS hides that message from all other consumers for the visibilityTimeout duration. If the consumer calls DeleteMessage before the timeout expires, processing is complete and the message is permanently removed. If the consumer crashes or the timeout expires before deletion, the message reappears in the queue with its ApproximateReceiveCount incremented by one. Once that count exceeds the DLQ's maxReceiveCount, the message is automatically sent to the DLQ instead of re-queuing.
The core risk for MCP probe pipelines is the timeout-shorter-than-processing case: if a probe result takes 45 seconds to process (writing to the verdict store, computing state-machine transitions, dispatching downstream notifications) but the queue's visibility timeout is 30 seconds, the message reappears while the original consumer is still working. A second Lambda invocation picks it up, and both process the same probe result simultaneously — a race condition on the verdict store and a potential duplicate alert.
The 6× rule for Lambda consumers
AWS's documented recommendation: set queue visibilityTimeout to at least 6× the Lambda function timeout. The safety margin accounts for Lambda internal retries on infrastructure errors, cold-start overhead, and the ESM extension mechanics described below.
| Lambda function timeout | Recommended queue visibility timeout |
|---|---|
| 5 seconds | 30 seconds |
| 15 seconds | 90 seconds |
| 30 seconds | 3 minutes |
| 60 seconds | 6 minutes |
| 5 minutes | 30 minutes |
| 15 minutes (Lambda max) | 12 hours (use heartbeat instead) |
import { Queue, Duration } from 'aws-cdk-lib/aws-sqs';
import { SqsEventSource } from 'aws-cdk-lib/aws-lambda-event-sources';
const lambdaTimeout = Duration.seconds(30);
const probeQueue = new Queue(this, 'ProbeQueue', {
visibilityTimeout: Duration.seconds(lambdaTimeout.toSeconds() * 6), // 180s
deadLetterQueue: { queue: dlq, maxReceiveCount: 3 },
});
probeFunction.addEventSource(new SqsEventSource(probeQueue, {
batchSize: 1,
// Lambda ESM handles visibility extension automatically
}));
Lambda ESM automatic extension: what it does and what it does not
When Lambda processes SQS messages via an Event Source Mapping, it automatically calls ChangeMessageVisibility on each in-flight message at approximately 2T/3 through the function timeout (where T is the function timeout in seconds). This resets the visibility timeout to the queue's configured VisibilityTimeout value — not the remaining function execution time.
Three things Lambda ESM does not do:
- It does not call
ChangeMessageVisibilitymore than once per invocation. If your function runs longer than 2× the timeout (possible with certain async patterns or retries within the handler), the message may reappear. - It does not extend visibility for a batch's messages individually when they take varying amounts of time — all messages in the batch receive the extension at the same 2T/3 moment.
- It does not extend visibility when the function is terminating due to a timeout error — the message reappears as soon as the original timeout expires.
Heartbeat pattern for ECS consumers
For ECS-based probe workers or Node.js polling loops that consume SQS directly, ESM is not available — visibility extension must be implemented manually. The heartbeat pattern calls ChangeMessageVisibility every visibilityTimeout/3 seconds, resetting the timeout from the current moment on each call:
import { SQSClient, ChangeMessageVisibilityCommand, DeleteMessageCommand } from '@aws-sdk/client-sqs';
const sqs = new SQSClient({});
const VISIBILITY_TIMEOUT_SEC = 60;
const HEARTBEAT_INTERVAL_SEC = 20; // visibilityTimeout / 3
async function processWithHeartbeat(
queueUrl: string,
receiptHandle: string,
work: () => Promise<void>
): Promise<void> {
let heartbeatTimer: NodeJS.Timeout | undefined;
const heartbeat = async () => {
try {
await sqs.send(new ChangeMessageVisibilityCommand({
QueueUrl: queueUrl,
ReceiptHandle: receiptHandle,
VisibilityTimeout: VISIBILITY_TIMEOUT_SEC,
}));
} catch (err) {
// ReceiptHandleIsInvalid = message deleted by another consumer; stop
clearInterval(heartbeatTimer!);
}
};
heartbeatTimer = setInterval(heartbeat, HEARTBEAT_INTERVAL_SEC * 1000);
try {
await work();
await sqs.send(new DeleteMessageCommand({
QueueUrl: queueUrl,
ReceiptHandle: receiptHandle,
}));
} finally {
clearInterval(heartbeatTimer);
}
}
ReceiptHandleIsInvalid from the heartbeat call indicates the message was already deleted — either by this consumer (race between delete and the next heartbeat tick) or by another consumer after the visibility window expired. Either way, stop the heartbeat and stop processing.
Pattern 3 — Long Polling: Eliminating Empty-Response Cost
Short polling vs long polling mechanics
SQS distributes messages across multiple internal servers. A short-poll ReceiveMessage call (WaitTimeSeconds=0) samples only a subset of those servers and returns immediately with whatever messages were found — which may be zero even if messages exist on other servers. An idle probe-result queue between 60-second probe cycles generates dozens of short-poll calls per minute, each one empty, each one billed.
A long-poll call (WaitTimeSeconds=1–20) queries all SQS servers and holds the connection open until either a message arrives (returned immediately) or the wait window elapses. On an idle queue, this reduces empty responses from hundreds per minute to at most three per minute (one per 20-second window). The cost difference at scale is substantial: a single ECS consumer polling every second with short polling generates 3,600 billable requests per hour; with long polling at 20 seconds, it generates 180.
| Property | Short polling (WaitTimeSeconds=0) | Long polling (WaitTimeSeconds=1–20) |
|---|---|---|
| Servers sampled | Subset | All |
| False-empty on non-empty queue | Possible | Not possible |
| Cost on idle queue | High (one call per poll interval) | Low (one call per WaitTimeSeconds) |
| Latency when message arrives | Up to one poll interval | Sub-second |
| Blocks polling loop | No | Yes, for up to WaitTimeSeconds |
Configuration: queue attribute vs per-call override
There are two places to set WaitTimeSeconds. The queue attribute ReceiveMessageWaitTimeSeconds sets the default for all calls on the queue that do not supply an explicit value. The per-call WaitTimeSeconds parameter overrides the queue default for that specific call. Per-call takes precedence when both are set.
import { Queue, Duration } from 'aws-cdk-lib/aws-sqs';
import { SQSClient, ReceiveMessageCommand } from '@aws-sdk/client-sqs';
// Queue default: long polling
const probeQueue = new Queue(this, 'ProbeQueue', {
receiveMessageWaitTime: Duration.seconds(20),
visibilityTimeout: Duration.seconds(90),
});
// ECS consumer: omit WaitTimeSeconds to use queue default
const sqs = new SQSClient({
requestHandler: new NodeHttpHandler({
socketTimeout: 25_000, // 25s — above 20s long-poll max
connectionTimeout: 5_000,
}),
});
async function pollLoop(queueUrl: string): Promise<void> {
while (true) {
const resp = await sqs.send(new ReceiveMessageCommand({
QueueUrl: queueUrl,
MaxNumberOfMessages: 10,
AttributeNames: ['All'],
}));
if (!resp.Messages || resp.Messages.length === 0) continue;
await Promise.all(resp.Messages.map(processMessage));
}
}
Lambda ESM: no action needed
Lambda's Event Source Mapping uses WaitTimeSeconds=20 by default. This is built into the ESM implementation and is not a configurable property in CDK or CloudFormation. Lambda-triggered SQS queues generate at most ~3 empty ReceiveMessage calls per minute per polling process, which is negligible even at production scale with many concurrent Lambda executions.
When short polling is the right choice
Short polling is appropriate in exactly two situations. First, a non-blocking diagnostic check — for example, an API handler that returns approximate queue depth as part of a health check response. In this case, use GetQueueAttributes with ApproximateNumberOfMessages instead of ReceiveMessage; it is more accurate and does not consume a ReceiveMessage API call. Second, a high-throughput saturated queue where the queue is always expected to have messages: if the queue is never idle, empty responses are rare and the 20-second wait of long polling would add latency during brief inter-burst gaps.
For MCP probe pipelines, probe-result queues are idle between 60-second probe cycles. Long polling is almost always the correct choice.
Pattern 4 — Batch Operations: SendMessageBatch, DeleteMessageBatch, and reportBatchItemFailures
The cost case for batching
SQS charges per 64 KB chunk of payload per API call. A single SendMessage with a 1 KB probe-result body counts as one request. A SendMessageBatch with 10 × 1 KB bodies also counts as one request — a 10× reduction in SQS send cost. At scale, a probe cycle that monitors 500 MCP endpoints generates 500 probe results; sending them individually costs 500 API calls, batching them costs 50. The same economics apply to DeleteMessageBatch vs individual DeleteMessage calls.
The single most commonly missed default in all of SQS: ReceiveMessage returns at most 1 message unless MaxNumberOfMessages: 10 is set explicitly. A polling loop that omits this parameter is 10× less efficient than it could be.
import { SQSClient, SendMessageBatchCommand } from '@aws-sdk/client-sqs';
const sqs = new SQSClient({});
async function sendProbeResultBatch(
queueUrl: string,
results: ProbeResult[]
): Promise<void> {
for (let i = 0; i < results.length; i += 10) {
const chunk = results.slice(i, i + 10);
const resp = await sqs.send(new SendMessageBatchCommand({
QueueUrl: queueUrl,
Entries: chunk.map((result, idx) => ({
Id: `msg-${idx}`, // unique within batch
MessageBody: JSON.stringify(result),
MessageGroupId: result.endpointId, // FIFO: per-endpoint ordering
MessageDeduplicationId: `${result.endpointId}-${result.probeRunId}`,
})),
}));
// Handle partial send failures
if (resp.Failed && resp.Failed.length > 0) {
for (const failure of resp.Failed) {
if (failure.SenderFault) {
// Malformed message — fix the payload before retrying
console.error('SendMessageBatch SenderFault', failure);
} else {
// Service-side error — retry with exponential backoff
scheduleRetry(chunk[parseInt(failure.Id.replace('msg-', ''))]);
}
}
}
}
}
Lambda ESM: batchSize, batching window, and FIFO limits
Lambda ESM adds two batch controls beyond the standard SQS batch API:
batchSize: how many messages Lambda delivers per invocation. Range for standard queues: 1–10,000. Range for FIFO queues: 1–10 (FIFO cap). The default is 10.maximumBatchingWindowInSeconds: how long Lambda waits to accumulate a full batch before invoking, regardless of how many messages are available. Range: 0–300 seconds. Default is 0 (invoke immediately when messages arrive).
For probe-result pipelines where low latency matters — you want to process results and update verdicts as soon as they arrive — keep maximumBatchingWindowInSeconds=0. A non-zero batching window reduces Lambda invocation count at the cost of increased probe-result processing latency.
reportBatchItemFailures: the mandatory partial-failure pattern
By default, when an unhandled exception escapes a Lambda processing an SQS batch, Lambda treats the entire batch as failed. All 10 messages become visible again and are retried — including the 9 that processed successfully. This causes duplicate alert firing, duplicate verdict insertions, and wasted Lambda invocations for the 9 successful messages.
Enabling reportBatchItemFailures via CDK (reportBatchItemFailures: true on the event source) lets the Lambda handler return a structured response identifying which specific message IDs failed. Only those messages are retried:
import { SQSEvent, SQSBatchResponse, SQSBatchItemFailure } from 'aws-lambda';
export const handler = async (event: SQSEvent): Promise<SQSBatchResponse> => {
const failures: SQSBatchItemFailure[] = [];
for (const record of event.Records) {
try {
await processProbeResult(JSON.parse(record.body));
} catch (err) {
console.error('Failed to process', record.messageId, err);
failures.push({ itemIdentifier: record.messageId });
// Do NOT rethrow — let remaining messages succeed
}
}
return { batchItemFailures: failures };
// Empty array = all messages deleted (success)
// Non-empty array = only listed messageIds retried
// Throw = all messages retried (equivalent to pre-reportBatchItemFailures behavior)
};
Critical rules:
- Never rethrow inside the per-message loop — catch errors per record and push the
messageIdto the failures array. - The
itemIdentifiermust match themessageIdfrom the SQS record exactly (not theMessageDeduplicationId, not a custom ID). - For FIFO queues, a failed item blocks all subsequent messages in the same message group until the failed item is processed or moved to the DLQ — partial failures in FIFO have a higher blast radius than in standard queues.
- Alarm on FIFO DLQ depth
> 0: a message in the DLQ for a FIFO queue means all subsequent messages in that message group are silently queued behind the blocked item.
Putting it together: a production-ready SQS configuration for MCP probe pipelines
The four patterns compose into a single CDK stack configuration. Each decision has a specific reason grounded in the failure modes above:
import { Queue, QueueEncryption, Duration } from 'aws-cdk-lib/aws-sqs';
import { Function, Runtime } from 'aws-cdk-lib/aws-lambda';
import { SqsEventSource } from 'aws-cdk-lib/aws-lambda-event-sources';
const lambdaTimeout = Duration.seconds(15);
const probeResultQueue = new Queue(this, 'ProbeResultQueue', {
queueName: 'mcp-probe-results.fifo',
fifo: true,
contentBasedDeduplication: false, // probe payloads have probeRunId — use explicit ID
visibilityTimeout: Duration.seconds(lambdaTimeout.toSeconds() * 6), // 6× rule: 90s
receiveMessageWaitTime: Duration.seconds(20), // long polling default
encryption: QueueEncryption.KMS_MANAGED,
deadLetterQueue: {
queue: probeResultDlq,
maxReceiveCount: 3, // 3 attempts before DLQ
},
});
probeResultFunction.addEventSource(new SqsEventSource(probeResultQueue, {
batchSize: 10, // receive up to 10 per invocation
maxBatchingWindow: Duration.seconds(0), // invoke immediately — latency matters
reportBatchItemFailures: true, // retry only failed messages, not entire batch
}));
And the corresponding probe worker that sends to this queue with all four patterns applied:
import { SQSClient, SendMessageBatchCommand } from '@aws-sdk/client-sqs';
const sqs = new SQSClient({});
async function publishProbeResults(
queueUrl: string,
results: ProbeResult[]
): Promise<void> {
for (let i = 0; i < results.length; i += 10) {
const chunk = results.slice(i, i + 10);
await sqs.send(new SendMessageBatchCommand({
QueueUrl: queueUrl,
Entries: chunk.map((r, idx) => ({
Id: `msg-${idx}`,
MessageBody: JSON.stringify(r),
MessageGroupId: r.endpointId,
// Pre-computed before first send; stable across retries
MessageDeduplicationId: `${r.endpointId}-${r.probeRunId}`,
})),
}));
}
}
This configuration eliminates the four most common SQS failure modes in MCP monitoring pipelines: duplicate probe processing from producer retries (handled by explicit deduplication ID), duplicate processing from consumer races (handled by 6× visibility timeout and ESM auto-extension), unnecessary API cost from empty polls (handled by long polling default on queue), and blast-radius amplification from batch failures (handled by reportBatchItemFailures).
Common failure modes across all four patterns
| Symptom | Pattern | Cause | Fix |
|---|---|---|---|
| Same probe result processed twice despite FIFO queue | Deduplication | Retry generates a new UUID component in MessageDeduplicationId — two different IDs, both enqueue | Pre-compute ID before first send, store it, reuse on retry |
| DLQ replay silently dropped | Deduplication | Replaying within 5-minute deduplication window — SQS accepts the send but does not enqueue | Wait 5 minutes before replaying DLQ messages |
| Duplicate alert from same probe result | Visibility timeout | Visibility timeout shorter than processing time — message reappears while first consumer is still running | Increase visibility timeout to 6× Lambda timeout; reduce function timeout if too long |
Heartbeat fails with ReceiptHandleIsInvalid | Visibility timeout | Message deleted by another consumer after visibility window expired | Catch this error specifically in the heartbeat and stop the loop — the message was processed elsewhere |
| High SQS cost despite low message volume | Long polling | Polling loop using WaitTimeSeconds=0 on idle queue | Set WaitTimeSeconds=20 on ReceiveMessage or ReceiveMessageWaitTimeSeconds=20 on queue |
ECONNRESET on ReceiveMessage during long poll | Long polling | HTTP client socket timeout shorter than WaitTimeSeconds | Set socket timeout to 25+ seconds in the SQS client HTTP handler |
| 9 successful messages retried when 1 fails | Batch operations | reportBatchItemFailures not enabled — Lambda throws on any error, retrying entire batch | Enable reportBatchItemFailures: true and return per-message failure array |
| FIFO message group stalled after one failure | Batch operations | Failed item reached DLQ — all subsequent messages in that group are blocked | Alarm on FIFO DLQ depth > 0; add manual redrive consumer to unblock the group |