Bot guarda en memoria todo lo que tiene en curso. Si el proceso
muere mientras atiende un mensaje, ese mensaje se pierde (salvo que el canal lo
vuelva a entregar), y una respuesta que se estaba enviando puede haber salido o no.
Para muchos bots eso basta. Cuando no basta, activa la durabilidad:
new RedisDurability({ client, prefix })
de @botmesh/redis-store (ver Backends).
Con durability, el bot lleva dos journals:
- El inbox. Cada mensaje o interacción admitido se anota antes de que corra ningún handler, y se marca como hecho solo cuando sus efectos se asentaron. Tras una caída, el siguiente arranque reprocesa lo que quedó a medias.
- El outbox. Cada envío se anota con una clave de idempotencia antes de llamar al provider, y justo después se guarda el id de mensaje que devolvió el provider. Un envío cuya clave ya está guardada se responde desde el outbox en vez de salir otra vez. Así un reproceso no manda dos veces la misma respuesta. Los envíos que fallan se reintentan con backoff y van a la cola de muertos (DLQ) cuando se les acaban los reintentos.
durability no cambia nada: el bot ejecuta exactamente el mismo
código que antes, en memoria y en un solo proceso.
Backends
SQLite guarda los journals en la tabla
durable del archivo del store, creada
con el resto del esquema. PostgreSQL crea una tabla, {tablePrefix}durable, de forma idempotente al
arrancar. Redis usa claves bajo {prefix}:{durable:inbox:<scope>} y
{prefix}:{durable:outbox:<scope>}. Ninguno toca las tablas ni las claves propias del store.
Con connectionString, PostgreSQL crea (y al parar cierra) su propio pool, y una conexión
que el servidor corta mientras está ociosa (reinicio, failover) se informa a logger.error
y se sustituye en la siguiente consulta. Un pool inyectado es tuyo: dale un listener
pool.on('error', …), porque node-postgres tumba el proceso si nadie escucha.
FileDurability.open(ruta, { appendLog: true }) (o { compactEvery, compactBytes, fsync })
añade cada escritura confirmada a <ruta>.log en vez de reescribir el snapshot, y compacta
cada 1000 entradas o 4 MiB; close() pliega el log en el snapshot. Un log sobrante se
reproduce siempre al abrir, incluso sin la opción. Un journal que no se puede volver a
leer (un snapshot truncado, una entrada del log dañada) se rechaza al abrir con un error
que nombra el archivo, en vez de aplicarse a medias: restáuralo de una copia o apártalo.
Una última línea del log a medias (una caída a mitad de escritura) se recorta y el resto
se reproduce; una línea mala en medio, seguida de más entradas, es corrupción: open()
la rechaza con code: 'APPEND_LOG_CORRUPT' (archivo, línea y byte) y deja el archivo como
está, porque cortar ahí perdería en silencio todas las entradas posteriores. fsync: true
fuerza cada snapshot a disco antes de reemplazar al anterior (y es el valor por defecto de
appendLog.fsync); sin él el rename es atómico, pero puede seguir en la caché del sistema
si la máquina se queda sin corriente. FileEventJournal y SharedFileEventJournal de
@botmesh/events aceptan la misma opción fsync y rechazan igual un log dañado.
Varios bots pueden compartir un backend. Cada bot trabaja en su propia vista del
backend, acotada por su namespace (tenantId, environmentId, botId,
connectionId; bot.durability.scope es el id derivado). Un bot solo toma, reprocesa
y deduplica sus propios registros, y las claves de idempotencia explícitas son por
bot: dos bots que encolan recordatorio:1 envían cada uno el suyo. Un backend propio
sin scope() no debe compartirse entre bots.
Todos los backends pasan la misma suite de conformidad
(test-utils/durability-conformance.ts), y puedes escribir el tuyo implementando la
pequeña interfaz DurableKv (compare-and-set con versión más dos índices
ordenados) y envolviéndola en KvDurability (las dos en @botmesh/core/spi).
El bot arranca y detiene el backend por su cuenta. Usa también un store
persistente: los checkpoints de un flow .persistent() viven en el store y el
reproceso depende de ellos.
Un journal de @botmesh/events
Una app que ya lleva un journal de @botmesh/events puede usarlo como backend, sin
otro store:
-
Los registros de la inbox son
EventRecords de ese journal, en el canal'botwa:durable', coneventType'botwa.inbound.message'o'botwa.inbound.interaction'. Ellist()y elreplay()del journal los ven, y el barrido del bot recoge unreplay. UnEventWorkernunca los toma: unclaimsin canal solo toma registros sin canal. - El outbox es el del core, guardado como sidecar del journal: el mismo archivo y el mismo snapshot atómico que la inbox.
-
Un
FileEventJournaldebe abrirse con{ lock: true }. El archivo de lock con el pid impide que un segundo proceso lo abra, y un segundoopen()del mismo archivo en el mismo proceso se rechaza hasta que se cierra el primero. -
Coste: un
FileEventJournalreescribe el archivo entero en cada escritura, y aquí ese archivo guarda los eventos de la app, los registros de entrada del bot (durante la retención, 7 días por defecto) y el outbox. Cada mensaje entrante y cada envío cuestan unas pocas reescrituras (las escrituras concurrentes comparten una, y los heartbeats de lease solo se escriben con el siguiente cambio). Para algo más que poco tráfico, da al bot un archivo de journal propio, acorta la retención o pasa unoutboxrespaldado por una base de datos; o usaFileDurabilityo un backend de base de datos. -
Para varias réplicas hace falta un
LeasedEventJournalcompartido, con operaciones atómicas y con fencing.SharedFileEventJournales uno: varios procesos abren el mismo archivo y cada escritura corre bajo un mutex entre procesos (<archivo>.mutex) sobre el archivo actual, así que los claims son exclusivos y los fencing tokens valen entre procesos. El outbox vive en el mismo archivo y también se comparte:Está pensado para pocos procesos en un mismo host (o un sistema de archivos con rename atómico y creación exclusiva; no NFS). Por defecto cada escritura relee y reescribe el archivo entero; conappendLog: true(o{ compactEvery, compactBytes, fsync }) cada escritura añade una línea a<archivo>.logcon los registros/estado lateral que cambiaron, y el snapshot se reescribe (conlogSeq) y el log se trunca cada 1000 entradas o 4 MiB. Un fallo entre el snapshot y el truncado es inofensivo (el replay salta las entradas ya incluidas) y una última línea a medias se recorta. Un mutex que dejó un proceso muerto se rompe al momento; un titular vivo refresca su mtime (heartbeat,heartbeatMspor defecto un tercio destaleLockMs), así que solo se da por abandonado un titular atascado trasstaleLockMs(30 s por defecto). Nunca se mata nada (déjalo muy por encima de la escritura más lenta: si tres procesos compiten por romper un mutex ya atascado, dos pueden tenerlo un instante y perderse una escritura; usa un journal de base de datos si eso importa).close()deja terminar las operaciones ya llamadas y rechaza las posteriores. El formato del archivo es el deFileEventJournal, pero no abras un archivo con los dos a la vez (el journal compartido rechaza un archivo que tiene unFileEventJournalvivo conlock, también si ese journal lo abre después: cada operación compartida se rechaza en lugar de perder registros). La regla del mismo chat de Varias réplicas sigue valiendo. Para mucho tráfico, usa otroLeasedEventJournalcon unoutboxcompartido (eventsDurability({ journal, outbox: kv }), con elDurableKvde un backend de Postgres o Redis), o esos backends directamente.
Configuración
BotConfig.durability admite:
Las duraciones admiten un string (
'15s') o milisegundos, como todas las
opciones de duración del bot.
Qué se garantiza
Los envíos son exactamente una vez mientras el proceso vive. Tras una caída también lo son, salvo en una ventana que ningún canal permite cerrar: el proceso muere después de que el provider aceptó un mensaje y antes de que el outbox lo anotara.
Los handlers son al menos una vez: un mensaje reprocesado vuelve a ejecutar su
handler y solo se deduplican sus envíos. Haz idempotentes los demás efectos (un
upsert, un
ctx.user.set, una escritura en base de datos con clave ctx.ref.id) o
dales su propia clave.
Claves de idempotencia
Cada envío lleva una clave:options.idempotencyKeycuando la pasas:bot.send(chatId, texto, { idempotencyKey: 'factura:42' }).- Si no, mientras se atiende un mensaje entrante:
<id del registro entrante>:<n>, el n-ésimo envío hecho para ese mensaje. Un paso de conversación usa el mensaje que lo alimentó, así que las respuestas de un flow conservan sus claves cuando el flow se reanuda. - Si no, una clave aleatoria. El envío igual se reintenta y sobrevive a una caída, pero una segunda llamada no se reconoce como el mismo envío.
bot.broadcast con concurrency > 1, un
Promise.all de respuestas) pasa claves explícitas.
El outbox guarda además una huella de cada envío (chat + contenido, antes de los
hooks before('send')). Cuando un reproceso produce otro contenido bajo una clave
de la regla 2, el handler tomó otro camino (un flow no persistente perdido en la
caída, una condición que cambió). outbox.onDiverged decide: 'send' (por defecto)
envía el contenido nuevo con una clave nueva, para que ninguna respuesta se trague en
silencio; 'skip' lo responde desde el envío anotado, como antes. En ambos casos lo
cuenta metrics().outboxDiverged y se registra un aviso. Elige 'skip' cuando las
respuestas no son deterministas (texto generado por IA) y una segunda respuesta
distinta sería peor que ninguna. Las claves explícitas nunca se comparan: la clave que
pasas nombra el envío.
El provider nunca ve idempotencyKey: el core la quita antes de llamar al provider.
Reintentos, envíos diferidos y la cola de muertos
Un envío que falla se reintenta con backoff (unRateLimitedError espera al menos
su retryAfterMs), salvo que el error sea definitivo. Estos errores son definitivos
y van directo a la DLQ: UnsupportedContentError, PayloadTooLargeError,
OutsideSessionWindowError, RecipientUnreachableError o cualquier error con
retryable: false.
ctx.reply y bot.send siguen reintentando en línea hasta outbox.inlineMaxMs
(por defecto 15s). Pasado ese tiempo rechazan con SendDeferredError, que lleva
la key del outbox. El envío no se pierde: el barrido en segundo plano lo sigue
reintentando. Si quien llama no necesita esperar, encola el envío:
bot.enqueue ejecuta los hooks before('send') una vez, anota el envío y vuelve
enseguida. El barrido lo entrega y después ejecuta after('send') y escribe el
registro de mensajes. Dentro de un proceso, los envíos a un chat conservan su orden
mientras uno de ellos se reintenta en línea.
Un texto que supera el límite del canal se divide después de los hooks, en un registro
por parte (key, key#2, …, entregados en ese orden). Una parte nunca adelanta a una
anterior: mientras key espera un reintento, key#2 espera detrás (y muere con ella si
key va a la DLQ). Esperar detrás de una parte anterior no cuenta como intento, así que
cada parte conserva todo su presupuesto de outbox.retries para sus propios fallos. El
vínculo entre partes se guarda en el registro de cada parte (OutboxRecord.previousPart),
no se deduce de la clave: tus propias claves de idempotencia pueden acabar en #2, #3…
y siguen siendo envíos independientes. Un registro anotado cuyo texto supera el límite (escrito por una
versión anterior, o con un límite mayor) lo divide el barrido con el mismo divisor: la
primera parte sale con la clave del registro y las demás se anotan como key#2,
key#3… antes de enviarla, así que un reintento o una segunda pasada no anotan ni envían
nada dos veces. Las claves de las partes solo dependen de la clave del registro, del límite del canal y del
texto, así que son las mismas en cada replay.
Los mensajes entrantes cuyo procesamiento falla antes de los handlers (por
ejemplo, el store está caído) se reintentan con inbox.backoff y van a la DLQ tras
inbox.maxAttempts. Un handler que lanza un error no se reintenta: sus efectos
ya ocurrieron. El error llega a bot.on('error') y el mensaje se confirma.
Operación
- Leases y fencing. Un registro en proceso se presta a
instanceIdduranteinbox.leaseMs(60 s por defecto), y un envío en curso duranteoutbox.leaseMs(60 s por defecto; como mínimo 20 ms, o el bot rechaza la configuración). Un latido extiende ambos mientras corren el handler o la llamada al provider, así que una subida lenta nunca se toma por un envío abandonado. Si un préstamo vence (el proceso murió o se congeló), otra réplica, o esta misma tras reiniciar, toma el registro con un nuevo token de fencing. Entonces se rechaza la confirmación del dueño anterior y se cuenta enstaleLeases. instanceId. Mantenlo estable entre reinicios de un despliegue, para que un proceso reiniciado recupere enseguida sus registros sin terminar en vez de esperar a que venzan sus préstamos. Su primer barrido tras el reinicio solo recupera los préstamos tomados antes de que se recuperara (los de la encarnación muerta, incluida una escritura que llegó tarde), nunca un envío que está haciendo él mismo. Hazlo único por réplica que corra a la vez (el nombre del pod de un StatefulSet, por ejemplo): dos procesos vivos con el mismo id se quitarían los préstamos. En un backend de un solo proceso (memoria, archivo) el valor por defecto es<hostname>/<botId>/<connectionId>. En un backend compartido (Postgres, Redis) se le añade un sufijo por arranque, para que dos procesos del mismo host nunca lo compartan; entonces un reinicio espera a que venzan los préstamos anteriores. Fíjalo explícitamente para evitar esa espera.- Retención. Los registros
doneysentse purgan trasretention(por defecto7d). Mantenla más larga que la ventana de reentrega más larga de tus canales, porque una reentrega solo se reconoce mientras su registro existe. - Cierre ante fallos. Si el journal no puede anotar un mensaje entrante, el
mensaje no se despacha. Se emite un
'error'y el dedupe en memoria olvida el mensaje, así que una reentrega se procesa. ConfailOpen: truese despacha sin journal. - Tamaño del contenido. El contenido saliente se anota como JSON, con los
Buffers en base64. Un contenido de más de
outbox.maxPayloadBytes(1 MiB por defecto), o uno que no se puede codificar (un stream), se salta el outbox con un aviso. Para archivos grandes, usa media por URL o por ruta. - Media en un reproceso. Ver Media en un reproceso.
- Ediciones, borradores e indicadores de escritura no se anotan. Las ediciones son idempotentes, y los borradores y los indicadores son pasajeros.
Media en un reproceso
El journal no guarda la media de un mensaje, solo una referencia a ella. Los bytes se descargan bajo demanda conloadMediaOf(ctx). En algunos canales esa referencia es un
objeto vivo: Baileys guarda el mensaje original de WhatsApp. Por eso la inbox le pide al
provider una referencia serializable y versionada (IProvider.serializeMediaRef) y
anota esa. Un mensaje puede reprocesarse tras una caída, como reintento en segundo
plano o por un replayInbound de un operador. En todos los casos el provider
reconstruye la referencia (IProvider.rehydrateMediaRef) antes de que corra el handler,
y la media se descarga igual que con un mensaje en vivo.
No se anota ninguna credencial: los tokens y las claves se añaden de nuevo al
descargar. La referencia guardada se valida al leerla. Un mensaje reprocesado cuya
media no se puede reconstruir se procesa igual; solo falla su descarga.
loadMediaOf(ctx, { throwOnError: true }) rechaza entonces con
MediaUnavailableError (un ProviderError), y su reason es uno de estos:
'not-durable': el provider no serializó la referencia (por ejemplo, visualización única).'provider-mismatch': otro provider escribió el registro.'rehydrate-failed': el valor guardado no es válido, es de una versión desconocida o fue manipulado.
throwOnError, loadMediaOf resuelve undefined, igual que con cualquier descarga
fallida.
durability.inbox.mediaRefs: false los tokens de media se anotan como antes; un
mensaje reprocesado puede entonces no poder descargar su media. Los registros escritos
antes de esta función se reprocesan como antes.
Un provider añade los dos métodos opcionales de IProvider.
serializeMediaRef(ref) es síncrono y devuelve { v, data } (JSON) o undefined.
rehydrateMediaRef(stored, ref) valida stored, lanza un ProviderError si no es
válido y no necesita el transporte conectado.
Los trece canales los implementan: Baileys (una clave de mensaje), Meta Cloud, Telegram
(file_id), Twilio, Messenger e Instagram (URL de adjunto alojada), Discord, Evolution
(la clave del mensaje; el base64 en línea nunca se guarda), WPPConnect (messageId),
Gupshup y GoHighLevel (URL http(s) alojada), email (Message-ID + índice del adjunto +
digest, más el UID y UIDVALIDITY de IMAP cuando se conocen) y TikTok (coordenadas de la
media, o la URL del sticker). Una URL alojada puede caducar en el servidor antes de un
replay: el provider rechaza entonces con su error de descarga habitual y el core reporta
MediaUnavailableError. Nunca escribas credenciales en data.
Varias réplicas
Los journals se pueden compartir sin riesgo: dos réplicas nunca toman el mismo registro y un dueño caducado no puede completarlo. El resto del bot sigue siendo por proceso: las conversaciones activas, suask pendiente y la cola por chat viven en la
memoria de la réplica que las ejecuta. Por eso:
- Envía cada chat a una sola réplica a la vez (enrutado fijo por id de chat en el
balanceador, o reparte los chats entre réplicas). Si dos réplicas reciben mensajes
del mismo chat, la respuesta a un
askque una réplica tiene pendiente llega a la otra como un mensaje nuevo. - La conmutación va por préstamos. Cuando una réplica muere o se congela, otra toma
sus registros sin terminar en cuanto vencen sus préstamos (
inbox.leaseMs) y los reprocesa. Una réplica viva conserva sus préstamos gracias al latido, tarde lo que tarde su handler. - Da a cada réplica su propio
instanceIdexplícito. - Usa un backend compartido:
RedisDurabilityoPostgresDurability.SQLiteDurabilityySharedFileEventJournalsolo se comparten entre procesos de un mismo host.
coordination (ver Varias réplicas) no hace falta el enrutado
fijo: los turnos por chat serializan cada chat entre réplicas y los flujos
.persistent() siguen en la réplica que reciba el siguiente mensaje. Los bots con el
mismo botId comparten un ámbito de journal, así que cualquier réplica puede
reprocesar o entregar un registro. Con una sesión de transporte solo en la líder
(providerSession: 'leader', p. ej. Baileys), el barrido del outbox de una réplica en
espera nunca llama al transporte (el intento se rechaza con ProviderNotLeaderError y
se reintenta); lo entrega la líder.
Con coordinación, un registro que alimentó un paso de conversación se confirma solo
cuando aterriza la escritura del checkpoint que guarda su efecto (la marca de espera, o
la respuesta de un paso cortado); si un paso suspendido no pudo escribirla, libera el
registro y este se reprocesa. Una respuesta guardada apunta a su registro de entrada,
así que mantén retention por encima de tu pausa de conversación más larga. Ver
Relevo de conversaciones.
Probar la recuperación ante caídas
@botmesh/core/testing exporta crashable(backend), que puede “matar” un bot en una
escritura concreta del journal, y CrashableProvider. Abandona el bot caído y
arranca uno nuevo sobre el mismo backend y el mismo store, con el mismo
instanceId:
test-utils/durability-crash.ts) contra
cada backend. Cubre cada punto de caída con las dos políticas de ambigüedad, más
puntos aleatorios con semilla y un proceso hijo real al que se mata a mitad de un
handler.