coordination no se ejecuta nada de lo que
sigue y el bot se comporta exactamente como antes. Necesitas
@botmesh/redis-store (un servidor Redis o un Redis Cluster)
y un store que compartan todas las réplicas.
Cuándo la necesitas (y cuándo no)
Configuración
prefix en todas;
el id de réplica es un UUID aleatorio por proceso salvo que pases
replicaId (debe ser único entre las réplicas en marcha; sirve el nombre del
pod o del host). Cada instancia del coordinador añade su propio nonce en Redis
(coordinator.incarnation), así que una réplica reiniciada con el mismo
replicaId nunca hereda los turnos del proceso que murió. Con tu propia
conexión a Redis usa new RedisCoordinator({ url, prefix }) en lugar de
store.coordinator().
Qué obtienes
Una réplica por chat a la vez, en orden. Cuando llega un mensaje, la réplica que lo recibe toma un turno en la cola de ese chat en Redis. Los turnos del chat se conceden en ese orden, sea cual sea la réplica, y solo una réplica tiene el turno de un chat a la vez. Así, dos mensajes de un chat nunca se procesan en paralelo y se atienden en el orden en que tomaron su turno, aunque un balanceador los reparta entre réplicas. La réplica toma el turno cuando el mensaje ya está persistido (y anotado en el journal, condurability), así que dos mensajes de un chat que llegan a réplicas
distintas con pocos milisegundos de diferencia (menos que ese trabajo
previo al turno) pueden atenderse en cualquier orden; los que llegan a la misma
réplica, o más separados, conservan el orden de llegada. Los chats distintos
corren en paralelo. En grupos la clave es por participante, igual que el alcance de
las conversaciones.
El turno se mantiene hasta que el handler termina y la conversación que
recibió el mensaje vuelve a esperar (o termina), también a través de un
c.goto(), así que otra réplica nunca ve un flujo a mitad de un paso.
settleMs limita esa espera: un paso que siga en marcha entonces (un .do()
lento, un c.sleep o un delay largos) se detiene, conservando su checkpoint,
antes de soltar el turno, de modo que nunca corre a la vez que la réplica que
recibe el chat después. Mantén settleMs por encima de tu paso más largo.
Los temporizadores de una conversación (su timeout de espera, c.idle())
también actúan solo con el turno del chat. Si otra réplica atendió el chat
entretanto, la copia obsoleta se descarta en lugar de escribir al usuario o
borrar el checkpoint de la nueva dueña.
Las conversaciones pasan de una réplica a otra a través del store. Si el
siguiente mensaje de un flujo .persistent() llega a otra réplica, esa
réplica lee el checkpoint del store y sigue. Si el flujo esperaba en la
primera pregunta de su paso, el mensaje se toma como esa respuesta (no se
vuelve a preguntar). Si no (un paso .do() que esperaba en una pregunta
posterior, o una réplica que murió antes de preguntar), el paso se repite
desde el principio y hace su primera pregunta. La réplica que tenía el flujo
en memoria descarta su copia la próxima vez que reciba ese chat (o cuando
salte uno de sus temporizadores).
Las escrituras de checkpoint son compare-and-set. Una réplica congelada
después de perder el chat podría sobrescribir el checkpoint de la réplica que
lo tomó. Todos los stores integrados implementan setCheckpointIf /
clearCheckpointIf (una sola sentencia atómica, con FlowCheckpoint.revision
encadenada por clave), y el runtime los usa siempre que hay coordination:
una escritura rechazada termina la conversación como un turno perdido
(LeaseLostError, motivo 'superseded') y un borrado rechazado deja la fila
de la nueva dueña. Un store propio sin esos métodos conserva solo el fence
local, y el bot avisa al arrancar.
Una sola conexión para los transportes exclusivos. Con
providerSession: 'auto' (por defecto), un provider que declara
capabilities.exclusiveSession solo se arranca en la réplica elegida para
provider:<id>; las demás esperan y una de ellas toma el relevo cuando la
líder se detiene o muere.
Si el transporte de la líder sigue
'closed' durante providerDownMs
(sesión cerrada, sesión tomada por otro, reconexión abandonada), la réplica
cede el puesto, espera unos segundos y vuelve a presentarse, para que una
réplica en espera conecte entretanto.
Fuérzalo con providerSession: 'leader' o 'shared'. En una réplica en
espera, bot.ready sigue pendiente (el transporte no está conectado) y
bot.send() rechaza con ProviderNotLeaderError.
Relevo ante caídas. Cada réplica renueva un heartbeat y sus leases cada
leaseMs / 3. Cuando una réplica muere, sus leases caducan tras leaseMs y
las demás siguen: los turnos en cola de la réplica muerta se saltan y una
réplica en espera conecta el transporte. Cuando una réplica se queda
congelada (pausa del GC, event loop bloqueado, red caída) durante casi todo
leaseMs, suelta sus leases antes de que caduquen en Redis: un envío que
intente después rechaza con LeaseLostError en lugar de responder fuera de
orden, y su conversación en memoria de ese chat se descarta (el checkpoint se
queda para la nueva dueña). Una liberación que falla en la red (un corte
breve de Redis) la repara el watchdog en leaseMs / 3, así que el chat no
se queda bloqueado detrás de una réplica viva.
Relevo de conversaciones
Qué pasa con la conversación en curso de un chat cuando otra réplica toma el chat (la réplica que la ejecutaba se cayó, se detuvo por un despliegue, se congeló más allá de su lease o cortó un paso ensettleMs):
flows: 'forward'escribe un checkpoint por paso también para los flujos sin.persistent(), marcado como efímero: caducattldespués de su última escritura (por defectoconversation.timeout, si no 5 minutos; mientras el flujo espera, nunca antes del timeout de esa espera, y se renueva en cada espera) y lleva una huella del flujo, para que una réplica con el flujo cambiado (despliegue gradual) no lo retome en el paso equivocado; ese caso va afallbackconreason: 'changed'.c.statetiene que ser JSON plano: un flujo cuyo estado guarda una instancia de clase, una función o unDateno se reenvía y va afallback. Tras unbot.stop()ordenado el checkpoint se conserva, así que la réplica que toma el relevo (o esta misma, reiniciada dentro del TTL) sigue. Actualiza todas las réplicas antes de activarforward(y desactívalo antes de volver atrás): una réplica que no reenvía igualmente retoma un checkpoint efímero y lo borra, pero una versión anterior ignora su caducidad.fallbackse aplica a lo que no se puede serializar (la pila y los closures de una función asíncrona). La conversación deja un pequeño marcador en el store mientras espera (dos escrituras por conversación: una cuando espera por primera vez y otra para borrarlo). La réplica que toma el chat entonces:'notify': respondenotifyMessage(por defecto, el texto localizadocore.conversation.interrupted) y consume el mensaje;'restart': vuelve a entrar en la conversación por nombre, desde el principio y con su estado inicial (lo que se ejecutó antes de su primera espera se repite);- una función
(info, ctx) => boolean: tu migración; devuelvetruesi consumió el mensaje; 'drop'(por defecto): nada, el mensaje se enruta con normalidad.
.do() tras su c.ask) y el paso se suspende (settleMs, un turno
perdido), la respuesta se guarda en el checkpoint antes de que el paso siga.
La réplica que toma el chat le da esa respuesta en lugar de volver a
preguntar, y guarda el mensaje que la despertó para la siguiente pregunta.
Con durability el checkpoint solo apunta al mensaje del journal (y los
envíos del paso los deduplica el outbox); sin ella, se incrusta el propio
mensaje si cabe en maxAnswerBytes (por defecto 16 KiB). Lo que los hooks
before('message') hicieron con la respuesta (una transcripción de voz en
ctx.body, ctx.type, ctx.lang) viaja con ella; los hooks no se repiten.
Los acuses siguen al checkpoint. Con durability, un registro de entrada
se confirma solo después de que aterrice la escritura del checkpoint que
guarda su efecto (la marca de espera, la respuesta guardada). Si esa
escritura falló, o el paso suspendido había consumido una respuesta que ningún
checkpoint guarda (una pregunta posterior del paso, una respuesta demasiado
grande para incrustarla), libera el registro, que se reprocesa. Las escrituras
de checkpoints tienen fencing como los envíos: una réplica que sabe que perdió
el turno no escribe el checkpoint. Un paso suspendido que sigue ejecutándose
tampoco lo escribe ni lo borra después, aunque la misma réplica haya vuelto a
tomar el chat.
Límites:
- La unidad del relevo es el paso. Un paso que se repite en la réplica nueva
repite sus efectos anteriores a su primera espera: los envíos solo se
deduplican con
durability, y el trabajo de.do()es al-menos-una-vez. - Las respuestas a preguntas posteriores de un paso con varias preguntas (un
formulario en modo texto, un
.do()con variosc.ask) no se reenvían: el paso vuelve a empezar. - Sin
durability, el envío de la pregunta y su marca no son atómicos: una caída entre ambos pregunta una vez más. Las respuestas mayores quemaxAnswerBytesno se incrustan (el paso vuelve a preguntar). - El timeout de una espera vuelve a empezar en la réplica nueva, acotado por
el TTL. Los vigilantes
c.idle()solo se rearman con el código que se repite. - Una conversación suspendida antes de su primera espera no deja marcador (todavía no hay nada que relevar).
- Las conversaciones asíncronas nunca se migran solas.
- Todos los stores integrados escriben los checkpoints con compare-and-set
(ver arriba). Un store propio sin
setCheckpointIf/clearCheckpointIfsolo tiene el fencing local: una comprobación justo antes de cada escritura, como en los envíos.
Requisitos y límites
- Un store compartido:
RedisStore,PostgresStore,MysqlStoreoMongoStore(solo Redis trae coordinador; los demás guardan el estado compartido y los checkpoints con compare-and-set). ConMemoryStoreoSQLiteStorecada réplica tiene sus propios datos. - Los flujos de varios turnos deberían ser
.persistent()(o usa elhandover). Sin eso, unbot.conversationo un flujo no persistente vive en la memoria de la réplica que lo inició; si el siguiente mensaje llega a otra réplica, se enruta como un mensaje nuevo. Con coordinación un flujo persistente se guarda en cuanto se entra en él, así que incluso su primera pregunta sobrevive a un cambio de réplica. - El dedupe de entrada es por proceso. Un reintento de webhook que llegue
a otra réplica se procesa otra vez. Haz los handlers idempotentes, deja el
transporte en una sola réplica (
'leader') o activadurabilitycon un backend compartido: su journal de entrada deduplica entre réplicas. - Un mensaje que espera turno en una réplica que muere se pierde (como en
un único proceso que se cae), salvo con
durabilityy un backend compartido: el mensaje quedó anotado al llegar y otra réplica lo reprocesa cuando vence su préstamo. - Las tareas programadas simples (
bot.schedule/everysinid) corren en todas las réplicas. Conviértelas en tareas durables, que ejecutan cada ocurrencia una sola vez, o toma tú el liderazgo (ver Tareas únicas). - Un paso de conversación más largo que
settleMsse interrumpe (ver arriba): subesettleMspara trabajo largo en.do()o delays. - Redis: un failover que pierda las últimas escrituras puede conceder un
lease dos veces (standalone con Sentinel, o Redis Cluster). Redis Cluster
necesita el layout de claves
'cluster': ver Redis Cluster.
Cómo elegir leaseMs
leaseMs (por defecto 15s) es el tiempo de relevo tras una caída, y
también cuánto puede congelarse una réplica antes de soltar sus chats.
Mantenlo bastante por encima de tu peor pausa del event loop y del tiempo de
ida y vuelta a Redis; entre 5 y 30 segundos sirve para la mayoría de bots.
Mientras espera un turno, la réplica consulta Redis cada 5 a 100 ms
(maxPollMs).
Eventos
Tareas únicas y locks con fencing
Las tareas durables del scheduler (bot.every('5m', { id: 'informe' }, fn) con
BotConfig.scheduler, ver la guía del scheduler)
ya ejecutan cada ocurrencia en una sola réplica a la vez. Con coordination,
el liderazgo del scheduler (scheduler.lease) usa por defecto el mismo
coordinador, así que un solo Redis coordina chats, la sesión del transporte y
el scheduler. Pasa scheduler.lease explícitamente (por ejemplo
store.leases()) para usar otro backend.
Para una tarea normal (no durable), toma el liderazgo tú mismo:
store.acquireLockFenced(key, ttlMs)
devuelve { token, fence }: el fence crece en cada adquisición, así que un
recurso puede rechazar la escritura de un dueño que se quedó congelado más
allá de su lock. Alarga una sección larga con renewLock(key, token, ttlMs)
y libera con releaseLock(key, token). El acquireLock sin fence se
eliminó; ver Migrar a la API estable.
Docker Compose con dos réplicas
Tests
MemoryCoordinator (de @botmesh/core/testing) es la implementación de referencia en un solo proceso.
Los bots que comparten un MemoryCoordinationHub se coordinan igual que
réplicas que comparten Redis, y coordinator.crash() simula la caída de una
réplica:
new MemoryCoordinationHub({ maxIdleKeys }) (por defecto
10.000) se expulsan, primero los menos usados. Los fences siguen siendo monótonos tras una
expulsión, y un chat recreado informa previousHolder: null, así que su siguiente titular
refresca su estado.