DIEGO CECATO / NOTE DAL CAMPO

Symfony Messenger Inbox: il messaggio torna, il lavoro no

diego cecato//
Symfony Messenger Inbox: geometrie editoriali nere e blu con ritratto di Diego Cecato e titolo dell'articolo

Il worker aggiorna un ordine nel database, la transazione va a buon fine, poi perde la connessione prima di confermare al broker che il messaggio è stato gestito. Il broker fa ciò che deve: consegna di nuovo. E il nostro codice, se non abbiamo progettato quel passaggio, fa ciò che non dovrebbe: ripete l’operazione. Non è un bug esotico. È una conseguenza normale delle code che garantiscono la consegna almeno una volta.

Il 26 ottobre 2026 Symfony ha annunciato Messenger Inbox per la linea 8.2. L’idea è concreta: associare l’identità del messaggio al risultato della transazione, così una redelivery già conclusa non richiama gli handler una seconda volta. È un miglioramento importante, purché non lo si venda come la fine dell’idempotenza. Perché l’architettura di un sistema non finisce dove termina il database.

Il punto in cui la coda e il database smettono di parlarsi

Immaginiamo un messaggio ReserveStock per un ordine. Il consumer legge il messaggio, decrementa la disponibilità e fa commit. Per il database l’operazione è conclusa. Per il broker, invece, manca ancora l’acknowledgement: può arrivare dopo il commit, può perdersi per un timeout, oppure il processo può terminare nel mezzo.

Quando il messaggio torna, un handler ingenuo decrementa di nuovo. Aggiungere retry, aumentare il timeout o cambiare broker non risolve il confine: rende soltanto diversa la probabilità di attraversarlo. La documentazione ufficiale di Symfony Messenger ricorda da tempo che la consegna multipla è un comportamento ordinario e che gli handler devono essere progettati tenendone conto.

Il problema non è che un messaggio venga consegnato due volte. Il problema è che la nostra applicazione interpreti due consegne come due decisioni di business.

Cosa aggiunge davvero l’inbox di Symfony 8.2

L’opzione inbox del transport, annunciata per Symfony 8.2, registra nella tabella messenger_inbox il nome del transport e l’identificatore del messaggio. La registrazione avviene nella stessa transazione dei cambiamenti prodotti dagli handler. Questo dettaglio è tutto: se il lavoro è stato confermato, rimane anche la prova di averlo già eseguito; se il lavoro fallisce e la transazione viene annullata, viene annullata anche la registrazione e un tentativo successivo può elaborare davvero il messaggio.

La configurazione mostrata nell’annuncio ufficiale rende esplicite le tre condizioni: identità del messaggio, inbox sul transport e middleware transazionale Doctrine.

framework:
    messenger:
        identity_stamps: true
        transports:
            orders:
                dsn: '%env(MESSENGER_TRANSPORT_DSN)%'
                inbox: true
        buses:
            messenger.bus.default:
                middleware: [doctrine_transaction]

Non è una spunta magica. Serve il bridge symfony/doctrine-messenger, e la connessione del database usata dall’inbox deve essere la stessa che protegge le modifiche degli handler. La tabella può essere creata con messenger:setup-transports oppure tramite migrazione Doctrine. In produzione preferirei una migrazione esplicita, perché lo schema deve essere distribuito e verificato prima che un worker cominci a dipenderne.

Symfony descrive anche il caso di due worker che ricevono lo stesso messaggio: il secondo attende il completamento della transazione del primo. Se il primo conclude, il secondo riconosce la registrazione e salta gli handler; se il primo fallisce, la transazione si annulla e il messaggio resta processabile. È una garanzia circoscritta al meccanismo documentato, non una prova che ogni effetto esterno sia diventato atomico.

Inbox e outbox non sono sinonimi, e servono entrambe

Un mese prima Symfony aveva annunciato anche la transactional outbox. Il nome somiglia, ma protegge l’altro lato del problema. L’outbox riguarda il produttore: come salvo una modifica nel database e preparo un messaggio da inviare senza creare uno stato in cui esiste l’una ma non l’altro? Il messaggio viene prima salvato nello stesso database e un relay lo inoltra poi al broker.

L’inbox riguarda il consumatore: come evito di applicare di nuovo gli effetti di un messaggio che il broker ha riconsegnato dopo un commit già riuscito? Le due metà non eliminano le redelivery: rendono esplicita la responsabilità di chi pubblica e di chi consuma. Il relay dell’outbox può a sua volta ritrasmettere; la consumer inbox serve proprio a gestire una parte di quel comportamento.

Una sequenza concreta, senza formule pubblicitarie

  • Il servizio A registra un ordine e, nella stessa transazione, inserisce nell’outbox l’evento da inviare.
  • Un relay pubblica l’evento al broker. Se perde la conferma, potrebbe pubblicarlo ancora.
  • Il servizio B riceve il messaggio, applica modifiche locali e registra nell’inbox l’identità ricevuta, in una sola transazione.
  • Se il broker lo riconsegna dopo un commit riuscito, l’inbox riconosce quella identità e gli handler non ripartono.

Questa catena protegge bene i passaggi che condividono un confine transazionale. Non rende però atomico un servizio di pagamento remoto, un’email già spedita o una chiamata HTTP verso un gestionale. Quelli vivono fuori dalla transazione locale.

La parte che la tabella non può sapere: l’identità di business

Una inbox conosce il messaggio che ha visto. Non necessariamente l’operazione di business che quel messaggio rappresenta. Se lo stesso ordine viene trasformato per errore in due messaggi con due identificatori differenti, la deduplica basata sull’identità del messaggio non basta a riconoscere che la richiesta economica è una sola.

Per operazioni non idempotenti serve ancora una chiave stabile legata al fatto di business, ad esempio payment_attempt_id o order_id + operation_type, accompagnata da un vincolo di unicità e da uno stato persistente. Quando si chiama un PSP o un’altra API esterna, bisogna verificare se il provider supporta una propria idempotency key e come ne definisce la durata. È una responsabilità applicativa: nessun middleware può inventarsi la semantica corretta del pagamento.

Qui il confronto con l’analisi sui lock di Symfony 8.2 è utile: un lock limita la concorrenza, ma non equivale a una registrazione durevole dell’operazione conclusa. L’inbox aggiunge una prova transazionale per le redelivery dello stesso messaggio; la chiave di business affronta un problema diverso. Confondere questi livelli significa rimandare l’errore alla prima giornata storta.

Sette giorni di retention non sono sette giorni di sicurezza

L’annuncio indica una retention predefinita di sette giorni. È un valore di partenza, non una garanzia universale. Deve superare la massima finestra nella quale il transport può riconsegnare un messaggio. Se una coda di errore conserva eventi per settimane, oppure esistono replay operativi più tardivi, eliminare troppo presto la traccia di elaborazione riapre la possibilità di rieseguire un handler già concluso.

Il dimensionamento richiede dati: ritardi di consumo, politiche di retry, tempi di permanenza nella failure queue, procedure di replay e frequenza di pulizia. Una retention enorme, per contro, porta crescita della tabella, indici e manutenzione. Il parametro non va scelto perché è quello dell’esempio YAML, ma perché corrisponde alla finestra reale del sistema.

Tre limiti da verificare prima di abilitare l’opzione

1. I batch handler non rientrano nella copertura

Symfony specifica che i messaggi gestiti dai batch handler non vengono registrati nell’inbox. Chi usa elaborazioni a blocchi deve quindi mantenere controlli applicativi propri e non assumere che l’opzione copra ogni consumer configurato nello stesso progetto.

2. La transazione deve essere davvero condivisa

Se la logica di business scrive su un database differente, o lancia side effect prima del commit, la registrazione dell’inbox e l’effetto non partecipano necessariamente allo stesso destino. La correttezza va dimostrata con la configurazione delle connessioni e con test di rollback, non dedotta dalla presenza della parola transaction in un file di configurazione.

3. DispatchAfterCurrentBusStamp richiede attenzione

La documentazione della nuova inbox evidenzia un vincolo specifico: i messaggi inviati con DispatchAfterCurrentBusStamp devono andare a un transport dotato di outbox. La registrazione inbox viene confermata prima che quei messaggi siano spediti; se l’invio fallisse senza outbox, il consumer originale risulterebbe già completato e una redelivery non recupererebbe il messaggio figlio. Symfony rifiuta esplicitamente quella spedizione senza outbox.

Come lo testerei prima di chiamarlo affidabile

Non partirei da un benchmark di throughput. Partirei da quattro guasti riproducibili, perché sono i punti in cui il sistema deve dimostrare il proprio contratto.

  • Commit riuscito, ack perso: riconsegnare lo stesso messaggio e verificare che l’effetto locale non venga duplicato.
  • Eccezione prima del commit: controllare che siano annullati sia la modifica di business sia il record inbox, poi verificare un retry riuscito.
  • Due consumer concorrenti: inviare lo stesso identificatore e verificare un solo effetto locale, osservando lock e tempi di attesa.
  • Effetto esterno non transazionale: simulare il timeout di una chiamata HTTP già accettata dal destinatario e dimostrare che l’idempotenza lato business resta necessaria.

Aggiungerei metriche sulla dimensione della tabella inbox, sul numero di redelivery saltate, sull’età del messaggio più vecchio nelle failure queue e sugli errori di inoltro outbox. Se la funzionalità riduce i doppi effetti ma nessuno misura la finestra di replay, abbiamo soltanto spostato il punto cieco.

La vera novità è il contratto, non la checkbox

Symfony 8.2 porta un meccanismo utile in un posto sensato: vicino al trasporto e dentro la transazione che decide se l’handler ha davvero completato il proprio lavoro. È una funzione annunciata per la linea 8.2, non un invito a dare per scontata la disponibilità in qualunque installazione stabile oggi in produzione. Versione, dipendenze e percorso di migrazione vanno verificati prima del rollout.

Il punto, però, è più generale di Symfony. I sistemi distribuiti non diventano affidabili perché una coda consegna tutto o perché un framework promette di ricordare. Diventano più prevedibili quando ogni confine ha un proprietario, una prova persistente e un comportamento verificabile in caso di guasto. L’inbox chiude bene un confine. Gli altri, purtroppo, restano ancora lavoro nostro.

Domande frequenti

Messenger Inbox impedisce sempre che un pagamento venga eseguito due volte?

No. Evita la riesecuzione degli handler per redelivery riconosciute dello stesso messaggio, quando le modifiche condividono la transazione dell’inbox. Un pagamento verso un provider esterno richiede ancora una chiave di idempotenza e controlli applicativi.

Qual è la differenza fra inbox e outbox in Symfony Messenger?

L’outbox rende transazionale la preparazione di messaggi in uscita rispetto alle modifiche locali; l’inbox registra nella transazione di consumo quali messaggi sono già stati elaborati, per saltare redelivery già concluse.

Perché la retention dell'inbox deve superare sette giorni in alcuni sistemi?

Sette giorni è il valore predefinito annunciato, non una durata universalmente sicura. La conservazione deve superare la massima finestra di riconsegna, considerando retry, failure queue e replay operativi.

L'inbox di Symfony 8.2 copre anche i batch handler?

No. Symfony specifica che i messaggi gestiti dai batch handler non vengono registrati nell’inbox; questi flussi richiedono misure di deduplica e idempotenza dedicate.