Module 6 ended with an uncomfortable diagnosis. MercadoFresco's data layer is well distributed —Aurora for orders and stock, DynamoDB for baskets and sessions, Redshift for Sara's reports, ElastiCache for the catalogue— but the request that confirms an order still does eight things in a row before answering. Marta measured it at the Friday peak: 2,900 ms median, 9,100 ms at the 95th percentile, and 0.4 % of orders that fail after the card has been charged because the warehouse ERP returned a 503.

Amazon SQS (Simple Queue Service) breaks that chain. It is a managed message queue: one component writes a message, another reads it when it can, and neither needs the other to be alive at that instant. It is the oldest AWS service and also the most boring in the best sense: no servers, no versions, no capacity to size, and it scales from zero to millions of messages a second without anybody touching anything. Here Luis turns the eight chained calls into a 400 ms confirmation plus seven tasks that happen afterwards, and learns along the way the concept that causes the most incidents in production: the visibility timeout.

Cost warning. SQS charges per API request: 1 million free a month permanently and 0.40 USD per additional million on standard queues (0.50 on FIFO). An empty queue costs nothing, but a consumer using short polling can generate hundreds of millions of empty requests a month. At the end you have the cleanup commands. All data is fictitious.

Contents

  1. Synchronous versus asynchronous: what MercadoFresco gains
  2. Anatomy of a queue and the polling model
  3. Standard queues and FIFO queues
  4. Which queue each MercadoFresco task uses
  5. The life cycle of a message
  6. The visibility timeout, in detail
  7. Short polling and long polling
  8. Retention, delay, timers and large payloads
  9. Dead-letter queues and redrive
  10. Creating the queues with the CLI, queue policy and encryption
  11. Producer and consumer in Python
  12. Consuming from Lambda: event source mapping
  13. Metrics, alarms and autoscaling by queue depth
  14. Real cost and cleanup
  15. Common mistakes and tips
  16. Exercises
  17. Conclusion

Synchronous versus asynchronous: what MercadoFresco gains

A synchronous call is one where the caller waits for the answer before carrying on. There is nothing wrong with it as long as it is used for what it is meant for: getting a piece of data you need now in order to answer. The problem appears when you chain synchronous calls to things you do not need now. These are MercadoFresco's eight tasks, measured at the Friday peak:

# Task Destination p50 p95 Does the customer need the result?
1 Charge the card External gateway 240 ms 3,100 ms Yes
2 Write the order Aurora 38 ms 60 ms Yes
3 Empty the basket DynamoDB 11 ms 22 ms No
4 Invalidate the cache ElastiCache 4 ms 9 ms No
5 Notify the warehouse ERP Partner HTTP 1,180 ms 6,500 ms No
6 Confirmation email SMTP provider 820 ms 4,200 ms No
7 Notify the delivery driver Partner API 410 ms 2,900 ms No
8 Publish the analytics event Firehose 190 ms 700 ms No

Only the first two are essential in order to say "your order is confirmed". The remaining six are consequences of the order, not requirements, and yet the customer pays for them in waiting time and in risk. The sequential sum comes to 2,893 ms median, but the serious part is the multiplicative fragility: if each task is 99.9 % available, the chain of eight is 99.2 %. At 900 orders an hour at the peak, that is seven broken orders an hour, all of them already charged.

flowchart TD
    C[Customer presses Confirm order] --> A[Shop server]
    A --> P1[1. Payment gateway 240 ms]
    P1 --> P2[2. Aurora INSERT 38 ms]
    P2 --> P3[3. DynamoDB empty basket 11 ms]
    P3 --> P4[4. ElastiCache invalidate 4 ms]
    P4 --> P5[5. Warehouse ERP 1,180 ms]
    P5 --> P6[6. Confirmation email 820 ms]
    P6 --> P7[7. Delivery driver API 410 ms]
    P7 --> P8[8. Analytics event 190 ms]
    P8 --> R[Response to the customer 2,893 ms p50]
    P5 -. 503 from the ERP .-> X[Order charged and lost]
    style X fill:#f8d7da,stroke:#c00
    style R fill:#fff3cd

An asynchronous call breaks the chaining: the shop records that there is work pending and answers. The work is done afterwards, by another process, with its own retries.

flowchart TD
    C[Customer presses Confirm order] --> A[Shop server]
    A --> P1[1. Payment gateway 240 ms]
    P1 --> P2[2. Aurora INSERT 38 ms]
    P2 --> Q[(cola-mercadofresco-pedidos<br/>SendMessage 12 ms)]
    Q --> R[Response to the customer ~400 ms p95]
    Q --> W1[Warehouse consumer]
    Q --> W2[Email consumer]
    Q --> W3[Analytics consumer]
    W1 --> ERP[Warehouse ERP]
    W2 --> SMTP[Email provider]
    W3 --> FH[Firehose and S3]
    ERP -. 503 .-> RT[Automatic retry:<br/>the message stays in the queue]
    style R fill:#d4edda
    style RT fill:#fff3cd

What is gained is not just speed: the ERP's failure stops being the order's failure. The message stays in the queue, the consumer retries it, and if the ERP takes two hours to come back, the orders from those two hours are processed when it does. The customer never finds out.

Anatomy of a queue and the polling model

Four concepts and no more. The producer sends messages; in MercadoFresco, the shop on asg-mercadofresco-tienda, which only needs sqs:SendMessage and the queue URL. The queue is the durable store: SQS replicates every message across several servers in several AZs of eu-west-1 before acknowledging the send, and it has no maximum size and no capacity to provision. The message has a body (Body) of up to 256 KiB of text, up to 10 typed message attributes that travel outside the body, and system attributes (MessageId, SentTimestamp, ApproximateReceiveCount). The consumer reads, works and deletes; there can be one or a thousand without treading on each other.

The distinction between body and attributes matters: the attributes are inspected without deserialising the body and —crucially in 07-02— SNS filters by attributes. MercadoFresco's rule: attributes carry what serves to decide what to do with the message; the body carries the data.

{
  "MessageAttributes": {
    "tipo_evento":    { "DataType": "String", "StringValue": "PedidoConfirmado" },
    "franja_entrega": { "DataType": "String", "StringValue": "24h" },
    "version":        { "DataType": "Number", "StringValue": "1" }
  },
  "MessageBody": "{\"pedido_id\":\"PED-2026-084417\",\"cliente_id\":\"CLI-30912\",\"importe_eur\":48.20,\"franja_entrega\":\"24h\",\"lineas\":[{\"sku\":\"FRUT-FRES-011\",\"unidades\":2}],\"confirmado_en\":\"2026-08-02T18:41:07Z\"}"
}

The body is a string, not an object: SQS does not know what is inside. Serialisation and versioning of the contract are up to you, and we will come back to that in 07-03.

Polling (pull) versus push. This is the characteristic that defines SQS: it never calls anybody. There is no way of telling it "when a message arrives, send it to this URL". It is the consumer that asks.

Polling (SQS) Push (SNS, webhooks)
Who initiates The consumer asks The service delivers
The pace is set by The consumer The producer
If the consumer is down The messages wait They are lost or retried blindly
Natural backpressure Yes: you read what you can No: what arrives, arrives
Needs a public endpoint No Yes (except internal AWS destinations)
Retry Implicit: if you do not delete, it comes back Configured by the sender

The architectural consequence is enormous: the queue acts as a buffer. If 900 orders/hour come in and the consumers process 600, nothing is lost: the queue grows and by 22:00 it has emptied. That property —backpressure— is why queues are still the basic piece of integration, and we will come back to it in 07-05.

The apparent exception is Lambda: when you connect a queue to a function it looks as though SQS "pushes". It does not; the Lambda service maintains its own pollers that call ReceiveMessage for you. The model is still polling, only the poller is provided by AWS.

Standard queues and FIFO queues

Standard queue FIFO queue
Name Free Must end in .fifo
Order Best-effort: nearly always, not guaranteed Strict within each group
Delivery At least once (may duplicate) Exactly once within the deduplication window
Throughput Practically unlimited 300 msg/s (3,000 with batches); high throughput: tens of thousands
Groups Do not exist MessageGroupId mandatory
Deduplication No By MessageDeduplicationId or body hash, 5 min window
Parallelism Total One message in flight per group
Price 0.40 USD/million 0.50 USD/million

Three ideas worth internalising. "At least once" means there will be duplicates: it is not theoretical, SQS stores each message on several servers and occasionally one of them does not find out that it has already been deleted and redelivers it. If your consumer charges a card, a duplicate means charging twice, and the solution is not FIFO but the consumer's idempotency (07-05).

FIFO's "exactly once" comes with small print: it only acts within a 5-minute window and only covers the duplicate sending of the same message; if the consumer processes and dies before deleting, the message comes back. FIFO greatly reduces the probability of a duplicate; it does not eliminate it.

Groups are the unit of ordering and of parallelism at once. To guarantee order, SQS does not deliver a second message from a group until the first has been deleted. A single group for the whole queue gives global order and one effective consumer; the sku as the group gives order per product and as much parallelism as products. It is the same decision as picking a partition key in DynamoDB (06-02).

Which queue each MercadoFresco task uses

Task Queue Type Why
Prepare the box cola-mercadofresco-almacen Standard The warehouse reconciles by pedido_id; order between orders does not matter
Confirmation email cola-mercadofresco-correo Standard Duplicating an email is annoying, not catastrophic; it is deduplicated in the consumer
Notify the delivery driver cola-mercadofresco-reparto Standard Likewise
Analytics event cola-mercadofresco-analitica Standard Redshift aggregates; duplicates are filtered on load
Stock movements cola-mercadofresco-stock.fifo FIFO, group = sku "Reserve 2" and "release 2" for the same SKU must go in order
Photo thumbnails cola-mercadofresco-miniaturas Standard Regenerating a thumbnail is idempotent by nature

Only one of the six needs FIFO, and it is precisely the one that touches counters. That is the usual situation: most workloads do not need global ordering, they need an idempotent consumer. Paying the price of FIFO when it is not needed is an expensive design mistake.

In this lesson Luis starts with the minimum viable: cola-mercadofresco-pedidos collects all the work that follows confirmation, and cola-mercadofresco-correo separates out email sending, the slowest consumer and the one that fails most. In 07-02 we will see why that single queue ends up a problem.

The life cycle of a message

sequenceDiagram
    participant P as Producer (shop)
    participant Q as cola-mercadofresco-pedidos
    participant C as Consumer
    P->>Q: SendMessage(Body, MessageAttributes)
    Q-->>P: MessageId (replicated and durable)
    Note over Q: State: visible
    C->>Q: ReceiveMessage(Max=10, WaitTimeSeconds=20)
    Q-->>C: Messages + ReceiptHandle
    Note over Q: State: in flight (invisible)<br/>for VisibilityTimeout
    alt Work succeeds
        C->>Q: DeleteMessage(ReceiptHandle)
        Note over Q: The message disappears
    else The consumer fails or dies
        Note over Q: The visibility timeout expires
        Note over Q: Visible again<br/>ApproximateReceiveCount += 1
        Q-->>C: It is delivered again
    end

Three states and only three: visible, in flight and deleted. That explains the whole of SQS's behaviour, including the parts that look like service failures. The part that always surprises: receiving a message does not remove it. SQS delivers it and hides it from other consumers, but it is still there; if your process dies halfway, if the container restarts, if the ASG shuts the instance down, the message reappears and another consumer takes it. That is the durability guarantee.

The corollary: not deleting is reprocessing. If the consumer raises an exception and never reaches the DeleteMessage, the message comes back. And if it takes longer than the visibility timeout it will come back even when it ends well, doing the work twice: the top cause of duplicates in production.

The visibility timeout, in detail

The VisibilityTimeout is the number of seconds a message stays invisible after being received. By default 30 s; maximum 12 hours. The rule is a single sentence:

The visibility timeout must be longer than the time the slowest consumer takes to process the message and delete it.

Let us see what happens if it is not. The email consumer calls an SMTP that takes 42 s at the p99, and the visibility timeout is at the default 30:

t Consumer A Consumer B Message state
0 s Receives PED-084417 In flight
5 s Calls the SMTP In flight
30 s Still waiting Becomes visible again
31 s Still waiting Receives the same message In flight (for B)
42 s SMTP answers, DeleteMessage Calls the SMTP Deleted
73 s DeleteMessageReceiptHandleIsInvalid

The customer has received two emails. And the failure is silent, hard to reproduce in tests and only shows up under load. There are three ways of configuring it:

1. On the queue, as the default value:

aws sqs set-queue-attributes \
  --queue-url https://sqs.eu-west-1.amazonaws.com/111122223333/cola-mercadofresco-correo \
  --attributes VisibilityTimeout=90 --profile mercadofresco-dev --region eu-west-1

2. On reception, passing VisibilityTimeout to ReceiveMessage, useful when the same consumer handles jobs of widely differing cost.

3. Extending it as you go (heartbeat). If the work can last a long time and you do not know how long, do not set 12 hours: set a short value and extend it as you work with ChangeMessageVisibility. If the process dies, the message comes back in seconds instead of in hours.

def heartbeat(receipt_handle, stop):
    """Extends visibility to 60 s every 30 s while the work is still in progress."""
    while not stop.wait(30):
        try:
            sqs.change_message_visibility(QueueUrl=QUEUE, ReceiptHandle=receipt_handle,
                                          VisibilityTimeout=60)  # 60 s FROM NOW
        except sqs.exceptions.ReceiptHandleIsInvalid:
            break                       # already deleted or expired: nothing to extend

def process_with_heartbeat(message):
    stop = threading.Event()
    thread = threading.Thread(target=heartbeat, args=(message["ReceiptHandle"], stop), daemon=True)
    thread.start()
    try:
        prepare_box_in_warehouse(message["Body"])   # from 2 s to 8 minutes
        sqs.delete_message(QueueUrl=QUEUE, ReceiptHandle=message["ReceiptHandle"])
    finally:
        stop.set()                                  # stops the heartbeat whatever happens
        thread.join(timeout=2)

Two details: VisibilityTimeout=60 means "invisible for 60 seconds from this moment", not "add 60 to what was left"; and the total accumulated since the first reception cannot exceed 12 hours. The same command serves to return a message immediately with VisibilityTimeout=0 when you detect that you cannot process it now, or to postpone it by five minutes with 300.

Short polling and long polling

With short polling (WaitTimeSeconds=0, the default value on a freshly created queue) SQS queries a subset of servers and answers immediately, even if with an empty list. You can get an empty response with messages in the queue, and your loop generates requests at full speed. With long polling (1 to 20 s) SQS queries all the servers and keeps the connection open until there is a message or the wait runs out.

Short polling Long polling (20 s)
Empty responses with a non-empty queue Possible No
Requests/hour from an idle consumer Up to ~360,000 180
Latency when a message arrives Until the next poll Practically immediate
Monthly cost of 4 idle consumers ~415 USD ~0.21 USD
When to use it Almost never Always

The 415 USD are not rhetoric: four processes polling in a loop generate of the order of a billion requests a month. It is the most expensive rookie mistake in SQS and it shows up in Cost Explorer (11-03) as a disproportionate line. Configure it on the queue and on every call:

aws sqs set-queue-attributes \
  --queue-url https://sqs.eu-west-1.amazonaws.com/111122223333/cola-mercadofresco-pedidos \
  --attributes ReceiveMessageWaitTimeSeconds=20 --profile mercadofresco-dev --region eu-west-1

If you use long polling, raise the HTTP client's read timeout above 20 seconds or the SDK will cut the connection short.

Retention, delay, timers and large payloads

Attribute Range Default Use in MercadoFresco
MessageRetentionPeriod 60 s – 14 days 4 days 14 days on the DLQs, 4 on the normal ones
Queue DelaySeconds 0 – 15 min 0 10 s on cola-mercadofresco-correo
Per-message DelaySeconds 0 – 15 min 900 s on "your order is leaving the warehouse"
MaximumMessageSize 1 KiB – 256 KiB 256 KiB Default

Retention. A message that nobody deletes is removed when the period is up. It is a safety net, not a feature: if your messages are expiring, you have a stopped consumer and an alarm that did not fire. On the DLQs the maximum is used because there the message waits for a human to look at it.

Delay queue. With DelaySeconds at queue level, all messages are born invisible. Luis uses it on the email queue with 10 seconds: it gives the Aurora write time to replicate to aurora-mf-lector-1/-2 before the consumer reads the order. Without that margin, 0.3 % of the emails went out with "order not found".

Per-message timer. The same effect but per message, with DelaySeconds in SendMessage. It is not available on FIFO queues, where only the queue-level delay exists.

Large payloads. 256 KiB is a lot for an order and little for a PDF invoice. The solution is not to compress: it is the Extended Client Library, which stores the payload in S3 and sends through the queue only a pointer {"s3_bucket": ..., "s3_key": ...}. By hand it is three lines: a put_object in mercadofresco-informes-analitica and a send_message with the reference. Watch out for the life cycle: if the consumer deletes the message and nobody deletes the object, you pay for storage indefinitely; an S3 lifecycle rule (02-03) with expiry at 7 days solves it.

Dead-letter queues and redrive

A message the consumer cannot process goes back to the queue. If the cause is transient —the ERP down— that is exactly what you want. If it is permanent —malformed JSON, a non-existent sku, a None where there should have been a number— the message will come back for ever, consuming requests, blocking ordering on FIFO queues and polluting the logs. It is a poison message (poison pill).

The dead-letter queue (DLQ) is a normal queue to which SQS moves messages received too many times. It is configured with a redrive policy on the source queue:

{
  "deadLetterTargetArn": "arn:aws:sqs:eu-west-1:111122223333:mercadofresco-miniaturas-fallidas",
  "maxReceiveCount": 3
}

maxReceiveCount counts receptions, not failures: with 3, the message is attempted three times and on the fourth reception it is moved.

maxReceiveCount Effect
1 No retries: any transient failure sends the message to the DLQ. Almost never right
3 – 5 Recommended: absorbs short outages and isolates poison messages quickly
50+ The poison message is retried for hours; the logs fill with noise

Here we formalise mercadofresco-miniaturas-fallidas, which appeared in module 2 alongside mercadofresco-generar-miniaturas and was never fully configured. The architecture ends up like this: uploading a photo to mercadofresco-catalogo-fotos makes S3 publish to cola-mercadofresco-miniaturas; the Lambda consumes; if it fails three times, the message ends up in mercadofresco-miniaturas-fallidas.

aws sqs set-queue-attributes \
  --queue-url https://sqs.eu-west-1.amazonaws.com/111122223333/cola-mercadofresco-miniaturas \
  --attributes "{\"RedrivePolicy\":\"{\\\"deadLetterTargetArn\\\":\\\"$DLQ_ARN\\\",\\\"maxReceiveCount\\\":\\\"3\\\"}\"}" \
  --profile mercadofresco-dev --region eu-west-1

That triple escaping is as ugly as it looks: RedrivePolicy is a JSON inside a string inside another JSON. In 09-01 we will write it in CloudFormation and the problem disappears.

What to look at when something lands in the DLQ, in this order: (1) the ApproximateReceiveCount —if it is exactly maxReceiveCount + 1, it exhausted its retries—; (2) the body: valid JSON? expected fields? known contract version?; (3) the consumer's CloudWatch logs filtered by the MessageId. This is where putting the MessageId on every log line pays off.

Redrive. Once the fault is fixed, there is no need to resend by hand:

aws sqs start-message-move-task \
  --source-arn arn:aws:sqs:eu-west-1:111122223333:mercadofresco-miniaturas-fallidas \
  --max-number-of-messages-per-second 20 --profile mercadofresco-dev --region eu-west-1

Without --destination-arn the messages go back to their source queue. The rate limit is not optional in practice: reprocessing 40,000 messages at once can bring down a system that was already fragile. In 07-05 we will write Marta's complete runbook.

MercadoFresco's hard rule: every queue has a DLQ, and every DLQ has an alarm. A DLQ without an alarm is a bin where sales get lost in silence.

Creating the queues with the CLI, queue policy and encryption

We start with the DLQ, because the main queue needs its ARN.

export PROFILE="--profile mercadofresco-dev --region eu-west-1"
export TAGS='Proyecto=mercadofresco,Entorno=produccion,Componente=integracion,Propietario=marta,CentroCoste=tecnologia'

aws sqs create-queue --queue-name mercadofresco-pedidos-fallidos \
  --attributes MessageRetentionPeriod=1209600 --tags "$TAGS" $PROFILE
DLQ=$(aws sqs get-queue-attributes \
  --queue-url https://sqs.eu-west-1.amazonaws.com/111122223333/mercadofresco-pedidos-fallidos \
  --attribute-names QueueArn --query 'Attributes.QueueArn' --output text $PROFILE)

aws sqs create-queue --queue-name cola-mercadofresco-pedidos --tags "$TAGS" $PROFILE \
  --attributes "{\"VisibilityTimeout\":\"120\",\"MessageRetentionPeriod\":\"345600\",
    \"ReceiveMessageWaitTimeSeconds\":\"20\",\"KmsMasterKeyId\":\"alias/mercadofresco-datos\",
    \"KmsDataKeyReusePeriodSeconds\":\"300\",
    \"RedrivePolicy\":\"{\\\"deadLetterTargetArn\\\":\\\"$DLQ\\\",\\\"maxReceiveCount\\\":\\\"4\\\"}\"}"

aws sqs create-queue --queue-name cola-mercadofresco-correo --tags "$TAGS" $PROFILE \
  --attributes '{"VisibilityTimeout":"90","DelaySeconds":"10",
                 "ReceiveMessageWaitTimeSeconds":"20",
                 "KmsMasterKeyId":"alias/mercadofresco-datos"}'

Every value answers to a measurement: VisibilityTimeout=120 on orders because the ERP consumer takes 6.5 s at the p95 and 38 s in the worst case observed; 90 on email because the SMTP reaches 42 s at the p99; maxReceiveCount=4 for three real retries before isolating; and KmsDataKeyReusePeriodSeconds=300, which makes SQS reuse the data key for 5 minutes and drastically reduces the calls to KMS without appreciably compromising security. The FIFO stock queue is created differently:

aws sqs create-queue --queue-name cola-mercadofresco-stock.fifo \
  --attributes '{"FifoQueue":"true","ContentBasedDeduplication":"false",
                 "DeduplicationScope":"messageGroup","FifoThroughputLimit":"perMessageGroupId",
                 "VisibilityTimeout":"60","ReceiveMessageWaitTimeSeconds":"20"}' \
  --tags "$TAGS" $PROFILE

DeduplicationScope=messageGroup with FifoThroughputLimit=perMessageGroupId enable high throughput mode: the limit now applies per group instead of per queue. With the sku as the group and 3,400 SKUs, the parallelism is more than enough.

The two layers of authorisation. The identity policy (IAM, 04-01) attaches to the role that calls. The queue policy (resource-based) attaches to the queue and is indispensable when whoever sends is not an IAM principal of your account: another AWS service (SNS, S3, EventBridge) or another account.

{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "PermitirAvisosDeS3Catalogo",
    "Effect": "Allow",
    "Principal": { "Service": "s3.amazonaws.com" },
    "Action": "sqs:SendMessage",
    "Resource": "arn:aws:sqs:eu-west-1:111122223333:cola-mercadofresco-miniaturas",
    "Condition": {
      "ArnLike":      { "aws:SourceArn": "arn:aws:s3:::mercadofresco-catalogo-fotos" },
      "StringEquals": { "aws:SourceAccount": "111122223333" }
    }
  }]
}

aws:SourceArn limits the permission to that bucket; without it, any S3 bucket in the world could write to your queue. aws:SourceAccount closes off the confused deputy problem, in which an AWS service acts legitimately on behalf of a third party (04-01).

Encryption. SQS offers SSE-SQS (AWS keys, free, on by default on new queues) and SSE-KMS with your own key. MercadoFresco uses alias/mercadofresco-datos on the queues carrying personal data —orders carry the delivery address— for consistency with module 4 and because the KMS calls are recorded in trail-mercadofresco. Watch out for the detail that breaks deployments: if the queue is encrypted with KMS, the producer needs kms:GenerateDataKey and the consumer kms:Decrypt on the key, not just on the queue; and if the producer is an AWS service, the key policy must allow it.

Producer and consumer in Python

import json, os
from datetime import datetime, timezone
import boto3
from botocore.config import Config

# A single reused client: creating one per request resolves credentials and opens
# new connections every time, a classic performance mistake.
sqs = boto3.client("sqs", config=Config(
    region_name="eu-west-1",
    retries={"max_attempts": 5, "mode": "standard"},  # retries on 5xx and throttling
    connect_timeout=2, read_timeout=5,                # bound the worst case of the send
))
ORDERS_QUEUE = os.environ["URL_COLA_PEDIDOS"]


def confirm_order(basket, customer, card):
    """Confirms an order: charges, persists and enqueues. Nothing else."""
    charge = gateway.charge(card, basket.importe_eur)                    # essential
    pedido_id = aurora.insert_order(basket, customer, charge.reference)  # essential
    body = {
        "version": 1, "pedido_id": pedido_id, "cliente_id": customer.id,
        "importe_eur": float(basket.importe_eur),
        "franja_entrega": basket.franja,           # "24h" or "estandar"
        "referencia_cobro": charge.reference,
        "lineas": [{"sku": l.sku, "unidades": l.unidades} for l in basket.lineas],
        "confirmado_en": datetime.now(timezone.utc).isoformat(),
    }
    r = sqs.send_message(
        QueueUrl=ORDERS_QUEUE,
        MessageBody=json.dumps(body, ensure_ascii=False),
        MessageAttributes={
            "tipo_evento":    {"DataType": "String", "StringValue": "PedidoConfirmado"},
            "franja_entrega": {"DataType": "String", "StringValue": basket.franja},
            "version":        {"DataType": "Number", "StringValue": "1"},
        },
    )
    log.info("order enqueued", extra={"pedido_id": pedido_id, "message_id": r["MessageId"]})
    return pedido_id

Three decisions worth commenting on. The send_message comes after the INSERT: if it were enqueued first and the INSERT failed, there would be a message announcing an order that does not exist. In this order the possible failure is the opposite one —order written and message not sent—, less serious but real, and it has a name: the dual write problem, which we will solve with the outbox pattern in 07-05. The SDK retries are configured with exponential backoff on 5xx. And the timeouts are short: without them botocore uses 60 seconds, more than enough to exhaust the shop's connection pool during an SQS incident.

For high volumes —the nightly load enqueues 40,000 stock movements— use send_message_batch, which accepts 10 messages per request (Entries with Id and MessageBody) and divides the bill by ten. The trap: it returns HTTP 200 even when there are failed entries, so you have to walk r.get("Failed", []) and log every failure. If you do not, you lose messages without noticing.

The consumer that runs on an instance or container has a canonical form that is worth copying just as it is:

_keep_going = True

def _stop(signum, frame):
    """Graceful shutdown: on SIGTERM from the ASG or ECS, finish the batch in progress."""
    global _keep_going
    _keep_going = False

signal.signal(signal.SIGTERM, _stop)


def consume_loop():
    while _keep_going:
        response = sqs.receive_message(
            QueueUrl=QUEUE,
            MaxNumberOfMessages=10,         # the maximum: fewer requests, less cost
            WaitTimeSeconds=20,             # long polling: essential
            MessageAttributeNames=["All"],  # without this, MessageAttributes arrives empty
            AttributeNames=["ApproximateReceiveCount", "SentTimestamp"],
        )
        messages = response.get("Messages", [])   # the key does NOT exist if there are none!
        if not messages:
            continue

        deletable = []
        for m in messages:
            attempts = int(m["Attributes"]["ApproximateReceiveCount"])
            try:
                process_order(json.loads(m["Body"]), attempts)
                deletable.append({"Id": m["MessageId"], "ReceiptHandle": m["ReceiptHandle"]})
            except PermanentError as e:
                # Invalid data: retrying fixes nothing. It is archived and deleted so that
                # it does not burn four receptions before ending up in the DLQ.
                log.error("invalid message", extra={"message_id": m["MessageId"], "error": str(e)})
                archive_for_review(m)
                deletable.append({"Id": m["MessageId"], "ReceiptHandle": m["ReceiptHandle"]})
            except Exception as e:
                # Transient: it is NOT deleted. It comes back after the visibility timeout.
                log.warning("transient failure", extra={"message_id": m["MessageId"],
                                                        "attempts": attempts, "error": str(e)})

        if deletable:
            r = sqs.delete_message_batch(QueueUrl=QUEUE, Entries=deletable)
            for failure in r.get("Failed", []):
                log.error("could not delete", extra={"id": failure["Id"], "code": failure["Code"]})

What separates a correct consumer from one that gives trouble three weeks in: using response.get("Messages", []) (the response does not include the key when empty, and response["Messages"] raises KeyError on the first poll); asking for MessageAttributeNames=["All"] or the attributes arrive empty; distinguishing permanent from transient errors; deleting in batches; handling SIGTERM so the ASG does not kill the process halfway through a message; and using ApproximateReceiveCount as a signal to log more detail or switch to a degraded path on attempt three.

Consuming from Lambda: event source mapping

For the email consumer, setting up instances is disproportionate: short, sporadic and stateless work. The connection between queue and function is called an event source mapping.

aws lambda create-event-source-mapping \
  --function-name mercadofresco-enviar-correo-pedido \
  --event-source-arn arn:aws:sqs:eu-west-1:111122223333:cola-mercadofresco-correo \
  --batch-size 10 --maximum-batching-window-in-seconds 5 \
  --scaling-config MaximumConcurrency=20 \
  --function-response-types ReportBatchItemFailures $PROFILE
Parameter What it does Value in MercadoFresco
--batch-size Messages per invocation (1–10,000) 10: the SMTP does not benefit from more
--maximum-batching-window-in-seconds Waits to fill the batch (0–300) 5: fewer invocations in the trough
MaximumConcurrency Cap on concurrent instances (2–1,000) 20: protects the SMTP limit
--function-response-types Enables partial batch failures ReportBatchItemFailures

The concurrency cap is the key defensive piece. Lambda scales aggressively with queue depth: it can go from 5 to 60 pollers in a minute. If behind it there is an Aurora with 200 connections or an SMTP with a limit of 50 sends per second, that elasticity becomes a denial of service you inflict on yourself. It is the same backpressure idea we will generalise in 07-05.

The whole-batch problem. By default, if the function raises an exception, Lambda considers the whole batch failed and deletes none of the ten messages: if nine were fine and one was not, the nine get reprocessed. ReportBatchItemFailures fixes it by returning only the ones that failed.

import json

def handler(event, context):
    """Consumer for cola-mercadofresco-correo with partial batch failures."""
    failed = []
    for record in event["Records"]:
        try:
            order = json.loads(record["body"])
            slot = record.get("messageAttributes", {}) \
                         .get("franja_entrega", {}).get("stringValue", "estandar")
            send_confirmation_email(order["cliente_id"], order["pedido_id"],
                                    order["importe_eur"], slot)
        except Exception as e:
            print(json.dumps({"nivel": "ERROR", "message_id": record["messageId"],
                              "error": str(e)}))
            # Only this message goes back to the queue; the rest of the batch is deleted.
            failed.append({"itemIdentifier": record["messageId"]})
    return {"batchItemFailures": failed}

Two conditions for it to work: declare ReportBatchItemFailures on the mapping and return the exact structure {"batchItemFailures": [{"itemIdentifier": "..."}]}. If the key name is not that one, Lambda ignores it silently.

Also: you must not call delete_message —Lambda deletes the messages when it ends well— and the queue's visibility timeout must be at least 6 times the function timeout, which is AWS's recommendation and stops Lambda receiving the same message while it is still processing it.

Metrics, alarms and autoscaling by queue depth

SQS publishes metrics to CloudWatch every minute and at no extra cost.

Metric What it means Signal
ApproximateNumberOfMessagesVisible Messages waiting for a consumer Queue depth
ApproximateNumberOfMessagesNotVisible Messages in flight Work in progress
ApproximateAgeOfOldestMessage Seconds of the oldest message The real health metric
NumberOfMessagesSent / Deleted Flow Sent > Deleted sustained = it is piling up
NumberOfEmptyReceives Empty polls High = short polling = bill
SentMessageSize Average size Close to 256 KiB = move to S3

If you can only watch one, watch ApproximateAgeOfOldestMessage. Depth is deceptive: 5,000 messages with fast consumers is perfectly normal on a Friday at 19:00, and 40 messages stuck for an hour is an incident. Age answers the question that really matters: how long does an order take today to reach the warehouse?

aws cloudwatch put-metric-alarm --alarm-name mercadofresco-pedidos-cola-retrasada \
  --namespace AWS/SQS --metric-name ApproximateAgeOfOldestMessage \
  --dimensions Name=QueueName,Value=cola-mercadofresco-pedidos \
  --statistic Maximum --period 60 --evaluation-periods 3 --threshold 300 \
  --comparison-operator GreaterThanThreshold --treat-missing-data notBreaching \
  --alarm-actions arn:aws:sns:eu-west-1:111122223333:alertas-mercadofresco $PROFILE

The second alarm is the same command changing the metric to ApproximateNumberOfMessagesVisible, the dimension to mercadofresco-pedidos-fallidos, --threshold 0 and --period 300: any message in the DLQ is an incident. --treat-missing-data notBreaching is important in both, because SQS stops publishing metrics for a queue that has been empty for hours and without that setting the alarm would go to INSUFFICIENT_DATA, generating night-time noise. Both are added to the mercadofresco-produccion dashboard.

Autoscaling by depth. The worker ASG asg-mercadofresco-trabajadores must not scale on CPU: a consumer waiting on the ERP has its CPU at 4 % and is saturated. The correct metric is the backlog per instance.

def publish_backlog_per_instance():
    """Runs every minute (EventBridge Scheduler, which we will see in 07-03)."""
    a = sqs.get_queue_attributes(QueueUrl=QUEUE, AttributeNames=[
        "ApproximateNumberOfMessages", "ApproximateNumberOfMessagesNotVisible"])["Attributes"]
    pending = int(a["ApproximateNumberOfMessages"]) + \
              int(a["ApproximateNumberOfMessagesNotVisible"])
    group = asg.describe_auto_scaling_groups(
        AutoScalingGroupNames=["asg-mercadofresco-trabajadores"])["AutoScalingGroups"][0]
    in_service = max(1, sum(1 for i in group["Instances"]
                            if i["LifecycleState"] == "InService"))
    cw.put_metric_data(Namespace="MercadoFresco/Tienda", MetricData=[{
        "MetricName": "MensajesPendientesPorInstancia",
        "Dimensions": [{"Name": "Cola", "Value": "cola-mercadofresco-pedidos"}],
        "Value": pending / in_service, "Unit": "Count"}])

A target tracking policy with a value of 35 is defined on that metric. The number is not arbitrary: a worker processes about 7 messages a minute against the ERP and Marta wants to empty the queue in 5 minutes at most, so 7 × 5 = 35.

Real cost and cleanup

SQS charges exclusively per API request, with the first million a month free permanently. A send, a receive (whether it brings 0 or 10 messages) and a delete are one request each; batch operations count as a single one, which is the economic reason for always using them.

Item Requests/month
Sends to the orders queue (240,000 orders/month) 240,000
Receives with long polling and batches of 10 190,000
Batch deletes 24,000
Email queue (send + receive + delete) 300,000
Thumbnails and stock 160,000
Total ~914,000 → within the free million
KMS (GenerateDataKey, 300 s reuse) ~8,600 → 0.03 USD

Total cost: practically zero. All of MercadoFresco's decoupling fits inside the permanent free tier, and the only appreciable expense is the KMS calls. In exchange, 2,500 ms less per request free up threads in asg-mercadofresco-tienda much sooner and the group scales to 3 instead of 4 on Fridays. Two ways of turning that zero into an unpleasant bill: short polling in a loop (around 100 USD a month per idle process) and a low KmsDataKeyReusePeriodSeconds with many consumers.

for Q in cola-mercadofresco-pedidos cola-mercadofresco-correo \
         cola-mercadofresco-stock.fifo mercadofresco-pedidos-fallidos; do
  aws sqs delete-queue --queue-url https://sqs.eu-west-1.amazonaws.com/111122223333/$Q $PROFILE
done
aws lambda delete-event-source-mapping --uuid <MAPPING_UUID> $PROFILE
aws cloudwatch delete-alarms --alarm-names mercadofresco-pedidos-cola-retrasada \
  mercadofresco-pedidos-dlq-con-mensajes $PROFILE

Deleting a queue takes up to 60 seconds and you cannot create another with the same name during that minute, an annoying detail in scripts that delete and recreate.

Common Mistakes and Tips

A visibility timeout shorter than the processing. The number one mistake. Symptom: random duplicates that grow with load and ReceiptHandleIsInvalid in the logs. Measure your consumer's p99, multiply it by two, and if you cannot bound it use a heartbeat with ChangeMessageVisibility.

Leaving short polling on by default. A queue created from the console has ReceiveMessageWaitTimeSeconds=0. Set it to 20 on the queue and on every call.

Assuming a message arrives only once. Standard queues duplicate by design. Every consumer with external effects —charging, sending email, calling a partner— must be idempotent (07-05).

Creating a FIFO queue "just in case". It limits throughput, complicates the consumer and encourages the belief that idempotent retries are no longer needed. And a single MessageGroupId for the whole FIFO queue turns a distributed queue into a serial process: one message in flight at most, however many consumers you have.

A queue with no DLQ. A poison message is retried until retention runs out and —on FIFO— it blocks its whole group for days. And a DLQ with no alarm is worse than not having one: it gives a false sense of control while the orders pile up in silence.

Reading response["Messages"] without .get() gives a KeyError on the first empty poll; forgetting MessageAttributeNames=["All"] makes the attributes arrive empty and the consumer decide with default values; and ignoring Failed in send_message_batch and delete_message_batch hides partial failures under an HTTP 200.

Encrypting with KMS and forgetting the key permissions. The symptom is a confusing AccessDenied that mentions KMS and not SQS.

Tip: put the MessageId on every log line. When you investigate a message from the DLQ three days later, it will be the only thing that lets you reconstruct the story.

Tip: one queue per type of work. A shared queue means the slow ERP consumer delays the emails. Separate queues allow different visibility, concurrency and priorities. And do not use SQS for priorities: message priority does not exist; if you need urgent and normal, create two queues and give the urgent one more consumers.

Exercises

Exercise 1: sizing the warehouse queue

The cola-mercadofresco-almacen consumer calls the partner's ERP. Marta measures: p50 1,180 ms, p95 6,500 ms, p99 31 s, worst case in a month 74 s. The ERP accepts a maximum of 12 concurrent requests. On Friday 900 orders/hour come in and no order may take more than 10 minutes to reach the warehouse.

Determine: (a) the VisibilityTimeout and its justification; (b) whether Lambda or instances suit better; (c) the event source mapping parameters if you choose Lambda; (d) maxReceiveCount and DLQ retention; (e) two alarms with thresholds calculated from the data, not invented.

Exercise 2: diagnosing the duplicated email

Three Fridays running, complaints arrive about duplicated confirmation emails —two, sometimes three— always between 18:00 and 21:00. The data: ApproximateNumberOfMessagesVisible swings between 0 and 40; ApproximateAgeOfOldestMessage maximum 55 s; the Lambda has a timeout of 60 s, p50 duration 900 ms and p99 47 s; the queue has VisibilityTimeout 60 s, batch-size 10 and no ReportBatchItemFailures; the DLQ is empty; occasional Task timed out after 60.00 seconds show up.

Explain: (a) the two independent causes of duplication; (b) why it only happens at the peak; (c) why an empty DLQ is not reassuring; (d) the fixes with concrete values; (e) which one eliminates the problem at its root and which one only reduces its frequency.

Exercise 3: FIFO for stock

Design cola-mercadofresco-stock.fifo. The messages are {"sku": "...", "delta": -2, "pedido_id": "...", "motivo": "reserva|liberacion|reposicion"}. There are 3,400 SKUs and at the peak 2,800 movements an hour are generated, concentrated in the 200 best-selling SKUs.

Answer: (a) what you would use as MessageGroupId and the three alternatives you rule out; (b) what you would use as MessageDeduplicationId and whether to enable ContentBasedDeduplication; (c) what happens if a message for SKU FRUT-FRES-011 is poisoned and how you mitigate it; (d) whether high throughput mode is appropriate; (e) why this queue cannot use per-message DelaySeconds and what you would do if needed.

Solutions

Solution 1

(a) The worst case is 74 s, so 180 seconds is defensible: 2.4 times the worst case recorded. It is not a good idea to raise it to 900, because if the worker dies the message would be blocked for 15 minutes and the target is 10. A better alternative: 120 s with a heartbeat, which gives fast recovery when the worker dies with no real upper limit on duration.

(b) Lambda, with reservations. In favour: sporadic, stateless work of variable duration; with almost no traffic in the small hours, paying for instances left switched on is absurd. Against: the p99 of 31 s forces a high timeout and a function that waits is paid for in full. The deciding factor is the ERP's limit of 12 concurrent requests: MaximumConcurrency imposes it declaratively, whereas with instances you would need a distributed semaphore.

(c) --batch-size 1 (with batches of 10 and 74 s per message, one invocation could need 740 s); --maximum-batching-window-in-seconds 0; MaximumConcurrency=12; a function timeout of 120 s; and a queue VisibilityTimeout of 720 s, applying the 6 × timeout rule, which here wins over the estimate in (a). Capacity check: 12 ÷ 1.18 s = 10 orders/s = 36,000/hour, and even at the p95 (6.5 s) that is 6,600/hour. Plenty of margin over 900.

(d) maxReceiveCount=4: with 720 s of visibility, three retries cover around 36 minutes of unavailability before isolating. DLQ retention 14 days, the maximum, because what lands there are paid-for sales that Marta must be able to recover even if the incident falls on a bank-holiday Friday.

(e) ApproximateAgeOfOldestMessage > 480 for 2 periods of 60 s: the requirement is 600 s, so the alert goes out at 8 minutes to leave room to react. And ApproximateNumberOfMessagesVisible > 0 on the DLQ. A third one worth having: function Errors > 10 in 5 minutes, which detects the ERP down sooner.

Solution 2

(a) Cause 1: the visibility timeout equals the function's timeout. Both are 60 s: when an invocation takes 47 s or exhausts the timeout, the message becomes visible before or just as the function finishes, and Lambda delivers it again. The recommended factor of 6 is missing. Cause 2: ReportBatchItemFailures is not enabled. With batch-size 10, if message number 7 fails or the batch exhausts the timeout, none of the ten is deleted and the other nine get reprocessed; that explains the cases of three duplicates.

(b) Both causes depend on the SMTP latency, which degrades when many emails are sent at once. In the trough the function takes 900 ms and comes nowhere near any limit; from 18:00 the concurrency grows with the queue depth, the provider throttles, the duration shoots up to the p99 and both causes fire at once. It is a failure that does not reproduce in tests because it only appears with real concurrency.

(c) An empty DLQ means no message exhausted its receptions, not that everything is fine. Here the failure is one of successful double delivery: the message is processed correctly twice and deleted. A successful duplicate never reaches the DLQ. The DLQ detects work that was not done, never work that was done twice over.

(d) VisibilityTimeout at 360 s (6 × 60); enable ReportBatchItemFailures and return batchItemFailures; MaximumConcurrency=20 so as not to throttle the SMTP; batch-size at 5 to bound the duration per invocation; idempotency in the consumer by writing PEDIDO#<id>#correo-confirmacion to DynamoDB with a conditional write and a 24 h TTL; and alarms on Duration p99 and Throttles.

(e) The first four reduce the frequency: they make duplicates much rarer but not impossible, because standard queues duplicate by design. The only one that eliminates the problem at its root is idempotency: receiving the message twice becomes harmless. This is the thesis of 07-05, and the reason why correct configuration is necessary but never sufficient.

Solution 3

(a) MessageGroupId = sku. Order only matters between movements of the same product, and with 3,400 SKUs there is parallelism to spare. Ruled out: a fixed group, which imposes unnecessary global order and a single message in flight; pedido_id, which guarantees order within one order but not between different orders for the same SKU, which is exactly where the race condition is; and categoria, which leaves a dozen groups and creates hot groups in the best-selling categories —the same mistake as a hot partition key in DynamoDB (06-02)—.

(b) A deterministic identifier that is unique per logical movement: f"{pedido_id}:{sku}:{motivo}". If the producer retries after a timeout, the same movement produces the same identifier and SQS discards it within the 5-minute window. ContentBasedDeduplication is left disabled: the body includes a timestamp, so the hash would change between retries; and worse still, two legitimately identical movements —two restocks of 10 units of the same SKU in the same window— would be deduplicated by mistake, losing real stock.

(c) It is the worst scenario in FIFO: since SQS does not deliver the next message of the group until the current one is deleted, the whole FRUT-FRES-011 group is blocked, and with 4-day retention that SKU would go days without its stock being updated while the rest work —a silent partial failure—. Mitigation: a DLQ with maxReceiveCount=3; an alarm on the DLQ; schema validation in the producer; distinguishing permanent from transient in the consumer; and watching ApproximateAgeOfOldestMessage, which in FIFO gives away a blocked group even when the total depth is low.

(d) Yes, and it is free. DeduplicationScope=messageGroup with FifoThroughputLimit=perMessageGroupId lifts the limit of 300 msg/s per queue. Although 2,800 movements an hour is only 0.8 msg/s, enabling it protects against campaigns or mass restocks; the only consequence is that deduplication becomes per group, which is irrelevant because the identifier already includes the sku.

(e) FIFO queues do not accept a per-message timer, only the queue-level delay. To postpone a specific movement —releasing the stock of an unpaid order after 15 minutes— there are three options: the queue DelaySeconds, ruled out because it would also delay urgent reservations; returning the message with ChangeMessageVisibility, ruled out because in FIFO it blocks the whole group; and the correct one, taking the postponement out of the queue and using a Step Functions Wait state (07-04) or a scheduled EventBridge rule (07-03), so that the message only enters the queue when it has to be applied.

Conclusion

Confirming an order in MercadoFresco has gone from 2,893 ms median to around 400 ms at the 95th percentile, and from eight chained failure points to two: charging the card and writing to Aurora. The rest now lives in cola-mercadofresco-pedidos and cola-mercadofresco-correo, with cola-mercadofresco-stock.fifo for the only thing that needed real ordering and mercadofresco-pedidos-fallidos collecting what could not be processed. The warehouse ERP can be down for two hours without a single sale being lost.

Along the way you have seen the concepts that will recur throughout the module: the polling model, which turns the queue into a buffer and gives backpressure for free; the visibility timeout, the number one source of duplicates when it falls short; the fact that not deleting is reprocessing, which is at once the durability guarantee and the reason consumers must be idempotent; long polling, which separates a bill of zero from one of hundreds of euros; and dead-letter queues with their maxReceiveCount, their mandatory alarm and their redrive.

But a seam has been left showing. cola-mercadofresco-pedidos is a point-to-point channel: each message is processed by one consumer and then disappears. With warehouse, email, delivery and analytics all interested in the same fact —"an order has been confirmed"— either the shop sends four messages to four different queues —and then it goes back to knowing who its consumers are, exactly what we wanted to avoid— or a single consumer distributes the work and becomes the new point of failure. When marketing asks next month to hear about orders too for their loyalty programme, the shop's code will have to be touched again. The decoupling is only half done.

In 07-02, "Amazon SNS", we will solve the missing half: a topic the shop publishes to once and several subscribed queues that each receive their own copy. We will see the SNS→SQS fan-out pattern, why it is worth putting a queue between the topic and each consumer instead of subscribing Lambda functions directly, how to filter by attributes so that the 24-hour delivery queue only receives what concerns it, and why the alertas-mercadofresco topic we have been using since module 5 is exactly the same mechanism applied to alarms.

© Copyright 2026. All rights reserved