Amarula.RetryCache behaviour (amarula v0.4.4)
View SourcePluggable cache of recently-sent messages, so the library can re-encrypt and
resend when a recipient asks for a retry (<receipt type="retry">).
This is a separate concern from Amarula.Storage: it holds ephemeral,
bounded, latency-sensitive state (a small LRU of recent sends), not durable
account state. It therefore has its own behaviour, its own adapters, and its
own config key — independent of where durable storage lives.
Adapters
Amarula.RetryCache.ETS— in-memory, per-connection (the default). Lost on restart, which is fine: a retry receipt arrives within seconds.Amarula.RetryCache.DETS— on-disk, survives restart.Amarula.RetryCache.ReadOnly— backed by your own message store; Amarula keeps no copy and only reads. See "Using your own message store" below.
Select one via the connection config :retry_cache (a {adapter, opts} spec,
a bare opts list → the default adapter, or a prebuilt Scope), or set the
default for all connections:
config :amarula, retry_cache_adapter: Amarula.RetryCache.DETSThe behaviour is open: any module implementing Amarula.RetryCache can be passed
as the adapter, not just the three shipped here.
Using your own message store
If your application already persists the messages it sends, the built-in cache is
a redundant second copy. Point the retry cache at your store instead with the
Amarula.RetryCache.ReadOnly adapter (or your own behaviour module): you own
writes — Amarula never stores, evicts, or deletes anything — and on a retry it
reads the original message back from you by msg_id. This also removes the
:max_entries/TTL sizing question entirely: retention is whatever your store
already does.
Scoping
Like Amarula.Storage, every call carries the connection profile, so one
adapter instance serves many connections. The dispatch handle is a
RetryCache.Scope (adapter + state).
Eviction
The cache is bounded; eviction is the adapter's job (each backend does it
differently — an ETS size check, a DETS fold, a Redis TTL). put/4 is expected
to keep the cache within its bound.
Both shipped adapters take a :max_entries cap (default 200) in their opts and
evict the oldest entries past it. Raise it on a high-throughput sender so a burst
of sends can't push a still-unacked message out of the cache before its retry
receipt arrives:
Amarula.new(%{profile: :x, retry_cache: {Amarula.RetryCache.ETS, max_entries: 1000}})
Summary
Types
Adapter-specific state, returned by the adapter's new/1.
A cached entry: the recipient, the sent message, and a ms timestamp. The send
pipe also carries :stanza_attrs (replayed verbatim on a retry resend so a
peer/edit stanza keeps its attrs); it's optional since not every producer sets it.
The connection identity (its :profile).
Callbacks
Number of cached entries for profile (for tests/introspection).
Optional. Create any process-owned local resource the adapter needs, owned
by the calling process — for the ETS adapter, the named table. Called from
Connection.init, so the table is owned by Connection and dies (and is
recreated empty) with it: a poisoned entry can never survive the Connection
restart it triggers. Adapters with no process-owned resource (DETS, Redis)
don't implement this and it is a no-op.
Fetch a cached entry by msg_id, or :error on a miss.
Initialise adapter state from opts. Called once per connection.
Optional. Store entry under msg_id for profile, evicting to stay within
bound. A read-only adapter backed by the consumer's own store (e.g.
Amarula.RetryCache.ReadOnly) does not implement this — the consumer owns
writes, so Amarula never writes here.
Functions
Number of cached entries for profile.
The adapter used when config gives bare opts / no :retry_cache.
Create the adapter's process-owned local resource (the ETS table, for the
default adapter), owned by the caller — call this from Connection.init.
A no-op for adapters that don't own such a resource.
Fetch a cached entry by id, or :error.
Store a sent message for possible retry-resend. A no-op for read-only adapters
(those that don't implement put/4) — the consumer's store already has it.
Build a RetryCache.Scope from a connection config. Reads :retry_cache
(a {adapter, opts} spec, a bare adapter module, a bare opts list, or a
prebuilt Scope); when absent, uses default_adapter/0 with no opts.
Types
@type adapter_state() :: term()
Adapter-specific state, returned by the adapter's new/1.
@type entry() :: %{ :recipient_jid => String.t(), :message => struct(), :ts => integer(), optional(:stanza_attrs) => map() }
A cached entry: the recipient, the sent message, and a ms timestamp. The send
pipe also carries :stanza_attrs (replayed verbatim on a retry resend so a
peer/edit stanza keeps its attrs); it's optional since not every producer sets it.
The connection identity (its :profile).
Callbacks
@callback count(adapter_state(), profile()) :: non_neg_integer()
Number of cached entries for profile (for tests/introspection).
@callback ensure_local(adapter_state(), profile()) :: :ok
Optional. Create any process-owned local resource the adapter needs, owned
by the calling process — for the ETS adapter, the named table. Called from
Connection.init, so the table is owned by Connection and dies (and is
recreated empty) with it: a poisoned entry can never survive the Connection
restart it triggers. Adapters with no process-owned resource (DETS, Redis)
don't implement this and it is a no-op.
@callback get(adapter_state(), profile(), msg_id :: String.t()) :: {:ok, entry()} | :error
Fetch a cached entry by msg_id, or :error on a miss.
@callback new(opts :: keyword()) :: adapter_state()
Initialise adapter state from opts. Called once per connection.
@callback put(adapter_state(), profile(), msg_id :: String.t(), entry()) :: :ok
Optional. Store entry under msg_id for profile, evicting to stay within
bound. A read-only adapter backed by the consumer's own store (e.g.
Amarula.RetryCache.ReadOnly) does not implement this — the consumer owns
writes, so Amarula never writes here.
Functions
@spec count(Amarula.RetryCache.Scope.t(), profile()) :: non_neg_integer()
Number of cached entries for profile.
@spec default_adapter() :: module()
The adapter used when config gives bare opts / no :retry_cache.
@spec ensure_local(Amarula.RetryCache.Scope.t(), profile()) :: :ok
Create the adapter's process-owned local resource (the ETS table, for the
default adapter), owned by the caller — call this from Connection.init.
A no-op for adapters that don't own such a resource.
@spec get(Amarula.RetryCache.Scope.t(), profile(), String.t()) :: {:ok, entry()} | :error
Fetch a cached entry by id, or :error.
@spec put(Amarula.RetryCache.Scope.t(), profile(), String.t(), entry()) :: :ok
Store a sent message for possible retry-resend. A no-op for read-only adapters
(those that don't implement put/4) — the consumer's store already has it.
@spec scope(map()) :: Amarula.RetryCache.Scope.t()
Build a RetryCache.Scope from a connection config. Reads :retry_cache
(a {adapter, opts} spec, a bare adapter module, a bare opts list, or a
prebuilt Scope); when absent, uses default_adapter/0 with no opts.