4dim / Notas
Una cola de trabajo con Postgres y nada más
Sin Redis ni broker: SKIP LOCKED con la reserva en la misma sentencia, la condición de negocio dentro del UPDATE, un contador con bloqueo de fila y interruptores con valor explícito. Cuatro casos reales de un CRM de WhatsApp.
Sin Redis, sin broker, sin cola aparte
Si atiendes clientes por WhatsApp con un equipo pequeño, esta nota cuenta cómo repartimos y despachamos ese trabajo sin montar otro servidor aparte. Al final sabrás qué preguntarle a quien te construya el sistema.
Un CRM de WhatsApp tiene varias tareas incómodas. Sacar mensajes de una cola y mandarlos a Meta. Repartir las conversaciones nuevas entre los vendedores por turnos. Y dejar que varias pestañas y un proceso periódico trabajen sobre lo mismo a la vez.
Es exactamente el problema para el que existen Redis, RabbitMQ y compañía, programas dedicados a hacer cola. Nosotros lo resolvimos con Postgres, la base de datos que ya estaba, y con seis dependencias en todo el repositorio.
No es una postura contra las colas dedicadas. Es que para el volumen de una pyme, y para no tener otro servicio que cuidar en un servidor alquilado, Postgres alcanza. Basta con usar bien tres cosas que ya trae.
Tomar trabajo: SKIP LOCKED y la reserva en la misma sentencia
La consulta que toma mensajes pendientes es un UPDATE, una orden de escritura. Por dentro selecciona con FOR UPDATE SKIP LOCKED y marca la fila como reservada con la hora. Todo en una sola orden.
Dos procesos que llegan a la vez no se estorban. El segundo salta las filas que el primero ya tiene y toma otras. Ninguno toma la misma.
La reserva dura dos minutos, y el número no es casual. Es mayor que los diez segundos de plazo que damos a una petición a Meta. Si la petición vence sin respuesta, el mensaje puede haber salido igual, así que no se marca como enviado ni se libera la reserva.
Se deja reservado y solo se reintenta cuando la reserva caduca sola. Reintentar antes sería la forma más rápida de mandarle dos veces el mismo mensaje a un cliente. Cada pase toma una tanda pequeña, cinco por bandeja, porque el despacho corre también dentro de una petición web y no puede colgarse.
El patrón: la condición va en el UPDATE
Leer, decidir, escribir. Es la secuencia natural y es una carrera. Dos procesos leen «libre», los dos deciden «lo tomo», los dos escriben.
La corrección es siempre la misma y la repetimos en todo el código. La condición de negocio va dentro de la propia orden de escritura, y después se mira cuántas filas cambió. Si no cambió ninguna, otro llegó antes.
| Caso | Condición en la escritura | Qué evita |
|---|---|---|
| Repartir una conversación nueva | WHERE assigned_to IS NULL | Que dos repartos pisen al mismo hilo aunque el razonamiento previo se rompa un día |
| Aceptar una invitación | WHERE estado = 'pendiente' | Dos personas pulsando el mismo enlace a la vez, o una pulsando dos veces |
| Avanzar el estado de entrega | WHERE peldano_actual < peldano_nuevo | Que un «entregado» que llega después de «leído» retroceda el estado (los avisos de Meta no llegan ordenados) |
| Archivar | SET pinned = pinned AND NOT archivado | Un hilo fijado y archivado a la vez, que no aparecería en ninguna lista |
Comprobar antes y escribir después son dos momentos, y entre dos momentos cabe otro proceso. Comprobar y escribir en la misma sentencia es un solo momento.
Un contador sin carrera
El reparto por turnos entre los miembros de un equipo necesita un contador: a quién le toca. Llevarlo en la aplicación, leyendo, sumando uno y escribiendo, es la carrera de siempre.
Lo que pone orden de verdad es un UPDATE equipos SET turno = turno + 1 … RETURNING turno. Postgres bloquea esa fila hasta que la operación termina, así que el segundo proceso espera y se lleva el número siguiente.
Quedan dos detalles alrededor. El orden de los miembros va por identificador, que es estable, y no por nombre. Renombrar a alguien no puede reordenar la vuelta y hacer que otro se salte turnos.
Y la comprobación de «esta conversación ya tiene dueño» va antes de gastar un turno, para no dejar un hueco en la vuelta. La cuenta del turno vive en una función que solo calcula, con su propia prueba. Es la única parte que puede equivocarse en silencio: no daría error, daría una injusticia que se notaría meses después.
Interruptores con valor, no con «invertir»
El agente de IA se enciende y se apaga por canal. Dos personas del equipo, mirando dos conversaciones del mismo número, pueden pulsar el interruptor a la vez.
Si el botón mandara «invierte lo que haya», dos inversiones seguidas dejarían el canal como estaba, y mientras tanto cada pantalla enseñaría una cosa distinta. El botón manda un valor explícito, encendido o apagado, y el último clic gana.
Además, la función devuelve lo que quedó guardado en el servidor, no lo que el navegador suponía. En un interruptor compartido, la respuesta del servidor es la única versión que vale.
Qué hacer entonces
- Medir antes de añadir una cola dedicada. Para miles de mensajes al día, Postgres con
SKIP LOCKEDsobra. - Reservar en la misma sentencia que toma el trabajo, con una reserva más larga que el plazo de red.
- Meter la condición de negocio en el
UPDATEy contar las filas afectadas. Siempre. - Llevar los contadores compartidos con
UPDATE … RETURNING, nunca leyendo, sumando y escribiendo. - Mandar un valor explícito en los interruptores compartidos.
Esta nota sale de nuestro trabajo en Software a medida.