Going to Production
View SourceWhat a consumer must decide before running Amarula for real. The library ships sensible defaults for local dev; production forces three choices it deliberately leaves to you:
- Credential storage — where the auth state lives.
- The profile registry — single node or cluster.
- Message storage — what to keep, how to read it back.
Amarula has no opinion on any of these by design. It gives you seams; you pick the backend and the policy. This doc points at the seams and the gotchas.
1. Credential storage
Everything Amarula must remember across a restart — auth creds, 1:1 Signal
sessions, group sender keys, LID↔PN mappings, the device-list cache, app-state —
flows through one seam: the Amarula.Storage behaviour, scoped by
{profile, namespace, key}. Lose this and you re-pair from a QR. So in prod
it must be durable, backed up, and concurrency-safe.
Pick an adapter
Pass :storage on the config (Amarula.new/1). Two adapters ship:
| Adapter | Spec | Use |
|---|---|---|
Amarula.Storage.File (default) | {Amarula.Storage.File, root: "./amarula_data"} | single node, simple. One <root>/<profile>/ dir per profile, atomic writes. |
Amarula.Storage.DETS | {Amarula.Storage.DETS, ...} | single node, fewer inodes. |
If you pass no :storage, the default is the File adapter rooted at
./amarula_data (override with the AMARULA_DATA_DIR env var). That default path
is already in .gitignore (along with amarula_auth/amarula_store) so credentials
never get committed by accident — keep any custom root ignored too.
Both adapters are node-local on disk. For multi-node, or for "creds must survive the
box dying," write a DB/object-store adapter — implement the five callbacks
(new/get/put/delete/clear, plus optional list_profiles) and pass
{YourAdapter, opts}. The protocol layer only ever says "save this session" /
"load that mapping"; it never touches your backend directly.
Gotchas
- Values are opaque Elixir terms (
:erlang.term_to_binary/1) — atom keys, raw binaries. Not interchangeable with Baileys' JSON state, and a DB adapter must store an opaque blob, not try to map columns. - Concurrency. Adapters must be safe to call from multiple processes for the same scope. The File adapter does atomic temp-file + rename; a DB adapter gets this for free per row.
- Profile = tenant key, and it becomes a path segment in the File adapter.
Never wire untrusted input straight into
:profile— the File adapter raises on traversal ("../.."), but a multi-tenant bot should validate/namespace profiles itself. - Back it up. Re-pairing means a human scans a QR. Treat the store like a password vault.
- Decode is
[:safe]— a tampered.termfile can't mint atoms or smuggle funs; it's treated as a cache miss. Don't rely on that for trust; keep the store private.
One profile = one credential set
:profile names the account's stored creds. The next run with the same profile
reconnects without a QR. Keep profile ↔ credentials strictly 1:1 — the library
trusts this and does not validate it.
2. The profile registry
Amarula enforces one live connection per profile. Two WebSockets on one
credential set corrupt the shared Signal ratchet — this is a correctness
invariant, not deduplication. How far "one" reaches is your choice, set by the
:registry config seam.
WhatsApp enforces this too, server-side. One credential set is one device. If
a second connection authenticates on the same creds, the server drops the
first — it sends a conflict/replaced stream error and disconnects it
(connectionReplaced, code 440). So racing two connections doesn't give you two
live sessions; it gives you a flapping connection as each kicks the other off, and
a corrupted ratchet in the crossfire. The local registry exists to stop you ever
getting there. Treat 440 / replaced as "someone else took my profile," not a
transient error to auto-reconnect through.
Uniqueness reach = the registry's reach. The library only uses the standard
Registry/:via contract:
:registry | Reach | When |
|---|---|---|
default Amarula.ProfileRegistry (local) | one per profile per node | single node |
:via-cluster registry (Horde.Registry, a :global/:pg shim) | one per profile cluster-wide | distributed |
The consumer distributes the credentials and picks the registry; Amarula enforces one-per-profile against whatever reach that registry has. The library never decides clustering.
Distributed gotcha
A cluster registry built on :global is only best-effort at uniqueness. If the
cluster splits in two (a network partition), each half can register the same
profile, because neither half can see the other. Now you have two live
connections on one profile until the network heals — long enough to corrupt the
ratchet.
So in a real cluster, don't rely on the registry alone. Add an external lease:
a single row in your database (or a Redis key) that one node must hold before it's
allowed to connect a given profile. The database stays consistent across a
partition where :global doesn't, so it's the real guard; the registry just keeps
things tidy within a healthy cluster. You use both — the lease for safety, the
:registry seam for the fast in-cluster check.
Handles
Amarula.whereis(profile)→ current pid (restart-safe; the pid changes on restart, the profile keeps resolving).Amarula.via(profile)→ a:viahandle usable anywhere aconn()is accepted.- Raw pid from
connect/2goes stale on restart — prefer the profile handle in long-lived code. disconnect/1closes the socket but keeps the profile registered (it may reconnect). To free the slot (and let another node claim it), useAmarula.stop/1.
3. Message storage
Amarula does not persist messages. That is your job. An inbound message is
delivered once as {:whatsapp, :messages_upsert, %{from, id, messages: [%Amarula.Msg{}]}} to your parent_pid, then forgotten. No replay, no inbox.
What to store
Store the consumer struct, not the raw protobuf. Each %Amarula.Msg{} carries
the friendly view:
| field | keep for |
|---|---|
id | dedup key + reply/quote/revoke target |
channel | the reply handle — peer in 1:1, the group in a group |
from / to | who wrote it / real recipient (matters for from_me fan-out) |
from_me | tells your own sent messages apart |
timestamp | ordering |
type + content | :text / :media / :reaction / :edit / :revoke / … |
pushname | sender display name off the stanza (name a contact with no fetch) |
msg.raw is the full %Proto.Message{} escape hatch — keep it only if you need a
field Amarula doesn't surface; it's large.
Reading the body — text and media
msg.content shape depends on msg.type. Match on the type:
case msg.type do
:text ->
text = msg.content # content IS the String
save_text(msg.id, text)
:media ->
# content is a DESCRIPTOR, not bytes:
# %{kind: :image | :video | :audio | :document | :sticker, media: struct}
%{kind: kind} = msg.content
# ...store the descriptor; fetch bytes on demand (below)
_ ->
:ok # reactions, edits, polls, … — see the type table
endMedia is not downloaded for you. The inbound message carries only a descriptor (URL + decryption keys), never the file. To get the bytes, call:
{:ok, bytes} = Amarula.download_media(msg) # pass the whole %Msg{}What to do with the bytes is your decision, and it drives what you store:
- Don't store bytes in your message DB. They're large and the WhatsApp media URL is short-lived, so you can't lazily re-fetch later from the descriptor alone forever. Decide up front.
- Download once, on receipt, to your own object store (S3/disk), and store its URL/path next to the message. This is the usual choice — the WhatsApp URL expires; yours doesn't.
- Deferring is risky: WhatsApp's CDN drops the blob after a while, and a later
download then returns
{:error, {:http, 404}}. The protocol's recovery is to ask the phone to re-upload the media — and Amarula does not implement that retry path yet. So treat the descriptor as fetch-soon, not fetch-whenever.
So: text → store the string. Media → download to storage you control, store the
pointer (and the kind), not the raw bytes in your row.
Best practices
- Dedup by
id. On a single connection you do not echo your own sends, so no self-dedup needed. The one case that needs cross-connection dedup byid: two connections on the same account (each receives the other's sends). - Reply by
channel. Putmsg.channelstraight into a send target — routes back to the same conversation. Don't reconstruct fromfrom/to. - Media is lazy. A
:mediamessage'scontentis a descriptor; callAmarula.download_media/2to fetch bytes. Store the descriptor if you want to download later; bytes aren't kept by the library. - Edits/revokes/reactions point at an earlier
idvia aMessageKeyincontent. To apply them you need the original stored — another reason to keep anid-keyed store. - Persist on receipt, synchronously enough not to lose on crash. The event fires once; if your handler crashes before writing, the message is gone.
History sync
On first pair, WhatsApp pushes a history-sync blob (recent chats/messages). It
arrives through the normal event path — treat it as a bulk :messages_upsert to
seed your store, then rely on live :messages_upsert going forward.
Quick checklist
- [ ] Durable, backed-up
:storageadapter (not the dev./amarula_dataFile default). - [ ]
profile ↔ credentials1:1, profiles validated if tenant-derived. - [ ] Registry reach matches your topology (local vs cluster), + an external lease
if `:global`. - [ ] A message store you own, keyed by
id, persisted on:messages_upsert. - [ ] Reply via
msg.channel; dedup byidonly across connections.
See also
INFRASTRUCTURE.md— supervision tree, send/ack semantics, the two registries.Amarula.Storage— the storage behaviour + namespaces.Amarula.Msg— the received-message struct (addressing,from_me,pushname).Amarula— the public facade (new/1,connect/2,whereis/1,stop/1).