Debug a visibility-timeout redelivery
Tell a slow consumer from a released message or a heartbeat that stopped, and see whose delete failed and why.
Use this when a message was delivered again before the first consumer finished with it, or a delete failed with ReceiptHandleIsInvalid.
1Step 1: Know what Trailbeam infers#
SQS has no event for "the visibility timeout ran out". Trailbeam sees only the receives around it. Trailbeam adds an inferred Visibility timeout expired step (type: "visibility_timeout_expired") between two receives when:
- both are on the same queue, and
- the later one has a higher
receive_count(ApproximateReceiveCount) than the earlier one.
A delete that SQS accepted for the earlier delivery does not prevent the expiry. The later receive shows that the delete came too late.
Two receives with the same receive count get no expiry. That is an at-least-once duplicate, not a timeout. If the count jumps by more than one, Trailbeam also adds Deliveries not observed with the number of missing receives. It adds the same step when the first receive it sees on the source queue already has a count above 1.
The expiry carries after_delivery, before_delivery, queue, a window and a reason. Its earlier anchor is the receive of after_delivery, unless a ChangeMessageVisibility that SQS accepted for that delivery is proven to come before the redelivery. Then the latest such change is the anchor, because it restarted the timer. window.from is the anchor's start, window.to is the redelivery's end.
2Step 2: Read the reason#
The reason is picked from what Trailbeam has for the earlier delivery, in this order:
| What Trailbeam has for the earlier delivery | reason |
|---|---|
An accepted visibility change, the last one to 0 |
"ChangeMessageVisibility set the visibility timeout to 0, which made the message visible again at once." |
| An accepted visibility change, the last one to N | "The visibility timeout, last changed to N s, expired before the message was deleted." |
| An accepted delete | "The visibility timeout expired before the delete: SQS accepted the delete, but the message was received again, so the delete came too late to remove it." |
| None of these | "The visibility timeout expired before the message was deleted, so SQS delivered it again." |
Only changes and deletes with result: "ok" count. A rejected visibility change does not restart the timer and is ignored.
3Step 3: Match the pattern#
A slow consumer#
- 10:00:00.010Sentproducer · queue orders
- 10:00:00.402Receiveddelivery 1 · receive count 1 · consumer-a
- consumer-a works for 13 svisibility timeout is 10 s
- inferredVisibility timeout expiredThe visibility timeout expired before the message was deleted, so SQS delivered it again.
- 10:00:10.950Receiveddelivery 2 · receive count 2 · consumer-b
- 10:00:11.120Deletedelivery 2 · result ok · status final · consumer-b
- 10:00:13.410Deletedelivery 1 · result failed · status failed · error ReceiptHandleIsInvalid · consumer-a
This is the demo's slow message on ElasticMQ, which rejects a delete with an earlier delivery's handle. Amazon SQS accepts that delete, so the same run there shows delivery 1's delete as result ok, status stale_handle, and the expiry's reason becomes the "came too late" one, because an accepted delete of delivery 1 now exists.
A released message#
- 10:01:00.205Receiveddelivery 1 · receive count 1 · consumer-a
- 10:01:00.231Visibility changeddelivery 1 · result ok · visibility timeout 0 s
- inferredVisibility timeout expiredChangeMessageVisibility set the visibility timeout to 0, which made the message visible again at once.
- 10:01:00.780Receiveddelivery 2 · receive count 2 · consumer-b
- 10:01:00.812Deletedelivery 2 · result ok · status final
The consumer gave the message back on purpose. The expiry's window starts at the visibility change, not at receive 1.
A heartbeat that stopped#
- 10:02:00.100Receiveddelivery 1 · receive count 1 · consumer-a
- 10:02:05.003Visibility changeddelivery 1 · result ok · visibility timeout 5 s
- 10:02:09.004Visibility changeddelivery 1 · result ok · visibility timeout 5 s
- inferredVisibility timeout expiredThe visibility timeout, last changed to 5 s, expired before the message was deleted.
- 10:02:15.000Receiveddelivery 2 · receive count 2 · consumer-b
The consumer extended the timeout twice, then stopped extending (it crashed or hung). The expiry is placed after the last accepted extension.
| What the timeline shows | Cause | Fix |
|---|---|---|
| Receive 1, expiry, receive 2, then a late delete of delivery 1 stale_handle (reason "came too late") or failed (default reason) | The consumer ran past the visibility timeout | Raise the queue's timeout above the slowest run, or send a heartbeat |
Visibility changed 0 s, expiry, receive 2 |
Deliberate release | Nothing, if that was the intent. Check it did not happen after a side effect. |
| One or more Visibility changed N s, expiry "last changed to N s", receive 2 | The heartbeat stopped, or N was too short | Extend by more than the time between beats |
| Delete of delivery 1 stale_handle shown before receive 2, expiry reason "came too late" | The delete reached SQS after the timeout but before the redelivery. SQS accepted it and delivered again anyway. The message stays not_deleted if nothing deletes delivery 2. | Same as a slow consumer |
| Delete of delivery 1 failed in one box with the expiry, labelled "concurrent, order unknown" | Trailbeam cannot tell whether the delete came before or after the timeout ran out. It does not know the queue's timeout. | Read error_code for why SQS rejected it. Treat it as a slow consumer. |
4Step 4: Read the delete status#
Every deleted event has result and status. delivery is the delivery whose receipt handle was used, found by the receipt-handle hash join, not by time.
status |
Rule | Meaning |
|---|---|---|
| final | result is ok, and the handle is from the message's latest receive |
The message is gone |
| stale_handle | result is ok, but the handle is from an earlier delivery |
SQS accepts old handles without always deleting the message |
| failed | result is failed |
SQS rejected it. See error_code. |
| unmatched_handle | result is ok, but no stored receive has this hash |
Unknown which delivery it settled. See the notices. |
error_code is what the hooks recorded:
error_code |
Where it comes from |
|---|---|
An SQS error code, such as ReceiptHandleIsInvalid |
The call failed, or one batch entry was in Failed with that Code |
MissingFromResponse |
A batch entry was in neither Successful nor Failed |
NoResponse or an exception class name (Python), the error's code, name or class name (Node.js) |
The call threw before any response |
Prevent it#
Set the queue's visibility timeout above your slowest normal run. If runs vary, extend the timeout while working and stop when done. Each extension is recorded as Visibility changed, so the timeline shows exactly when the heartbeat stopped.
import threading
def run_with_heartbeat(sqs, queue_url, message, work, extend_s=30, every_s=20):
done = threading.Event()
def beat():
while not done.wait(every_s):
sqs.change_message_visibility(QueueUrl=queue_url, ReceiptHandle=message["ReceiptHandle"],
VisibilityTimeout=extend_s)
t = threading.Thread(target=beat, daemon=True)
t.start()
try:
work(message)
finally:
done.set()
t.join()
sqs.delete_message(QueueUrl=queue_url, ReceiptHandle=message["ReceiptHandle"])const { ChangeMessageVisibilityCommand, DeleteMessageCommand } = require('@aws-sdk/client-sqs');
async function runWithHeartbeat(sqs, queueUrl, message, work, extendS = 30, everyMs = 20000) {
const timer = setInterval(() => {
sqs.send(new ChangeMessageVisibilityCommand({
QueueUrl: queueUrl, ReceiptHandle: message.ReceiptHandle, VisibilityTimeout: extendS,
})).catch(() => {});
}, everyMs);
try {
await work(message);
} finally {
clearInterval(timer);
}
await sqs.send(new DeleteMessageCommand({ QueueUrl: queueUrl, ReceiptHandle: message.ReceiptHandle }));
}Both clients must be instrumented (trailbeam.boto.install() or trailbeam.instrument(client)) for the extensions to appear.
To reproduce all three patterns locally, run the Quickstart demo. Its orders queue has a 10 s visibility timeout. retry_once messages are released with a visibility change to 0, and slow messages run for 13 s.
Related#
- Find out why a message ran twice
- Receipt handles
- Ordering
- Span contract: delete and change visibility
Message
Amazon SQS terms in Vocabulary
Consumer
ReceiveMessage) and then deletes them. A Lambda function behind an event source mapping is a consumer too.Amazon SQS terms in Vocabulary
Delete
DeleteMessage, made with a receipt handle. This is the only way a consumer removes a message. With the handle of an older receive, the call can still succeed while the message is received again.Amazon SQS terms in Vocabulary
Visibility timeout
Amazon SQS terms in Vocabulary
Inferred
inferred: true. See Recorded and inferred steps.Queue
Amazon SQS terms in Vocabulary
Receive count
ApproximateReceiveCount: how many times SQS has returned the message. It goes up by one on every receive.Amazon SQS terms in Vocabulary
Delivery
Receive
ReceiveMessage call returning a message. A receive does not remove the message; it starts its visibility timeout.Amazon SQS terms in Vocabulary
Change visibility
ChangeMessageVisibility: sets a new visibility timeout on an in-flight message, counted from the call. A value of 0 makes it visible at once.Amazon SQS terms in Vocabulary
Visible
ReceiveMessage call.Amazon SQS terms in Vocabulary
Timeline
Receipt handle
Amazon SQS terms in Vocabulary
Receipt-handle hash
Hooks
trailbeam.boto.install()), the AWS SDK v3 middleware (trailbeam.instrument(client)) and the Lambda wrapper.