Día 05 de la serie técnica WKafka Open Source.
Construir sistemas distribuidos confiables exige pasar de bloqueos HTTP síncronos a workers de eventos asíncronos. WKafka lo hace directo.
Los Problemas Reales
- Errores HTTP 504 Gateway Timeout en tareas de procesamiento pesado
- Acoplamiento rígido entre APIs frontales y workers de procesamiento
- Pérdida de notificaciones cuando un worker se cae a mitad del cálculo
La Implementación
from wkafka import WKafka
kafka = WKafka(servers=["localhost:9092"], client_id="task_worker_node")
# Worker Node consumiendo tareas y respondiendo con resultados
@kafka.consumer(topic="tasks", format="json")
def on_task(msg):
result = compute_heavy_work(msg.value)
kafka.produce(topic="results", key=msg.key, value=result)
Por qué esta arquitectura gana
- Flujo Desacoplado: El cliente produce la tarea; el worker consume, calcula y responde.
- Correlation IDs: Empareja peticiones y respuestas mediante claves de mensaje.
- Escalado Infinito: Añade pods de workers bajo demanda sin cambiar el cliente.
Verificación y Estado
Probado y verificado contra clusters reales de Apache Kafka (ver EXAMPLES_STATUS.md en el repositorio). Compatible con Python 3.9 a 3.14 con tipado estricto mypy.
Top comments (5)
Yo cerraría el ejemplo con el contrato de reintento. Aquí
msg.keyenlaza la tarea con la respuesta, pero no evita que una reentrega publique dos resultados ni define qué pasa cuando el worker cae después de calcular y antes de producir. Añadiría unrequest_idestable y un estado terminal en el consumidor, con el offset confirmado solo después de persistir el resultado, más una ruta explícita para fallas. ¿WKafka define cómo se maneja una respuesta tardía o un worker que reinicia en esa ventana? Ese es el punto donde el request-response deja de ser solo desacoplar HTTP.El análisis va directamente al núcleo de los desafíos de consistencia en sistemas distribuidos basados en eventos. En arquitecturas asíncronas con Kafka, desacoplar el ciclo HTTP es solo la fase inicial; garantizar idempotencia de extremo a extremo y resiliencia ante caídas del consumidor exige un contrato estricto en tres capas:
Identificador de correlación unívoco (
request_id) en Headers:El
msg.keyse reserva para determinar la partición física de destino (afinidad de particionado), mientras que elrequest_id(UUIDv7 determinista) viaja en los metadatos/headers del registro Kafka. Esto desacopla el balanceo de carga de la trazabilidad y la deduplicación del mensaje.Deduplicación atómica y manejo de caídas post-cálculo:
Para resolver el escenario crítico donde el worker procesa la tarea pero cae antes de producir el resultado en el tópico de respuesta, el consumidor implementa un bloqueo transaccional/idempotencia con estado:
Antes de procesar, se valida el
request_idcontra una tabla de deduplicación distribuida (en Redis mediante SETNX con TTL o SQLite transaccional).La confirmación del offset (
manual_commitenwkafka) se acopla estrictamente a la confirmación de entrega (ack) del productor hacia el tópico de respuesta.Si el worker cae tras procesar pero antes de publicar, el reintento del siguiente worker detecta la existencia previa del cálculo completado y reemite el resultado sin reejecutar la lógica de negocio pesada.
Manejo de respuestas tardías y estado terminal:
Si el consumidor solicitante ya ha expirado por timeout local, la respuesta que llegue posteriormente no se descarta silenciosamente: pasa a un Dead Letter Queue (DLQ /
invoices.DLQ) con flag de correlación expirada, asegurando auditoría completa y evitando encolamiento de buffers en memoria.El SETNX antes de procesar marca la tarea como tomada, no como terminada, y ahí queda abierto justo el caso de la caída. Si el worker cae después del SETNX y antes de publicar, el reintento encuentra la clave pero no tiene ningún resultado que reemitir. Le quedan dos salidas: descartar la tarea o volver a calcular sin saber si el primer intento produjo algo.
Yo separaría dos estados en la tabla de deduplicación: "en_proceso" con un lease corto, y "completado" con el resultado guardado o una referencia a él. El reintento solo reemite desde "completado"; si encuentra "en_proceso" con el lease vencido, recalcula. El orden también importa: guardar el resultado, publicar la respuesta y después confirmar el offset.
Una prueba que lo cubre: matar el worker entre el cálculo y el produce, y verificar que el cliente recibe la respuesta y que, si llega duplicada, trae el mismo resultado para ese request_id.
Tu observación da en el punto crítico del ciclo de vida distribuido: un bloqueo de concurrencia (
SETNX) sin máquina de estados finita confunde la adquisición del lease con la consumación del procesamiento.La arquitectura canónica para resolver esta brecha sin caer en duplicaciones destructivas ni bloqueos huérfanos se estructura formalmente como un patrón Two-Phase State Machine con Lease Renovable:
Estado
IN_PROGRESScon TTL estricto (Lease temporal):Al tomar la tarea con el
request_id, se persiste el registro en Redis o SQLite con estado{"status": "IN_PROGRESS", "worker_id": "...", "expires_at": t + lease_ttl}. Si el cálculo es prolongado, un hilo heartbeat extiende el lease mientras el proceso siga vivo.Transición atómica a
COMPLETEDantes del ack de Kafka:El pipeline de persistencia sigue una secuencia transaccional estricta:
{"status": "COMPLETED", "payload": result}.response_topic) usando elrequest_iden los headers.manual_commitenwkafka).Resolución determinista ante caídas:
IN_PROGRESSvencido (expires_at < now()) y recalcula limpiamente, puesto que no hubo mutación de estado definitiva.COMPLETED. En lugar de invocar la computación pesada, lee directamente el payload precalculado y procede a emitir en el tópico de respuesta y confirmar el offset.La prueba de caos que mencionas (inyección de
SIGKILLinmediatamente después de guardar el resultado y antes delproduce) forma parte de nuestra suite de verificación de resiliencia enwkafkaprecisamente para validar que el cliente downstream reciba una respuesta exacta e idempotente bajo cualquier escenario de fallo transitorio.Ese orden cierra el caso que te planteaba. Queda un borde que aparece justo por el heartbeat: un worker vivo pero detenido (una pausa larga de GC, o sin red hacia Redis) deja de renovar el lease, otro worker lo toma y recalcula, y cuando el primero vuelve, escribe su propio COMPLETED encima.
La solución habitual es que el Paso B sea condicional: escribir COMPLETED solo si el registro todavía tiene tu
worker_id, o mejor un número de lease que aumenta cada vez que alguien toma la tarea. En Redis eso es un script Lua que compara y escribe en una sola operación. El worker que perdió el lease descarta su resultado y no publica.Para la suite de caos agregaría ese caso: pausar el worker con SIGSTOP más allá del TTL, dejar que otro complete, reanudarlo con SIGCONT y verificar que el
request_idtiene un solo resultado y una sola respuesta publicada.