Saltar al contenido principal
Backend

Temporal.io: Reemplazando Colas de Mensajes con Ejecución Durable para Orquestación Compleja

10 min lectura
LD
Lucio Durán
Engineering Manager & AI Solutions Architect
También disponible en: English, Italiano

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:

  1. Cuando se llama reserveInventory, Temporal graba un evento "ScheduleActivityTask" en el historial de eventos del workflow
  2. Cuando la activity completa, se graba un evento "ActivityTaskCompleted" con el resultado
  3. Si el proceso del worker crashea entre los pase es 2 y 3 (pago), un nuevo worker levanta el workflow
  4. 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
  5. 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.

temporaldurable-executionworkflow-enginesaga-patternorchestrationdistributed-systemsmessage-queuesbackend-architecture

Herramientas mencionadas en este artículo

AWSProbá AWS
RenderProbá Render
Divulgación: Algunos enlaces en este artículo son enlaces de afiliado. Si te registrás a través de ellos, puedo recibir una comisión sin costo adicional para vos. Solo recomiendo herramientas que uso y en las que confío personalmente.
Compartir
Seguime