Skip to content

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.

Based on 9 repo files at 54ea61b

server/src/trailbeam_store/timeline.py, server/src/trailbeam_store/web.py, server/tests/test_timeline.py, python/src/trailbeam/boto.py, node/src/sqs.js, docs/store-api.md, docs/span-contract.md, demo/README.md, demo/python/trailbeamdemo.py

On this page

Use this when a message was delivered again before the first consumer finished with it, or a delete failed with ReceiptHandleIsInvalid.

An SQS message's states, and what Trailbeam recordsSendMessage puts the message in the queue, visible; Trailbeam records a send. ReceiveMessage makes it in flight, hidden until its visibility timeout ends, and adds one to the receive count; Trailbeam records a receive. DeleteMessage removes it for good; Trailbeam records a delete. ChangeMessageVisibility restarts the timer on an in-flight message; Trailbeam records it. If the timeout expires first, SQS makes the message visible again without any call; Trailbeam infers that step from the next receive's higher count. Once the receive count passes maxReceiveCount, SQS moves the message to the dead-letter queue, also without a call; Trailbeam learns of the move from DeadLetterQueueSourceArn on a later receive from the DLQ. SendMessage Trailbeam send Visible waiting in the queue In flight hidden, timer running Deleted gone for good Dead-letter queue the redrive target count + 1 ReceiveMessage Trailbeam receive timeout expires inferred by Trailbeam DeleteMessage Trailbeam delete ChangeMessageVisibility Trailbeam change_visibility moved by SQS once the receive count passes maxReceiveCount reported later, by DeadLetterQueueSourceArn on a receive from the DLQ Dashed arrows have no call behind them, so no span records them.
Figure 1 The states SQS moves a message through. Solid arrows are calls a Trailbeam hook records, labelled with the span it emits. Dashed arrows are moves SQS makes on its own: Trailbeam infers a timeout expiry from the next receive, and learns of a move to the dead-letter queue only when a receive from the DLQ reports it.

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#

Timeline sample
  1. 10:00:00.010Sentproducer · queue orders
  2. 10:00:00.402Receiveddelivery 1 · receive count 1 · consumer-a
  3. consumer-a works for 13 svisibility timeout is 10 s
  4. inferredVisibility timeout expiredThe visibility timeout expired before the message was deleted, so SQS delivered it again.
  5. 10:00:10.950Receiveddelivery 2 · receive count 2 · consumer-b
  6. 10:00:11.120Deletedelivery 2 · result ok · status final · consumer-b
  7. 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#

Timeline sample
  1. 10:01:00.205Receiveddelivery 1 · receive count 1 · consumer-a
  2. 10:01:00.231Visibility changeddelivery 1 · result ok · visibility timeout 0 s
  3. inferredVisibility timeout expiredChangeMessageVisibility set the visibility timeout to 0, which made the message visible again at once.
  4. 10:01:00.780Receiveddelivery 2 · receive count 2 · consumer-b
  5. 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#

Timeline sample
  1. 10:02:00.100Receiveddelivery 1 · receive count 1 · consumer-a
  2. 10:02:05.003Visibility changeddelivery 1 · result ok · visibility timeout 5 s
  3. 10:02:09.004Visibility changeddelivery 1 · result ok · visibility timeout 5 s
  4. inferredVisibility timeout expiredThe visibility timeout, last changed to 5 s, expired before the message was deleted.
  5. 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.

python
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"])
javascript
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.