Salta al contenuto principale

05 - Saga Pattern: Transazioni Distribuite con Choreography e Orchestration

Una saga sostituisce la transazione distribuita con passi e compensazioni: orchestrazione e coreografia provate su rifiuto, timeout e compensazione fallita.

Cosa ne pensi di questo articolo?

05 - Saga Pattern: Transazioni Distribuite con Choreography e Orchestration
Event-Driven ArchitectureFoto:Mikhail NilovPexels

In Breve

Una saga sostituisce la transazione distribuita con passi e compensazioni: orchestrazione e coreografia provate su rifiuto, timeout e compensazione fallita.

Un acquisto, tre servizi, nessuna transazione che li copra

In un monolite l'acquisto è una transazione: crei l'ordine, scali lo stock, registri il pagamento, e se qualcosa va storto il database annulla tutto. Con tre servizi, ciascuno col suo database, un BEGIN ... COMMIT che li attraversa non esiste. Il protocollo classico per farlo è il two-phase commit: un coordinatore chiede a tutti di prepararsi, poi ordina il commit. Funziona, ma tiene bloccate le risorse dei partecipanti finché il coordinatore decide, e li vuole tutti raggiungibili nello stesso momento: due condizioni che nei servizi indipendenti, con API di terzi nel mezzo, non puoi garantire.

La saga rinuncia all'atomicità globale e la sostituisce con una sequenza di transazioni locali, ciascuna confermata subito, più per ognuna una transazione compensativa che ne annulla l'effetto di business se un passo successivo fallisce. Il concetto viene da Hector Garcia-Molina e Kenneth Salem, che lo proposero nel 1987 per le transazioni di lunga durata nei database.

Il prezzo è che per un po' il sistema è visibilmente a metà: lo stock è scalato e il pagamento non c'è ancora. Questo articolo costruisce la saga di un acquisto in due modi, con un orchestratore e con una coreografia di eventi, li esegue sui quattro casi che contano (successo, rifiuto, errore temporaneo, compensazione che fallisce) e dice dove ciascun modello fa male. Presuppone il vocabolario di eventi e comandi.

I tre servizi della prova e la chiave di idempotenza

Le tre classi seguenti sono i partecipanti: Inventario, Pagamenti e Ordini, in memoria. Ogni operazione che modifica qualcosa riceve una chiave di idempotenza (id della saga più nome del passo) e ricorda quelle già eseguite: riceverne due volte la stessa non ripete l'effetto. È la ragione per cui, più avanti, una compensazione consegnata due volte non rilascia lo stock due volte.

Le due manopole transitori e rifiuta distinguono un errore temporaneo (il gateway va in timeout, vale la pena riprovare) da un rifiuto definitivo (carta respinta, riprovare non serve): la distinzione decide se si ritenta o si compensa. nonRaggiungibile fa fallire il rilascio dello stock, per provare che cosa accade quando è la compensazione a non riuscire.

export class ErroreTransitorio extends Error {}

// Ogni operazione porta una chiave di idempotenza: ripeterla non ripete l'effetto.
export class Inventario {
  stock = new Map<string, number>([['penna', 5]]);
  private fatte = new Set<string>();
  nonRaggiungibile = 0; // numero di chiamate a rilascia() che falliranno prima di funzionare

  riserva(chiave: string, prodotto: string, q: number): void {
    if (this.fatte.has(chiave)) return;
    const disponibili = this.stock.get(prodotto) ?? 0;
    if (disponibili < q) throw new Error('InventarioInsufficiente');
    this.stock.set(prodotto, disponibili - q);
    this.fatte.add(chiave);
  }
  rilascia(chiave: string, prodotto: string, q: number): void {
    if (this.nonRaggiungibile > 0) { this.nonRaggiungibile--; throw new ErroreTransitorio('Inventario non raggiungibile'); }
    if (this.fatte.has(chiave)) return;
    this.stock.set(prodotto, (this.stock.get(prodotto) ?? 0) + q);
    this.fatte.add(chiave);
  }
}

export class Pagamenti {
  transitori = 0;     // fallimenti temporanei prima del successo
  rifiuta = false;    // rifiuto definitivo (carta respinta)
  incassi = 0;
  private fatte = new Set<string>();
  paga(chiave: string): void {
    if (this.fatte.has(chiave)) return;
    if (this.transitori > 0) { this.transitori--; throw new ErroreTransitorio('Gateway pagamenti in timeout'); }
    if (this.rifiuta) throw new Error('PagamentoRifiutato');
    this.incassi++;
    this.fatte.add(chiave);
  }
}

export class Ordini {
  stati = new Map<string, string>();
  imposta(id: string, stato: string): void { this.stati.set(id, stato); }
}

Questo modulo non ha un output proprio: lo esercitano i due programmi seguenti. Una nota di realismo: la memoria delle chiavi già viste è un Set in RAM, quindi si perde al riavvio. In produzione la chiave si salva nello stesso database e nella stessa transazione dell'effetto, altrimenti l'idempotenza protegge solo finché il processo non muore.

Orchestrazione: un solo posto sa a che punto è la saga

Nell'orchestrazione un componente centrale conosce la sequenza dei passi, li invoca uno a uno e, se uno fallisce, esegue le compensazioni dei passi già completati in ordine inverso. L'ordine inverso non è una convenzione: la compensazione di un passo può dipendere dal fatto che quelli successivi siano già stati disfatti.

Nel programma, eseguiSaga tiene la lista dei passi fatti e, al primo errore, la percorre al contrario saltando i passi senza compensazione (qui paga, che se fallisce non ha lasciato nulla da disfare, e conferma ordine, che è l'ultimo). conRetry ritenta solo gli ErroreTransitorio e raddoppia l'attesa a ogni tentativo; l'attesa è scritta nel log ma non eseguita, per non rallentare la prova. Gli altri errori passano direttamente alla compensazione.

import { Inventario, Pagamenti, Ordini, ErroreTransitorio } from './servizi.ts';

type Passo = { nome: string; esegui: () => void; compensa?: () => void; tentativi?: number };
type Esito = 'COMPLETATA' | 'COMPENSATA' | 'BLOCCATA';

// Ripete solo gli errori transitori, con attesa raddoppiata (qui scritta nel log, non aspettata).
function conRetry(nome: string, f: () => void, max: number, traccia: string[]): void {
  let attesa = 2;
  for (let t = 1; ; t++) {
    try { f(); return; } catch (e) {
      if (!(e instanceof ErroreTransitorio) || t >= max) throw e;
      traccia.push(`  ${nome}: tentativo ${t} fallito, riprovo dopo ${attesa} s`);
      attesa *= 2;
    }
  }
}

function eseguiSaga(id: string, passi: Passo[], traccia: string[]): Esito {
  const fatti: Passo[] = [];
  for (const p of passi) {
    try {
      conRetry(p.nome, p.esegui, p.tentativi ?? 1, traccia);
      traccia.push(`ok       ${p.nome}`);
      fatti.push(p);
    } catch (e) {
      traccia.push(`FALLITO  ${p.nome}: ${(e as Error).message}`);
      // Compensazione: i soli passi completati, in ordine inverso.
      for (const c of [...fatti].reverse()) {
        if (!c.compensa) continue;
        try { conRetry(`compensa ${c.nome}`, c.compensa, 3, traccia); traccia.push(`compensato ${c.nome}`); }
        catch (e2) { traccia.push(`BLOCCATA su compensa ${c.nome}: ${(e2 as Error).message}`); return 'BLOCCATA'; }
      }
      return 'COMPENSATA';
    }
  }
  return 'COMPLETATA';
}

function scenario(titolo: string, prepara: (inv: Inventario, pag: Pagamenti) => void) {
  const inv = new Inventario(), pag = new Pagamenti(), ord = new Ordini();
  prepara(inv, pag);
  const id = 'saga-1';
  const passi: Passo[] = [
    { nome: 'crea ordine', esegui: () => ord.imposta(id, 'PENDING'), compensa: () => ord.imposta(id, 'ANNULLATO') },
    { nome: 'riserva stock', esegui: () => inv.riserva(`${id}:riserva`, 'penna', 2), compensa: () => inv.rilascia(`${id}:rilascia`, 'penna', 2) },
    { nome: 'paga', esegui: () => pag.paga(`${id}:paga`), tentativi: 3 },
    { nome: 'conferma ordine', esegui: () => ord.imposta(id, 'CONFERMATO') },
  ];
  const traccia: string[] = [];
  const esito = eseguiSaga(id, passi, traccia);
  console.log(`== ${titolo}\n${traccia.join('\n')}\nesito ${esito} | ordine ${ord.stati.get(id)} | stock penna ${inv.stock.get('penna')} | incassi ${pag.incassi}`);
}

scenario('tutto bene', () => {});
scenario('pagamento rifiutato', (_i, p) => { p.rifiuta = true; });
scenario('pagamento: due timeout, poi ok', (_i, p) => { p.transitori = 2; });
scenario('rifiuto + inventario irraggiungibile una volta', (i, p) => { p.rifiuta = true; i.nonRaggiungibile = 1; });
scenario('rifiuto + inventario irraggiungibile sempre', (i, p) => { p.rifiuta = true; i.nonRaggiungibile = 99; });

// La stessa compensazione consegnata due volte non rilascia due volte.
const inv = new Inventario();
inv.riserva('x:riserva', 'penna', 2);
inv.rilascia('x:rilascia', 'penna', 2);
inv.rilascia('x:rilascia', 'penna', 2);
console.log('== compensazione ripetuta | stock penna', inv.stock.get('penna'), '(5 all\'inizio)');

// Output:
// == tutto bene
// ok       crea ordine
// ok       riserva stock
// ok       paga
// ok       conferma ordine
// esito COMPLETATA | ordine CONFERMATO | stock penna 3 | incassi 1
// == pagamento rifiutato
// ok       crea ordine
// ok       riserva stock
// FALLITO  paga: PagamentoRifiutato
// compensato riserva stock
// compensato crea ordine
// esito COMPENSATA | ordine ANNULLATO | stock penna 5 | incassi 0
// == pagamento: due timeout, poi ok
// ok       crea ordine
// ok       riserva stock
//   paga: tentativo 1 fallito, riprovo dopo 2 s
//   paga: tentativo 2 fallito, riprovo dopo 4 s
// ok       paga
// ok       conferma ordine
// esito COMPLETATA | ordine CONFERMATO | stock penna 3 | incassi 1
// == rifiuto + inventario irraggiungibile una volta
// ok       crea ordine
// ok       riserva stock
// FALLITO  paga: PagamentoRifiutato
//   compensa riserva stock: tentativo 1 fallito, riprovo dopo 2 s
// compensato riserva stock
// compensato crea ordine
// esito COMPENSATA | ordine ANNULLATO | stock penna 5 | incassi 0
// == rifiuto + inventario irraggiungibile sempre
// ok       crea ordine
// ok       riserva stock
// FALLITO  paga: PagamentoRifiutato
//   compensa riserva stock: tentativo 1 fallito, riprovo dopo 2 s
//   compensa riserva stock: tentativo 2 fallito, riprovo dopo 4 s
// BLOCCATA su compensa riserva stock: Inventario non raggiungibile
// esito BLOCCATA | ordine PENDING | stock penna 3 | incassi 0
// == compensazione ripetuta | stock penna 5 (5 all'inizio)

Nel caso del rifiuto l'uscita mostra l'ordine corretto: prima si rilascia lo stock, poi si annulla l'ordine, e alla fine lo stock è tornato a 5, l'ordine è ANNULLATO e gli incassi sono zero. Nel caso dei due timeout il pagamento riesce al terzo tentativo: se avessi compensato al primo errore avresti annullato un acquisto che sarebbe andato a buon fine.

L'ultimo caso è quello da ricordare: quando anche il rilascio dello stock fallisce per tre volte, la saga finisce in BLOCCATA, con l'ordine ancora PENDING e due penne tolte dal magazzino per un acquisto che non esisterà. Nessun meccanismo automatico risolve questa situazione: serve uno stato esplicito della saga che un allarme possa leggere, e un intervento umano o una coda di ritentativi lunghi. L'ultima riga mostra l'idempotenza: chiamare due volte la stessa compensazione lascia lo stock a 5.

Coreografia: nessuno sa l'intera storia

Nella coreografia non c'è un orchestratore. Ogni servizio ascolta gli eventi che lo riguardano, fa il proprio passo e ne pubblica uno nuovo; la compensazione è un altro ascolto (qui Inventario e Ordini reagiscono a PagamentoFallito). Il bus del programma è una coda e una mappa di ascoltatori, con la traccia di chi ha ricevuto che cosa.

import { Inventario, Pagamenti, Ordini } from './servizi.ts';

type Evento = { tipo: string; sagaId: string };
const inv = new Inventario(), pag = new Pagamenti(), ord = new Ordini();
pag.rifiuta = true; // il pagamento verra' rifiutato

// Bus minimo: una coda e, per ogni tipo di evento, i servizi che lo ascoltano.
const coda: Evento[] = [];
const ascoltatori = new Map<string, ((e: Evento) => void)[]>();
const traccia: string[] = [];
const ascolta = (tipo: string, servizio: string, f: (e: Evento) => void) =>
  ascoltatori.set(tipo, [...(ascoltatori.get(tipo) ?? []), (e) => { traccia.push(`${e.tipo.padEnd(20)} -> ${servizio}`); f(e); }]);
const pubblica = (tipo: string, sagaId: string) => coda.push({ tipo, sagaId });

// Ognuno conosce solo il proprio passo e gli eventi a cui reagisce.
ascolta('OrdineCreato', 'inventario', (e) => {
  try { inv.riserva(`${e.sagaId}:riserva`, 'penna', 2); pubblica('InventarioRiservato', e.sagaId); }
  catch { pubblica('InventarioNonDisponibile', e.sagaId); }
});
ascolta('InventarioRiservato', 'pagamenti', (e) => {
  try { pag.paga(`${e.sagaId}:paga`); pubblica('PagamentoConfermato', e.sagaId); }
  catch { pubblica('PagamentoFallito', e.sagaId); }
});
ascolta('PagamentoConfermato', 'ordini', (e) => ord.imposta(e.sagaId, 'CONFERMATO'));
ascolta('PagamentoFallito', 'inventario', (e) => inv.rilascia(`${e.sagaId}:rilascia`, 'penna', 2)); // compensa
ascolta('PagamentoFallito', 'ordini', (e) => ord.imposta(e.sagaId, 'ANNULLATO'));                  // compensa
ascolta('InventarioNonDisponibile', 'ordini', (e) => ord.imposta(e.sagaId, 'ANNULLATO'));

ord.imposta('saga-1', 'PENDING');
pubblica('OrdineCreato', 'saga-1');
for (let e = coda.shift(); e; e = coda.shift()) (ascoltatori.get(e.tipo) ?? []).forEach((f) => f(e));

console.log(traccia.join('\n'));
console.log(`ordine ${ord.stati.get('saga-1')} | stock penna ${inv.stock.get('penna')} | incassi ${pag.incassi}`);

// Output:
// OrdineCreato         -> inventario
// InventarioRiservato  -> pagamenti
// PagamentoFallito     -> inventario
// PagamentoFallito     -> ordini
// ordine ANNULLATO | stock penna 5 | incassi 0

La traccia è l'unica prova che la saga è esistita: dice che l'ordine, partito da OrdineCreato, ha attraversato inventario e pagamenti e che il fallimento è stato ascoltato da due servizi. Questa leggibilità è finta, perché l'ho prodotta io con un push sulla stessa lista: in un sistema vero la traccia non esiste, e per sapere a che punto è una saga devi raccogliere gli eventi di più servizi con un identificativo di correlazione (sagaId).

Nessun servizio vede l'intero percorso, nessun punto singolo può fermarsi, e proprio per questo un ciclo di eventi o un passo dimenticato si scoprono tardi. Su un broker vero cambia anche un altro fatto: gli ascoltatori sono consumatori indipendenti, e la consegna è almeno una volta, quindi la chiave di idempotenza della sezione precedente non è facoltativa.

Una compensazione non è un rollback

Un rollback fa sparire l'effetto come se non fosse mai accaduto; una compensazione ne produce uno nuovo che lo neutralizza dal punto di vista del business. Lo stock rilasciato torna disponibile, ma qualcuno potrebbe averlo visto esaurito nel frattempo; un pagamento incassato si rimborsa, ma il cliente vede due movimenti; una mail di conferma già partita non si ritira, si manda un'altra.

Per questo il lavoro di progetto sta nel sapere, per ogni passo, quale sia la sua compensazione e se esista. Quando non esiste (l'invio della mail), il passo va messo dopo quelli che possono fallire, non prima. La compensazione deve poter essere ripetuta senza danni, e deve poter riuscire sempre: se può fallire a sua volta, la saga ha bisogno dello stato BLOCCATA di cui sopra.

Orchestrazione su un servizio gestito: Step Functions

I prodotti gestiti incorporano gli stessi due meccanismi. In AWS Step Functions il flusso si scrive come macchina a stati in Amazon States Language: un passo Task può avere un blocco Retry per gli errori temporanei e un blocco Catch che indirizza un errore definitivo al passo di compensazione. Il frammento sotto è lo schema equivalente del pagamento della prova. Non è stato eseguito (richiede un account e delle funzioni Lambda): è «schema da adattare», scritto sulla documentazione.

{
  "StartAt": "ProcessaPagamento",
  "States": {
    "ProcessaPagamento": {
      "Type": "Task",
      "Resource": "arn:aws:lambda:eu-west-1:111122223333:function:processa-pagamento",
      "Retry": [
        { "ErrorEquals": ["TransientPaymentError"], "IntervalSeconds": 2, "MaxAttempts": 3, "BackoffRate": 2 }
      ],
      "Catch": [
        { "ErrorEquals": ["PagamentoRifiutato"], "Next": "RilasciaInventario" }
      ],
      "Next": "ConfermaOrdine"
    },
    "RilasciaInventario": { "Type": "Task", "Resource": "arn:aws:lambda:eu-west-1:111122223333:function:rilascia-inventario", "Next": "AnnullaOrdine" },
    "AnnullaOrdine": { "Type": "Task", "Resource": "arn:aws:lambda:eu-west-1:111122223333:function:annulla-ordine", "Next": "SagaFallita" },
    "SagaFallita": { "Type": "Fail", "Error": "SagaFailed" },
    "ConfermaOrdine": { "Type": "Task", "Resource": "arn:aws:lambda:eu-west-1:111122223333:function:conferma-ordine", "End": true }
  }
}

Il Retry riproduce conRetry: la documentazione definisce IntervalSeconds (attesa prima del primo tentativo, 1 per default), MaxAttempts (3 per default) e BackoffRate (moltiplicatore dell'attesa, 2 per default). Qui i valori sono scritti esplicitamente per non dipendere dai default. Il Catch manda PagamentoRifiutato verso RilasciaInventario, poi AnnullaOrdine, poi uno stato Fail: la catena di compensazioni qui è scritta a mano nel grafo, non derivata automaticamente. Il vantaggio rispetto al codice è che lo stato di ogni esecuzione è visibile nel servizio; il costo è un vincolo a quella piattaforma.

Quale scegliere

AspettoCoreografiaOrchestrazione
Dove vive la logicaDistribuita nei serviziConcentrata nell'orchestratore
Stato della sagaDa ricostruire dagli eventiEsplicito in un punto
AccoppiamentoOgnuno conosce gli eventi altruiL'orchestratore conosce tutti i servizi
Aggiungere un passoSi aggiunge un ascoltatoreSi modifica l'orchestratore
Rischio tipicoCicli e passi dimenticatiOrchestratore troppo grosso

La regola pratica è il numero di passi. Con due o tre servizi e team indipendenti la coreografia è leggera e basta; oltre quattro passi, o quando serve sapere in ogni momento dov'è la saga, l'orchestrazione costa meno di quanto fa risparmiare la sua visibilità. Chris Richardson, che ha catalogato il pattern, descrive entrambe le coordinazioni e rimanda all'event sourcing e all'outbox transazionale come modi per aggiornare lo stato e pubblicare i messaggi in modo atomico.

Questo è il punto debole che i programmi sopra non risolvono: il servizio che salva e poi pubblica può morire a metà, ed è il tema del pattern outbox. I ritentativi e le compensazioni che non riescono si gestiscono con la dead letter queue.

Che cosa sai fare adesso

Sai spiegare perché una transazione distribuita diventa una sequenza di passi con compensazioni, sai implementare un orchestratore che compensa in ordine inverso e ritenta solo gli errori temporanei, e sai riconoscere lo stato in cui la saga si blocca. Sai anche che le chiavi di idempotenza sono il prerequisito di tutto, e perché la scelta tra coreografia e orchestrazione si decide sul numero di passi e sulla visibilità richiesta. Per le basi di come i servizi si scambiano gli eventi, vedi il confronto tra SQS, SNS ed EventBridge. Se gli eventi della saga finiscono in un event store, la ricostruzione delle viste è in Event Sourcing + CQRS; per ciò che l'utente vede mentre la saga è a metà, vedi la consistenza eventuale.

Fonti

I due programmi (orchestrazione.ts e coreografia.ts, con il modulo servizi.ts) sono stati eseguiti con Node.js v24.11.0; gli output nei commenti sono quelli reali. Il frammento Step Functions non è stato eseguito (schema da adattare). I broker veri non sono coinvolti: bus, coda e chiavi di idempotenza sono in memoria.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti