Temporal.io: Sostituire le Code di Messaggi con Esecuzione Durabile per Orchestrazione Complessa
Definizione di Esecuzione Durabile
L'idea centrale dietro Temporal è semplice da enunciare e sconvolgente in pratica: il tuo codice viene persistito. Non solo i dati, non solo lo stato — il progresso effettivo dell'esecuzione della tua funzione viene registrato così che se il processo crasha, può riprendere esattamente dove si era fermato.
Ecco un workflow Temporal in TypeScript:
import { proxyActivities, sleep, ApplicationFailure } from '@temporalio/workflow';
import type * as activities from './activities';
const { chargePayment, reserveInventory, shipOrder, sendConfirmation, refundPayment, releaseInventory } =
proxyActivities<typeof activities>({
startToCloseTimeout: '30s',
retry: {
maximumAttempts: 3,
backoffCoefficient: 2,
initialInterval: '1s',
},
});
export async function processOrder(order: OrderInput): Promise<OrderResult> {
// Passo 1: Riservare inventario
const reservation = await reserveInventory(order.items);
// Passo 2: Addebitare pagamento
let paymentId: string;
try {
paymentId = await chargePayment(order.paymentMethod, order.total);
} catch (err) {
// Compensazione: rilasciare inventario se il pagamento fallisce
await releaseInventory(reservation.id);
throw ApplicationFailure.nonRetryable(
`Pagamento fallito: ${err.message}`
);
}
// Passo 3: Spedire l'ordine
let shipmentId: string;
try {
shipmentId = await shipOrder(reservation.id, order.shippingAddress);
} catch (err) {
// Compensazione: rimborsare pagamento e rilasciare inventario
await refundPayment(paymentId);
await releaseInventory(reservation.id);
throw ApplicationFailure.nonRetryable(
`Spedizione fallita: ${err.message}`
);
}
// Passo 4: Inviare conferma
await sendConfirmation(order.email, {
orderId: order.id,
paymentId,
shipmentId,
items: order.items,
});
return { orderId: order.id, paymentId, shipmentId, status: 'completed' };
}
Guarda questo codice. È semplicemente una funzione async regolare con blocchi try-catch. Nessuna macchina a stati. Nessuna serializzazione di stati intermedi in un database. Nessuna logica di retry wrappata attorno ad ogni chiamata esterna. Nessuna gestione di dead-letter queue.
Ma ecco cosa fa Temporal dietro le quinte:
- Quando
reserveInventoryviene chiamata, Temporal registra un evento "ScheduleActivityTask" nella event history del workflow - Quando l'activity completa, viene registrato un evento "ActivityTaskCompleted" col risultato
- Se il processo worker crasha tra i passi 2 e 3 (pagamento), un nuovo worker prende il workflow
- Temporal fa replay della funzione workflow dall'inizio, ma invece di ri-eseguire
reserveInventory, legge il risultato registrato dalla event history - Il replay raggiunge il punto di fallimento e continua ad eseguire i passi rimanenti
Questo è ciò che significa "esecuzione durabile". La funzione workflow sembra girare continuamente, ma in realtà è una serie di passi registrati che possono essere replayati su qualsiasi worker. L'await su ogni activity è un punto di persistenza.
Il Vincolo di Determinismo
C'è un vincolo fondamentale che fa funzionare tutto questo: le funzioni workflow devono essere deterministiche. Dati gli stessi input e gli stessi risultati di activity, il workflow deve prendere la stessa sequenza di decisioni.
Questo significa che non puoi fare quanto segue dentro una funzione workflow:
// TUTTO QUESTO ROMPE IL REPLAY
// 1. Tempo non-deterministico
const now = Date.now(); // Diverso al replay!
// Usa invece: workflow.now()
// 2. Numeri random
const id = Math.random().toString(36); // Diverso al replay!
// Usa invece: workflow.uuid4()
// 3. I/O diretto
const data = await fetch('https://api.example.com/data'); // Effetto collaterale!
// Usa invece: un'activity
// 4. Iterazione non-deterministica
const keys = Object.keys(someMap); // Ordine non garantito!
// Usa invece: chiavi ordinate o array
Questo vincolo inizialmente sembra restrittivo. Con l'uso prolungato, si rivela come un feature. Forza una separazione pulita tra decisioni (workflow) e effetti (activity). Il workflow è logica pura. Le activity sono dove succede tutta l'interazione disordinata col mondo reale.
Le activity sono funzioni regolari che possono fare qualsiasi cosa — chiamate HTTP, query al database, I/O su file, chiamate API di terze parti. Girano su worker, e Temporal gestisce retry, timeout e heartbeating:
// activities.ts — queste girano sui worker, NON nel sandbox del workflow
import { Context } from '@temporalio/activity';
export async function chargePayment(
method: PaymentMethod,
amount: number
): Promise<string> {
Context.current().heartbeat('addebitando');
const result = await stripe.charges.create({
amount: Math.round(amount * 100),
currency: 'usd',
source: method.token,
});
return result.id;
}
export async function shipOrder(
reservationId: string,
address: ShippingAddress
): Promise<string> {
Context.current().heartbeat('creando spedizione');
const shipment = await shippingProvider.createShipment({
reservationId,
address,
service: 'standard',
});
return shipment.trackingNumber;
}
Pattern Saga: Compensazione Fatta Bene
Il workflow di elaborazione ordini sopra implementa il pattern saga — una sequenza di transazioni con azioni compensatorie se qualche passo fallisce. In un sistema tipico basato su RabbitMQ, il coordinatore saga è il suo proprio servizio con il suo proprio database, che traccia lo stato di ogni istanza saga.
Con Temporal, il pattern saga sono semplicemente blocchi try-catch. Ma per workflow più complessi con molti passi, un helper che accumula compensazioni è utile:
class SagaCompensation {
private compensations: Array<() => Promise<void>> = [];
addCompensation(fn: () => Promise<void>): void {
this.compensations.unshift(fn); // LIFO — compensa in ordine inverso
}
async compensate(): Promise<void> {
for (const compensation of this.compensations) {
try {
await compensation();
} catch (err) {
console.error('Compensazione fallita:', err);
}
}
}
}
export async function processComplexOrder(order: ComplexOrder): Promise<void> {
const saga = new SagaCompensation();
try {
const reservation = await reserveInventory(order.items);
saga.addCompensation(() => releaseInventory(reservation.id));
const paymentId = await chargePayment(order.payment, order.total);
saga.addCompensation(() => refundPayment(paymentId));
const discountApplied = await applyLoyaltyDiscount(order.customerId, order.total);
saga.addCompensation(() => revertLoyaltyDiscount(order.customerId, discountApplied));
const shipmentId = await shipOrder(reservation.id, order.address);
saga.addCompensation(() => cancelShipment(shipmentId));
await updateOrderStatus(order.id, 'completed');
await sendConfirmation(order.email, { reservation, paymentId, shipmentId });
} catch (err) {
await saga.compensate();
await updateOrderStatus(order.id, 'failed');
throw err;
}
}
Questo è lo stesso pattern che un coordinatore saga da 3.000 righe implementa, in circa 50 righe. Ed è affidabile — se un worker crasha durante la compensazione, Temporal fa replay del workflow e la compensazione continua da dove si era fermata.
Segnali, Query, e Human-in-the-Loop
I workflow Temporal possono ricevere segnali esterni (eventi che modificano lo stato del workflow) e rispondere a query (ispezione read-only dello stato del workflow). Questo è incredibilmente potente per workflow che necessitano approvazione umana o input esterno:
import { defineSignal, defineQuery, setHandler, condition } from '@temporalio/workflow';
const approveSignal = defineSignal<[{ approvedBy: string; notes: string }]>('approve');
const rejectSignal = defineSignal<[{ rejectedBy: string; reason: string }]>('reject');
const statusQuery = defineQuery<OrderStatus>('status');
export async function orderWithApproval(order: OrderInput): Promise<OrderResult> {
let status: OrderStatus = 'pending_approval';
let approval: { approvedBy: string; notes: string } | null = null;
let rejection: { rejectedBy: string; reason: string } | null = null;
setHandler(approveSignal, (data) => {
approval = data;
status = 'approved';
});
setHandler(rejectSignal, (data) => {
rejection = data;
status = 'rejected';
});
setHandler(statusQuery, () => status);
const approved = await condition(
() => approval !== null || rejection !== null,
'24h'
);
if (!approved || rejection) {
status = 'rejected';
await notifyRejection(order.email, rejection?.reason ?? 'Timeout approvazione');
return { orderId: order.id, status: 'rejected' };
}
status = 'processing';
// ... resto del workflow dell'ordine
}
Dall'esterno, segnali il workflow:
const handle = client.workflow.getHandle('order-12345');
await handle.signal(approveSignal, {
approvedBy: 'manager@company.com',
notes: 'Approvato per cliente VIP',
});
Il workflow può stare in attesa di approvazione per ore, giorni o settimane. Il worker non mantiene una connessione aperta. Non fa polling. Lo stato del workflow è persistito nel database di Temporal. Quando il segnale arriva, Temporal schedula il workflow per l'esecuzione, un worker lo prende, fa replay della history, processa il segnale, e continua.
Architettura di Produzione
Un setup di produzione esegue Temporal Server su Kubernetes con backend PostgreSQL:
apiVersion: apps/v1
kind: Deployment
metadata:
name: temporal-server
spec:
replicas: 3
template:
spec:
containers:
- name: temporal-server
image: temporalio/server:1.25.2
env:
- name: DB
value: postgresql
- name: POSTGRES_HOST
value: temporal-postgres.db.svc.cluster.local
- name: NUM_HISTORY_SHARDS
value: "512"
ports:
- containerPort: 7233
- containerPort: 7234
- containerPort: 7235
- containerPort: 7239
I worker girano come deployment separati:
import { Worker } from '@temporalio/worker';
import * as activities from './activities';
async function run() {
const worker = await Worker.create({
workflowsPath: require.resolve('./workflows'),
activities,
taskQueue: 'order-processing',
maxConcurrentActivityTaskExecutions: 100,
maxConcurrentWorkflowTaskExecutions: 50,
stickyQueueScheduleToStartTimeout: '10s',
});
await worker.run();
}
run().catch((err) => {
console.error(err);
process.exit(1);
});
Componenti Sostituiti e Risultati Misurati
Prima di Temporal:
- 7 code RabbitMQ, 4 dead-letter queue
- 12 servizi consumer (Node.js)
- 1 servizio coordinatore saga (3.000 righe)
- 1 database PostgreSQL per stato saga
- Logica retry custom in ogni consumer
- Script compensazione manuali per saga bloccate
Dopo Temporal:
- 1 cluster Temporal (3 nodi)
- 3 deployment worker (ordine, pagamento, spedizione)
- ~800 righe di codice workflow
- ~1.200 righe di codice activity
- Zero logica retry custom
- Zero script compensazione manuali
L'impatto operativo:
- Saga bloccate/orfane: Passate da ~15/settimana a zero. Temporal non perde traccia dei workflow.
- Incidenti di doppio addebito: Passati da ~3/mese a zero. Il pattern saga con esecuzione durabile garantisce effettivamente che la compensazione venga eseguita.
- Tempo medio per diagnosticare problemi: Sceso da 2-3 ore (correlare log tra 12 servizi) a 5-10 minuti (guardare la event history del workflow nella UI di Temporal).
- Alert di reperibilità: Scesi del 70%. La maggior parte dei vecchi alert erano per consumer bloccati o backlog nelle code.
La event history nella UI di Temporal è genuinamente trasformativa per il debugging. Ogni chiamata activity, ogni risultato, ogni retry, ogni segnale — è tutto lì in ordine cronologico. Quando un cliente segnala un problema, il workflow può essere cercato per ID ordine, mostrando esattamente cosa è successo, quando, e perché.
Quando Non Usare Temporal
Temporal non è lo strumento giusto per tutto:
API semplici request-response: Se il tuo endpoint chiama un database e restituisce un risultato, non hai bisogno di un workflow engine. L'overhead di registrare eventi e fare replay della history è sprecato.
Operazioni ad alta frequenza e bassa latenza: Temporal aggiunge ~20-50ms di overhead per workflow task. Per operazioni che necessitano latenza sub-millisecondo, usa chiamate dirette tra servizi.
Event streaming puro: Se hai bisogno di broadcastare eventi a molti consumer (fan-out), Kafka è meglio. Temporal è per orchestrare una sequenza specifica di passi, non per broadcastare.
Workflow con payload enormi: Temporal memorizza input e output delle activity nella sua event history. Se le tue activity passano blob da 10MB, la tua history sarà enorme. Passa riferimenti (chiavi S3, ID database) invece di dati.
Il punto forte è qualsiasi processo multi-passo che attraversa servizi multipli, necessita retry e compensazione, e dove la correttezza conta più del throughput grezzo. Elaborazione ordini, flussi di pagamento, sequenze di onboarding utenti, orchestrazione pipeline dati, workflow di approvazione — qui è dove Temporal brilla.
Per una pipeline tipica di elaborazione ordini, la migrazione richiede circa sei settimane incluso il testing. Tipicamente si spende più tempo a decommissionare la vecchia infrastruttura che a costruire i nuovi workflow. Questo rivela dove vive realmente la complessità.