Temporal.io: Reemplazando Colas de Mensajes con Ejecución Durable para Orquestación Compleja
Definición de Ejecución Durable
La idea central detrás de Temporal es simple de enunciar y transformadora en la práctica: el código se persiste. No solo los datos, no solo el estado — el progreso real de ejecución de la función se graba para que si el proceso crashea, pueda resumir exactamente donde quedó.
Aquí va un workflow de Temporal en 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> {
// Paso 1: Reservar inventario
const reservation = await reserveInventory(order.items);
// Paso 2: Cobrar pago
let paymentId: string;
try {
paymentId = await chargePayment(order.paymentMethod, order.total);
} catch (err) {
// Compensación: liberar inventario si falla el pago
await releaseInventory(reservation.id);
throw ApplicationFailure.nonRetryable(
`Pago falló: ${err.message}`
);
}
// Paso 3: Enviar la orden
let shipmentId: string;
try {
shipmentId = await shipOrder(reservation.id, order.shippingAddress);
} catch (err) {
// Compensación: reembolsar pago y liberar inventario
await refundPayment(paymentId);
await releaseInventory(reservation.id);
throw ApplicationFailure.nonRetryable(
`Envío falló: ${err.message}`
);
}
// Paso 4: Enviar confirmación
await sendConfirmation(order.email, {
orderId: order.id,
paymentId,
shipmentId,
items: order.items,
});
return { orderId: order.id, paymentId, shipmentId, status: 'completed' };
}
Observar este código. Es simplemente una función async regular con bloques try-catch. Sin máquinas de estado. Sin serialización de estados intermedios a una base de datos. Sin lógica de reintentos wrapeada alrededor de cada llamada externa. Sin manejo de dead-letter queues.
Pero aquí va lo que Temporal hace detrás de escena:
- Cuando se llama
reserveInventory, Temporal graba un evento "ScheduleActivityTask" en el historial de eventos del workflow - Cuando la activity completa, se graba un evento "ActivityTaskCompleted" con el resultado
- Si el proceso del worker crashea entre los pase es 2 y 3 (pago), un nuevo worker levanta el workflow
- Temporal hace replay de la función del workflow desde el principio, pero en vez de re-ejecutar
reserveInventory, lee el resultado grabado del historial de eventos - El replay llega al punto de falla y continúa ejecutando los pase es restantes
Esto es lo que significa "ejecución durable". La función del workflow parece correr continuamente, pero en realidad es una serie de pase es grabados que pueden ser replayeados en cualquier worker. El await en cada activity es un punto de persistencia.
La Restricción de Determinismo
Hay una restricción fundamental que hace que esto funcione: las funciones de workflow deben ser determinísticas. Dados los mismos inputs y los mismos resultados de activities, el workflow debe tomar la misma secuencia de decisiones.
Esto significa que no es posible hacer lo siguiente dentro de una función de workflow:
// TODO ESTO VA A ROMPER EL REPLAY
// 1. Tiempo no-determinístico
const now = Date.now(); // ¡Diferente en replay!
// Utilizar en cambio: workflow.now()
// 2. Números random
const id = Math.random().toString(36); // ¡Diferente en replay!
// Utilizar en cambio: workflow.uuid4()
// 3. I/O directo
const data = await fetch('https://api.example.com/data'); // ¡Efecto secundario!
// Utilizar en cambio: una activity
// 4. Iteración no-determinística
const keys = Object.keys(someMap); // ¡Orden no garantizado!
// Utilizar en cambio: keys ordenadas o arrays
Esta restricción inicialmente se siente limitante. Con el uso extendido, se revela como un feature. Fuerza una separación limpia entre decisiones (workflow) y efectos (activities). El workflow es lógica pura. Las activities son donde pasa toda la interacción sucia con el mundo real.
Las activities son funciones regulares que pueden hacer cualquier cosa — llamadas HTTP, queries a bases de datos, I/O de archivos, llamadas a APIs de terceros. Corren en workers, y Temporal maneja reintentos, timeouts y heartbeating:
// activities.ts — estas corren en workers, NO en el sandbox del workflow
import { Context } from '@temporalio/activity';
export async function chargePayment(
method: PaymentMethod,
amount: number
): Promise<string> {
// Heartbeat para operaciones largas — le avisa a Temporal que estamos vivos
Context.current().heartbeat('cobrando');
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 envío');
const shipment = await shippingProvider.createShipment({
reservationId,
address,
service: 'standard',
});
return shipment.trackingNumber;
}
Patrón Saga: Compensación Hecha Bien
El workflow de procesamiento de órdenes de arriba implementa el patrón saga — una secuencia de transacciones con acciones compensatorias si algún paso falla. En un sistema típico basado en RabbitMQ, el coordinador de sagas es su propio servicio con su propia base de datos, trackeando el estado de cada instancia de saga.
Con Temporal, el patrón saga son simplemente bloques try-catch. Pero para workflows más complejos con muchos pasos, un helper que acumula compensaciones es útil:
class SagaCompensation {
private compensations: Array<() => Promise<void>> = [];
addCompensation(fn: () => Promise<void>): void {
this.compensations.unshift(fn); // LIFO — compensar en orden inverso
}
async compensate(): Promise<void> {
for (const compensation of this.compensations) {
try {
await compensation();
} catch (err) {
// Loguear pero continuar — compensación parcial es mejor que nada
console.error('Compensación falló:', 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;
}
}
Este es el mismo patrón que un coordinador de sagas de 3.000 líneas implementa, en unas 50 líneas. Y es confiable — si un worker crashea durante la compensación, Temporal hace replay del workflow y la compensación continúa desde donde quedó.
Señales, Queries, y Human-in-the-Loop
Los workflows de Temporal pueden recibir señales externas (eventos que modifican el estado del workflow) y responder a queries (inspección read-only del estado del workflow). Esto es increíblemente poderoso para workflows que necesitan aprobación humana o input externo:
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);
// Esperar aprobación o rechazo, con timeout
const approved = await condition(
() => approval !== null || rejection !== null,
'24h' // Timeout después de 24 horas
);
if (!approved || rejection) {
status = 'rejected';
await notifyRejection(order.email, rejection?.reason ?? 'Timeout de aprobación');
return { orderId: order.id, status: 'rejected' };
}
status = 'processing';
// ... resto del workflow de la orden
}
Desde afuera, le se envía una señal al workflow:
const handle = client.workflow.getHandle('order-12345');
await handle.signal(approveSignal, {
approvedBy: 'manager@company.com',
notes: 'Aprobado para cliente VIP',
});
El workflow puede sentarse esperando aprobación por horas, días o semanas. El worker no mantiene una conexión abierta. No pollea. El estado del workflow está persistido en la base de datos de Temporal. Cuando la señal llega, Temporal agenda el workflow para ejecución, un worker lo levanta, hace replay del historial, procesa la señal, y continúa.
Arquitectura de Producción
Un setup de producción corre Temporal Server en Kubernetes con un 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
Los workers corren como deployments separados:
// worker.ts
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);
});
Componentes Reemplazados y Resultados Medidos
Antes de Temporal:
- 7 colas de RabbitMQ, 4 dead-letter queues
- 12 servicios consumidores (Node.js)
- 1 servicio coordinador de sagas (3.000 líneas)
- 1 base de datos PostgreSQL para estado de sagas
- Lógica de reintentos custom en cada consumidor
- Scripts de compensación manuales para sagas trabadas
Después de Temporal:
- 1 cluster de Temporal (3 nodos)
- 3 deployments de workers (orden, pago, envío)
- ~800 líneas de código de workflow
- ~1.200 líneas de código de activities
- Cero lógica de reintentos custom
- Cero scripts de compensación manuales
El impacto operacional:
- Sagas trabadas/huérfanas: Pasaron de ~15/semana a cero. Temporal no pierde el rastro de los workflows.
- Incidentes de doble cobro: Pasaron de ~3/mes a cero. El patrón saga con ejecución durable realmente garantiza que la compensación se ejecute.
- Tiempo medio para diagnosticar problemas: Bajó de 2-3 horas (correlacionar logs entre 12 servicios) a 5-10 minutos (mirar el historial de eventos del workflow en la UI de Temporal).
- Alertas de guardia: Bajaron un 70%. La mayoría de las alertas viejas eran por consumidores trabados o backlogs en las colas.
El historial de eventos en la UI de Temporal es genuinamente transformador para debugging. Cada llamada a activity, cada resultado, cada reintento, cada señal — está todo ahí en orden cronológico. Cuando un cliente reporta un problema, el workflow se puede buscar por ID de orden, mostrando exactamente qué pasó, cuándo, y por qué.
Cuándo No Usar Temporal
Temporal no es la herramienta correcta para todo:
APIs simples de request-response: Si tu endpoint llama a una base de datos y devuelve un resultado, no se necesita un workflow engine. El overhead de grabar eventos y hacer replay del historial es desperdiciado.
Operaciones de alta frecuencia y baja latencia: Temporal agrega ~20-50ms de overhead por workflow task. Para operaciones que necesitan latencia sub-milisegundo, utilizar llamadas directas entre servicios.
Event streaming puro: Si se necesita broadcastear eventos a muchos consumidores (fan-out), Kafka es mejor. Temporal es para orquestar una secuencia específica de pasos, no para broadcastear.
Workflows con payloads enormes: Temporal almacena los inputs y outputs de activities en su historial de eventos. Si tus activities pasan blobs de 10MB, tu historial va a ser enorme. Pasar referencias (keys de S3, IDs de base de datos) en vez de datos.
El punto óptimo es cualquier proceso multi-paso que abarca múltiples servicios, necesita reintentos y compensación, y donde la correctitud importa más que el throughput crudo. Procesamiento de órdenes, flujos de pago, secuencias de onboarding de usuarios, orquestación de pipelines de datos, workflows de aprobación — ahí es donde Temporal brilla.
Para un pipeline típico de procesamiento de órdenes, la migración toma unas seis semanas incluyendo testing. Típicamente se pasa más tiempo descomisionando la infraestructura vieja que construyendo los nuevos workflows. Eso revela dónde realmente vive la complejidad.