new RedisDurability({ client, prefix })
from @botmesh/redis-store (see Backends).
With durability set, the bot keeps two journals:
- The inbox. Every admitted message or interaction is written down before any handler runs, and marked done only once its effects settled. After a crash, the next start replays what was left unfinished.
- The outbox. Every send is written down under an idempotency key before the provider is called, and the provider’s message id is recorded right after. A send whose key is already recorded is answered from the outbox instead of going out again. That is how a replay avoids sending the same reply twice. Failed sends are retried with backoff and dead-lettered when they run out of retries.
durability key nothing changes: the bot runs exactly the code it ran
before, in memory and in a single process.
Backends
SQLite keeps the journals in the
durable table of the store’s file, created
with the rest of the schema. PostgreSQL creates one table, {tablePrefix}durable, idempotently on start.
With connectionString it creates (and later ends) its own pool, and a connection the
server drops while idle (restart, failover) is reported to logger.error and replaced on
the next query. An injected pool is yours: give it a pool.on('error', …) listener,
because node-postgres crashes the process when nobody listens.
Redis uses keys under {prefix}:{durable:inbox:<scope>} and {prefix}:{durable:outbox:<scope>}.
None of them touches the store’s own tables or keys.
FileDurability.open(path, { appendLog: true }) (or { compactEvery, compactBytes, fsync })
appends each committed write to <path>.log instead of rewriting the snapshot, and compacts
every 1000 entries or 4 MiB; close() folds the log into the snapshot. A leftover log is
always replayed on open, even without the option. A journal file that cannot be read
back (a truncated snapshot, a damaged log entry) is refused on open with an error that
names the file, instead of being applied half-way: restore it from a backup or move it
aside. A torn last line of the log (a crash mid-append) is cut and the rest replayed;
a bad line in the middle, followed by more entries, is corruption: open() refuses it
with code: 'APPEND_LOG_CORRUPT' (file, line and byte offset) and leaves the file as it is,
because cutting there would silently drop every later entry. fsync: true flushes each
snapshot to disk before it replaces the previous one (and is the default of
appendLog.fsync); without it the rename is atomic but may still sit in the OS cache when
the machine loses power. @botmesh/events’ FileEventJournal and
SharedFileEventJournal take the same fsync option and refuse a corrupt log the same way.
Several bots can share one backend. Each bot works in its own view of the
backend, scoped by its namespace (tenantId, environmentId, botId,
connectionId; bot.durability.scope is the derived id). A bot claims, replays and
deduplicates only its own records, and explicit idempotency keys are per bot: two bots
enqueueing reminder:1 each send their own. A custom backend that has no scope()
must not be shared between bots. Every backend passes the same
conformance suite (test-utils/durability-conformance.ts), and you can write your
own by implementing the small DurableKv interface (versioned compare-and-set plus
two sorted indexes) and wrapping it in KvDurability (both from @botmesh/core/spi).
The bot starts and stops the backend itself. Use a persistent store too: a
.persistent() flow’s checkpoints live in the store, and replay relies on them.
An @botmesh/events journal
An app that already keeps an @botmesh/events journal can use it as the backend, with
no other store:
-
The inbox records are
EventRecords of that journal, on the channel'botwa:durable', witheventType'botwa.inbound.message'or'botwa.inbound.interaction'. The journal’slist()andreplay()see them, and areplayis picked up by the bot’s sweeper. AnEventWorkernever claims them: a claim without a channel only takes records without one. - The outbox is the core outbox kept as a sidecar of the journal: the same file and the same atomic snapshot as the inbox.
-
A
FileEventJournalmust be opened with{ lock: true }. The pid lock file stops a second process from opening it, and a secondopen()of the same file in the same process is refused until the first journal is closed. -
Cost: a
FileEventJournalrewrites its whole file on every write, and here that file holds the app’s own events, the bot’s inbound records (kept for the retention window, 7 days by default) and the outbox. Each inbound message and each send cause a few rewrites (concurrent writes share one, and lease heartbeats only write with the next change). For more than light traffic, give the bot a journal file of its own, keep the retention short, or pass a database-backedoutbox; or useFileDurabilityor a database backend instead. -
For several replicas you need a shared
LeasedEventJournalwith atomic, fenced operations.SharedFileEventJournalis one: several processes open the same file, and every write runs under a cross-process mutex (<file>.mutex) against the current file, so claims are exclusive and fencing tokens hold across processes. The outbox lives in the same file and is shared too:It is meant for a few processes on one host (or a filesystem with atomic rename and exclusive create; not NFS). By default every write rereads and rewrites the whole file; withappendLog: true(or{ compactEvery, compactBytes, fsync }) each write instead appends one line with the records/side state that changed to<file>.log, and the snapshot is rewritten (carryinglogSeq) and the log truncated every 1000 entries or 4 MiB. A crash between the snapshot and the truncation is harmless (replay skips entries already in the snapshot) and a torn last line is cut off. A mutex left by a dead process is broken at once; a live holder refreshes its mtime (heartbeat,heartbeatMsdefault a third ofstaleLockMs), so only a stalled holder is treated as abandoned afterstaleLockMs(default 30 s). Nothing is ever killed (keepstaleLockMswell above the slowest write: if three processes race to break an already stalled mutex, two of them can briefly hold it and one write may be lost; use a database journal where that matters).close()lets the operations already called finish and rejects later ones. The file format is theFileEventJournalone, but never open a file with both at the same time (the shared journal refuses a file a live lockedFileEventJournalholds, also when that journal opens it later: every shared operation then rejects instead of losing records). The same-chat rule of Several replicas applies. For heavy traffic, use anotherLeasedEventJournalwith a sharedoutbox(eventsDurability({ journal, outbox: kv }), with theDurableKvof a Postgres or Redis backend), or those backends directly.
Configuration
BotConfig.durability takes:
Durations take a string (
'15s') or milliseconds, like every duration option
of the bot.
What is guaranteed
Sends are exactly once while the process is alive. Across a crash they are exactly once, except in one window that no channel lets anyone close: the process dies after the provider accepted a message but before the outbox recorded it.
Handlers are at least once: a replayed message runs its handler again, and only
its sends are deduplicated. Keep other side effects idempotent (an upsert, a
ctx.user.set, a database write keyed by ctx.ref.id), or give them their own key.
Idempotency keys
Every send gets a key:options.idempotencyKeywhen you pass one:bot.send(chatId, text, { idempotencyKey: 'invoice:42' }).- Otherwise, while handling an inbound message:
<inbound record id>:<n>, the n-th send made for that message. A conversation step uses the message that fed it, so the replies of a flow keep their keys when the flow resumes. - Otherwise a random key. The send still gets retries and survives a crash, but a second call is not recognised as the same send.
bot.broadcast with
concurrency > 1, Promise.all of replies) pass explicit keys.
The outbox also stores a fingerprint of each send (chat + content, before the
before('send') hooks). When a replay produces different content under a key of
rule 2, the handler took another path (a non-persistent flow lost on the crash, a
condition that changed). outbox.onDiverged decides: 'send' (default) sends the new
content under a new key, so no reply is silently swallowed; 'skip' answers it from the
recorded send, as before. Either way metrics().outboxDiverged counts it and a warning
is logged. Choose 'skip' when replies are not deterministic (AI-generated text) and a
second, different answer would be worse than none. Explicit keys are never compared:
the key you pass names the send.
The provider never sees idempotencyKey: core strips it before calling the provider.
Retries, deferral and the dead-letter queue
A failed send is retried with backoff (aRateLimitedError waits at least its
retryAfterMs), unless the error is final. These errors are final and go straight
to the DLQ: UnsupportedContentError, PayloadTooLargeError,
OutsideSessionWindowError, RecipientUnreachableError, or any error with
retryable: false.
ctx.reply and bot.send keep retrying inline for up to outbox.inlineMaxMs
(default 15s). After that they reject with SendDeferredError, carrying the
outbox key. The send is not lost: the background sweeper keeps retrying it.
When the caller does not need to wait, enqueue the send instead:
bot.enqueue runs the before('send') hooks once, journals the send and returns
at once. The sweeper delivers it and then runs after('send') and writes the
message log. Within one process, sends to a chat keep their order while one of them
is being retried inline.
A text over the channel’s limit is split after the hooks ran, into one record per part
(key, key#2, …, delivered in that order). A part never overtakes an earlier one:
while key waits for a retry, key#2 waits behind it (and dies with it if key is
dead-lettered). Waiting behind an earlier part does not count as an attempt, so a part
keeps its whole outbox.retries budget for its own failures. The link between parts is
stored on each part record (OutboxRecord.previousPart), not read from the key: your
own idempotency keys may end in #2, #3… and stay independent sends. A journaled record whose text is over the limit (written by an older
version, or under a larger limit) is split by the sweeper with the same splitter: the
first part goes out under the record’s key and the others are journaled as key#2,
key#3… before it is sent, so a retry or a second pass journals and sends nothing
twice. The part keys only depend on the record key, the text and the channel limit, so they are the same
on every replay.
Inbound events whose processing keeps failing before the handlers run (the store
is down, for example) are retried with inbox.backoff and dead-lettered after
inbox.maxAttempts. A handler that throws is not retried: its side effects
already ran. The error goes to bot.on('error') and the event is acknowledged.
Operating it
- Leases and fencing. A record being processed is leased to
instanceIdforinbox.leaseMs(default 60 s), and a send in flight foroutbox.leaseMs(default 60 s; at least 20 ms, or the bot refuses the config). A heartbeat extends both while the handler or the provider call runs, so a slow upload is never taken for an abandoned send. If a lease expires (the process died or froze), another replica, or this one after a restart, claims the record under a new fencing token. The old holder’s completion is then rejected and counted instaleLeases. instanceId. Keep it stable across restarts of one deployment, so a restarted process reclaims its own unfinished records at once instead of waiting for their leases to expire. Its first sweep after the restart reclaims only the leases taken before it recovered (the dead incarnation’s, including a write that landed late), never a send it is making itself. Make it unique per replica that runs at the same time (a pod name of a StatefulSet, for example): two live processes with one id would take each other’s leases. On a single-process backend (memory, file) the default is<hostname>/<botId>/<connectionId>. On a shared backend (Postgres, Redis) the default adds a per-boot suffix, so two processes on one host never share it; a restart then waits for the old leases to expire. Set it explicitly to skip that wait.- Retention.
doneandsentrecords are purged afterretention(default7d). Keep it longer than the longest redelivery window of your channels, because a redelivery is only recognised while its record still exists. - Fail-closed. If the journal cannot record an inbound event, the event is not
dispatched. An
'error'is emitted and the in-memory dedupe forgets the event, so a redelivery is processed. SetfailOpen: trueto dispatch without the journal instead. - Payload size. Outbound content is journaled as JSON, with Buffers
base64-encoded. A payload over
outbox.maxPayloadBytes(default 1 MiB), or one that cannot be encoded (a stream), bypasses the outbox with a warning. Prefer URL or path media for large files. - Replayed media. See Media on replay.
- Edits, drafts and typing indicators are not journaled. Edits are idempotent, and drafts and indicators are transient.
Media on replay
The journal does not store a message’s media, only a reference to it. The bytes are downloaded lazily withloadMediaOf(ctx). On some channels that reference is a live
object: Baileys keeps the original WhatsApp message. So the inbox asks the provider for
a serializable, versioned reference (IProvider.serializeMediaRef) and journals that
instead. A message can be replayed after a crash, as a background retry, or by an
operator’s replayInbound. In each case the provider rebuilds the reference
(IProvider.rehydrateMediaRef) before the handler runs, so the media downloads as it
would for a live message.
No credentials are journaled: tokens and keys are added again at download time. The
stored reference is validated when it is read back. A replayed message whose media
cannot be rebuilt still runs; only its download fails.
loadMediaOf(ctx, { throwOnError: true }) then rejects with MediaUnavailableError (a
ProviderError), and its reason is one of these:
'not-durable': the provider did not serialize the reference (view-once, for example).'provider-mismatch': another provider wrote the record.'rehydrate-failed': the stored value is invalid, from an unknown version, or tampered with.
throwOnError, loadMediaOf resolves undefined, as it does for any failed
download.
durability.inbox.mediaRefs: false
to journal media tokens as before; a replayed message may then be unable to download
its media. Records written before this feature replay as they did before.
A provider adds the two optional IProvider methods. serializeMediaRef(ref) is
synchronous and returns { v, data } (JSON) or undefined. rehydrateMediaRef(stored, ref) validates stored, throws a ProviderError when it is not valid, and does not
need the transport to be connected.
All thirteen channels implement them: Baileys (a message key), Meta Cloud, Telegram
(file_id), Twilio, Messenger and Instagram (hosted attachment URL), Discord, Evolution
(the message key; the inline base64 is never journaled), WPPConnect (messageId), Gupshup
and GoHighLevel (hosted http(s) URL), email (Message-ID + attachment index + digest, plus
the IMAP UID and UIDVALIDITY when known) and TikTok (media coordinates, or the sticker
URL). A hosted URL can expire server-side before a replay: the provider then rejects with
its usual download error and the core reports MediaUnavailableError. Never write
credentials into data.
Several replicas
The journals are safe to share: two replicas never both claim a record, and a stale holder cannot complete one. The rest of the bot is still per process: active conversations, their pendingask, and the per-chat queue live in the memory of the
replica that runs them. So:
- Route each chat to one replica at a time (sticky routing by chat id at the load
balancer, or partition chats between replicas). If two replicas receive messages of
the same chat, the answer to an
askthat one replica is holding arrives at the other as a new message. - Failover is by lease. When a replica dies or freezes, its unfinished records are
claimed by another replica once their leases expire (
inbox.leaseMs), and replayed there. A replica that is alive keeps its leases through the heartbeat, however long its handler takes. - Give every replica its own explicit
instanceId. - Use a shared backend:
RedisDurabilityorPostgresDurability.SQLiteDurabilityandSharedFileEventJournalare shared only by processes on one host.
coordination (see Running N replicas), sticky routing is
not needed: chat turns serialize each chat across replicas and .persistent() flows
continue on whichever replica gets the next message. Bots of the same botId share one
journal scope, so any replica can replay or deliver a record. With a leader-only
transport session (providerSession: 'leader', e.g. Baileys) a standby’s outbox
sweeper never calls the transport (the attempt is refused with ProviderNotLeaderError
and retried); the leader delivers it.
With coordination, a record that fed a conversation step is acknowledged only once the
checkpoint write holding its effect landed (the parked marker, or the answer of a step
cut short); a suspended step whose write failed releases the record instead, so it is
replayed. A recorded answer points at its inbox record, so keep retention above your
longest conversation pause. See Conversation hand-over.
Testing crash recovery
@botmesh/core/testing exports crashable(backend), which can “kill” a bot at a
precise journal write, and CrashableProvider. Abandon the crashed bot and start a
new one on the same backend and store with the same instanceId:
test-utils/durability-crash.ts) against every
backend. It covers each crash point under both ambiguity policies, plus seeded
random points and a real child process killed in the middle of a handler.