Cloud

Azure Service Bus : diagnostiquer la dead-letter queue avant un rejeu borné

Un runbook de production pour classer les messages en dead-letter, prouver la cause, vérifier l'idempotence et rejouer par canari sans créer une seconde panne.

16 août 2026 azureservice-busdead-lettermessagingreplayidempotencyazure-monitorobservabilityautomationrunbookrollbackproduction

La dead-letter queue d’une file ou d’une subscription Azure Service Bus grossit. Le consumer fonctionne encore, mais certains messages dépassent le nombre maximal de livraisons, expirent ou sont rejetés par l’application. Les renvoyer tous dans l’entité principale semble rétablir le flux. En pratique, un rejeu massif peut remettre en circulation un payload invalide, saturer la dépendance déjà en panne et dupliquer des effets métier partiellement exécutés.

Le cas fil rouge est une subscription qui distribue des commandes à un service de traitement. Une release récente a modifié un contrat, tandis qu’une API aval a également connu des erreurs transitoires. Le runbook doit séparer ces deux populations, corriger la cause et n’autoriser qu’un rejeu dont le périmètre, le débit et l’arrêt sont explicites.

Figer l’entité et la fenêtre d’incident

Une DLQ appartient à une queue ou à une subscription précise. Elle n’est pas une file globale du namespace. Commencez donc par identifier le chemin complet, le consumer, la première hausse visible et les changements survenus juste avant. Pour un topic, contrôlez chaque subscription : un même message peut être sain pour l’une et en échec pour une autre.

yaml service-bus-dlq-incident.yml
incident: inc-20260816-011
namespace: <service-bus-namespace>
entity_type: subscription
topic: <topic-name>
subscription: <subscription-name>
environment: production
first_dlq_growth_utc: <timestamp>
last_known_healthy_utc: <timestamp>

consumer:
application: <service-name>
version: <release-id>
identity: <managed-identity-or-sas-name>
receive_mode: peek-lock

preserve:
- active and dead-letter message counts
- incoming and outgoing message rates
- throttled requests, server errors and user errors
- consumer exceptions, lock losses and dependency failures
- deployments, configuration and subscription rule changes
- a redacted sample grouped by dead-letter reason

Gelez les déploiements et toute purge manuelle. Ne relevez pas seulement le compteur courant : conservez une série temporelle. Une DLQ stable contenant d’anciens messages n’appelle pas la même réponse qu’une pente qui augmente encore.

Mesurer sans consommer

Commencez par les compteurs de l’entité, puis ouvrez la sous-file dead-letter en lecture d’inspection. L’opération peek ne verrouille ni ne supprime les messages. Elle donne un échantillon, pas une preuve statistique parfaite : répétez la lecture sur plusieurs positions si le volume est hétérogène.

bash service-bus-counts.sh
az servicebus topic subscription show \
--resource-group <resource-group> \
--namespace-name <namespace> \
--topic-name <topic> \
--name <subscription> \
--query '{active:countDetails.activeMessageCount,deadLetter:countDetails.deadLetterMessageCount,transferDeadLetter:countDetails.transferDeadLetterMessageCount}'

Pour chaque message échantillonné, relevez sans exposer le payload complet : MessageId, identifiant métier ou corrélation, SequenceNumber, heure d’enqueue, nombre de livraisons, type/schema, DeadLetterReason, DeadLetterErrorDescription et propriétés applicatives utiles. Hachez ou masquez les valeurs sensibles.

python peek-dlq-sample.py
receiver = client.get_subscription_receiver(
  topic_name=topic,
  subscription_name=subscription,
  sub_queue=ServiceBusSubQueue.DEAD_LETTER,
)

sample = await receiver.peek_messages(max_message_count=50)
for message in sample:
  emit_redacted({
      "message_id": message.message_id,
      "sequence_number": message.sequence_number,
      "delivery_count": message.delivery_count,
      "enqueued_time_utc": message.enqueued_time_utc,
      "dead_letter_reason": message.dead_letter_reason,
      "dead_letter_description": message.dead_letter_error_description,
      "schema": message.application_properties.get(b"schema"),
  })

Inspectez aussi la transfer DLQ si l’architecture utilise l’auto-forwarding. Un échec de transfert reste attaché à l’entité source et ne se diagnostique pas comme une erreur du consumer final.

Classer avant de corriger

Regroupez les messages par raison, version de schéma, type, tranche horaire et identifiant de release. Évitez le diagnostic unique « le consumer est cassé ».

text dlq-classification.txt
MaxDeliveryCountExceeded
Chercher exception consumer, lock expiré, abandon répété ou dépendance aval lente.
Ne pas augmenter MaxDeliveryCount avant d'avoir prouvé qu'une tentative supplémentaire peut réussir.

TTLExpiredException
Comparer TTL, temps d'attente actif, backlog et capacité du consumer.
Un message expiré peut être fonctionnellement obsolète : le rejeu n'est pas automatique.

Dead-letter applicatif
Lire la raison et la description émises par le handler.
Séparer contrat invalide, règle métier, authentification et panne transitoire.

Filter evaluation ou session
Revoir les règles de subscription, le SessionId attendu et le changement de contrat.
Corriger le routage avant de remettre le message en circulation.

MaxTransferHopCountExceeded ou transfer DLQ
Reconstituer la chaîne d'auto-forwarding et l'état de la destination.
Ne pas rejouer directement vers une destination dont le chemin reste invalide.

Cette classification doit produire des lots homogènes. Un lot peut être rejouable après rétablissement d’une dépendance ; un autre exige une migration de payload ; un troisième doit rester en quarantaine parce que la commande n’est plus valable.

Prouver la cause hors de la DLQ

La raison de dead-letter indique le dernier mécanisme, pas toujours la cause initiale. MaxDeliveryCountExceeded peut venir d’un payload invalide, d’une API aval en erreur, d’un lock trop court ou d’un consumer qui traite puis échoue avant complete.

Construisez une timeline avec quatre sources : métriques Service Bus, logs du consumer, traces de la dépendance et changements de plateforme. Cherchez notamment :

  • une hausse simultanée des erreurs aval et des abandons ;
  • des MessageLockLost après une durée de traitement supérieure à la fenêtre de lock ;
  • une version ou un schéma dominant dans la DLQ ;
  • des requêtes throttlées au niveau du namespace ;
  • des effets métier déjà écrits avant l’échec de settlement ;
  • une règle de subscription ou une chaîne d’auto-forwarding modifiée.

Le compteur deadLetterMessageCount mesure un stock. Les métriques de requêtes et les logs applicatifs expliquent le flux qui l’alimente. Ne concluez pas à partir d’un seul graphe.

Vérifier l’idempotence et l’état aval

Avant tout rejeu, prenez quelques MessageId et recherchez-les dans la base, l’API aval, les traces et le journal d’audit. Le consumer Service Bus en mode peek-lock fournit une livraison au moins une fois : un traitement peut avoir réussi, puis le complete peut avoir échoué. Le message revient alors, même si l’effet existe déjà.

Documentez pour chaque classe :

  • la clé d’idempotence réelle, de préférence un identifiant métier stable ;
  • l’effet déjà observable et la manière de le comparer au résultat attendu ;
  • la politique pour un doublon : ignorer, mettre à jour, compenser ou bloquer ;
  • la compatibilité du payload ancien avec le handler corrigé ;
  • la date fonctionnelle au-delà de laquelle la commande ne doit plus être exécutée.

La duplicate detection côté Service Bus aide contre certains renvois du producteur. Elle ne remplace pas l’idempotence du consumer et peut aussi ignorer un rejeu qui réutilise un MessageId encore présent dans sa fenêtre de détection.

Préparer un manifeste de rejeu

Ne transformez pas la DLQ en bouton « tout renvoyer ». Produisez un manifeste versionné qui nomme exactement les messages éligibles et les exclusions.

yaml dlq-replay-manifest.yml
incident: inc-20260816-011
source: <topic>/<subscription>/$DeadLetterQueue
selection:
dead_letter_reason: MaxDeliveryCountExceeded
enqueued_from_utc: <timestamp>
enqueued_to_utc: <timestamp>
schema_versions: [order.v3]
exclude_business_states: [cancelled, already_completed]

controls:
dry_run: true
canary_messages: 1
batch_size: 20
max_messages: 200
max_rate_per_second: 2
pause_between_batches_seconds: 60
approval_required: true

stop_when:
- any duplicate side effect
- consumer error rate above agreed threshold
- downstream latency above agreed threshold
- dead-letter count grows from replayed messages
- identity, schema or target cannot be proven

Archivez pour chaque message l’enveloppe expurgée, le hash du payload, la séquence source, la décision et le résultat. Un outil de rejeu doit tenir un ledger afin qu’une relance du runbook ne renvoie pas silencieusement le même lot.

Exécuter un canari puis des lots bornés

Le premier passage doit fonctionner en dry-run : le handler valide schéma, identité, dépendances et effet attendu sans écriture métier. Rejouez ensuite un seul message représentatif. Attendez sa consommation, son effet aval et l’absence de nouvelle dead-letter avant d’autoriser le premier lot.

Lors du déplacement, ne terminez le message source qu’après confirmation de son renvoi et de son inscription dans le ledger. Si le renvoi réussit mais que le complete de la DLQ échoue, le message source peut rester visible : le ledger et l’idempotence doivent rendre cette ambiguïté inoffensive.

Entre les lots, contrôlez : DLQ source, messages actifs, taux de traitement, erreurs, throttling, latence aval et doublons métier. Le débit de rejeu doit rester inférieur à la marge mesurée du consumer et de sa dépendance, pas seulement à la capacité théorique de Service Bus.

Décider, valider ou rollbacker

La sortie du runbook tient dans une décision explicite :

text replay-decision.txt
REPLAY
Cause corrigée et prouvée.
Lot homogène, payload compatible et effet idempotent.
Canari validé, seuils d'arrêt actifs et owner présent.

TRANSFORM THEN REPLAY
Ancien schéma compris et transformation testée hors production.
Payload original archivé, mapping relisible et canari obligatoire.

KEEP IN QUARANTINE
État aval ambigu, message obsolète ou règle métier non résolue.
Conserver preuve et décision ; ne pas purger pour faire baisser un compteur.

DISCARD WITH APPROVAL
Message explicitement non exécutable et politique de rétention validée.
Journaliser identifiants, raison, approbateur et impact attendu.

ROLLBACK REPLAY
Arrêter le worker de rejeu et les nouveaux lots.
Laisser les messages restants en DLQ.
Bloquer la classe fautive, vérifier les effets du canari et compenser si nécessaire.

La validation finale ne consiste pas à ramener la DLQ à zéro. Elle consiste à prouver que sa pente est revenue à la normale, que les messages éligibles ont produit exactement l’effet attendu, que les exclus restent justifiés et qu’un nouveau déploiement ne recrée pas la même classe d’échec.

Conclusion

Une dead-letter queue est une frontière d’exploitation, pas une poubelle. Elle conserve les messages que le système n’a pas su traiter sans risque. La bonne réponse est donc de classer, corréler, vérifier l’état aval et borner le rejeu.

Avec un manifeste, un canari, un ledger et des critères d’arrêt, l’équipe peut remettre en circulation uniquement ce qui est prouvé comme sûr. Le rollback reste simple : stopper le rejeu, laisser le solde en quarantaine et corriger la cause avant de reprendre.