A behaviour for following the CouchDB _changes feed of a database.
If the only tool you have is CouchDB, then everything looks like
{:ok, :relax}
Sofa.Changes is a GenServer with one extra callback,
handle_change/2, which is invoked once per changed document, in
order, and one extra way of returning from init/1: alongside the
state it names the database to follow and, optionally, the feed options
described in Sofa.Changes.Query.
defmodule Follower do
use Sofa.Changes
def start_link(db), do: Sofa.Changes.start_link(__MODULE__, db)
@impl true
def init(db), do: {:ok, db, [include_docs: true], nil}
@impl true
def handle_change(%{"id" => id, "deleted" => true}, state) do
Logger.warning("deleted: #{id}")
{:ok, state}
end
def handle_change(%{"doc" => %Sofa.Doc{id: id, rev: rev, body: body}}, state) do
Logger.info("doc #{id} rev=#{rev} fields: #{inspect(Map.keys(body))}")
{:ok, state}
end
def handle_change(change, state) do
Logger.error("unhandled change: #{inspect(change)}")
{:ok, state}
end
endA ready-to-run script version of this example lives at
examples/follower.exs. It prints each document's id, rev and body
fields, and logs deletions without crashing.
The database is a %Sofa{} with its database set, as returned by
Sofa.DB.open!/2. The follower reuses its Req client and credentials.
use Sofa.Changes declares the behaviour and injects overridable
defaults for every callback, so only init/1 and handle_change/2
usually need writing. The remaining GenServer callbacks (handle_call,
handle_cast, handle_info, terminate, code_change) are delegated to
the callback module with its own state threaded through, and
handle_error/2 decides what to do when a request fails.
Streaming and reconnecting
Each follower is one process with its own HTTP connection, run by a
linked Sofa.Changes.Worker. Chunks of the feed are delivered as
messages to the follower, so handle_change/2 is single threaded and
ordered. Rows are passed through as decoded maps, see
Sofa.Changes.Parser, except that with include_docs: true the
embedded document becomes a %Sofa.Doc{} via Sofa.Doc.from_map/1,
with a deletion's tombstone carrying "_deleted" in its body.
The follower remembers the seq of every delivered row, and the
last_seq a response ends with. When the server ends the response, as it
does on its timeout, on limit, or after each longpoll batch, the
follower reconnects with since set to that sequence, after a random
delay of up to 500 ms so that many followers do not reconnect at once.
With feed: :normal the single batch is delivered and the follower then
stays idle, which suits a one-off catch-up.
Sequences are opaque and never parsed. Because shards can move, a change
may occasionally be delivered twice. CouchDB documents this, so
handle_change/2 should be idempotent.
Should the server reject an advanced since with a 400, the follower
retries once from the last sequence the server did accept, the one it
sent on the previous successful request. If that is rejected too, or
there is no such sequence, the follower stops with reason :bad_request.
A watchdog restarts the connection when nothing, not even a heartbeat,
has arrived for twice the heartbeat interval, which catches half-open
TCP connections the server will never notice. Client-side receive
timeouts, dropped connections and a crashed connection process restart
the stream the same way, from the last sequence, without involving
handle_error/2.
Errors
A response with a status other than 200, or a failed connection, is
reported to handle_error/2 as one of error/0, after closing the
connection. The reply decides whether and when to reconnect.
Summary
Types
A changed document, as decoded from the feed, see Sofa.Changes.Parser.row/0
Why a request failed, as given to handle_error/2
A follower, as accepted by GenServer functions
The reply tag passed to handle_call/3
The follower's internal state, visible via :sys.get_state/1
Callbacks
Called once for every changed document, in the order received.
Called when a request fails with a non-200 status or a connection error.
Called when the follower starts, inside the GenServer init.
Functions
Calls the follower, handled by its handle_call/3.
Casts to the follower, handled by its handle_cast/2.
Returns a specification to start this module under a supervisor.
Replies to a call, see GenServer.reply/2.
Reconnects a follower that is waiting after {:retry, :infinity, state}
from handle_error/2. A no-op while a request is in flight.
Starts an unlinked follower, see GenServer.start/3.
Starts a follower linked to the current process, see GenServer.start_link/3.
Stops a follower, closing its connection and calling terminate/2.
Resolves a registered name to a pid, see GenServer.whereis/1.
Types
@type change() :: %{required(String.t()) => term() | Sofa.Doc.t()}
A changed document, as decoded from the feed, see Sofa.Changes.Parser.row/0
@type error() :: :bad_request | :unauthorized | :forbidden | :not_found | :internal_server_error | {:status_code, pos_integer()} | {:conn_failed, term()}
Why a request failed, as given to handle_error/2
@type follower() :: pid() | atom() | {:global, term()} | {:via, module(), term()} | {atom(), node()}
A follower, as accepted by GenServer functions
The reply tag passed to handle_call/3
@type t() :: %Sofa.Changes{ buffer: binary(), connected: boolean(), db: Sofa.t(), fallback: Sofa.Changes.Query.since(), lkg: Sofa.Changes.Query.since(), module: module(), mstate: term(), query: Sofa.Changes.Query.t(), ref: reference() | nil, retry: reference() | :infinity | nil, timeout: timeout() | :hibernate | nil, watchdog: reference() | nil, worker: pid() | nil, workers: %{optional(pid()) => true} }
The follower's internal state, visible via :sys.get_state/1
Callbacks
@callback handle_call(request :: term(), from(), state :: term()) :: {:reply, reply, new_state} | {:reply, reply, new_state, timeout() | :hibernate} | {:noreply, new_state} | {:noreply, new_state, timeout() | :hibernate} | {:stop, reason, reply, new_state} | {:stop, reason, new_state} when reply: term(), new_state: term(), reason: term()
@callback handle_change(change(), state :: term()) :: {:ok, new_state} | {:ok, new_state, timeout() | :hibernate} | {:stop, reason :: term(), new_state} when new_state: term()
Called once for every changed document, in the order received.
A deleted document arrives as a change whose "deleted" key is true.
With include_docs: true its embedded "doc" is a tombstone whose body
carries only "_deleted" => true and none of the document's former
fields. Callbacks should match on "deleted" (or on "id") before
assuming a field such as a document body is present, otherwise a
deletion will raise a FunctionClauseError and stop the follower.
{:stop, reason, state} stops the follower and calls terminate/2.
@callback handle_error(error(), state :: term()) :: {:retry, new_state} | {:retry, timeout(), new_state} | {:stop, reason :: term(), new_state} when new_state: term()
Called when a request fails with a non-200 status or a connection error.
{:retry, state} reconnects right away, {:retry, ms, state} after
ms milliseconds, {:retry, :infinity, state} waits for retry_now/1,
and {:stop, reason, state} stops the follower. Every reconnect adds up
to 500 ms of jitter. The default waits 60 seconds, which also keeps a
follower with bad credentials from tripping CouchDB's account lockout.
@callback init(args :: term()) :: {:ok, db :: Sofa.t(), state} | {:ok, db :: Sofa.t(), opts :: keyword(), state} | {:ok, db :: Sofa.t(), opts :: keyword(), state, timeout() | :hibernate} | :ignore | {:stop, reason :: term()} when state: term()
Called when the follower starts, inside the GenServer init.
Returns the database to follow, optionally the feed options (see
Sofa.Changes.Query), the callback state, and optionally a GenServer
timeout. :ignore and {:stop, reason} behave as in GenServer.
The first request is only made after init/1 returns, so starting
succeeds even when the server will refuse it. The refusal then reaches
handle_error/2.
Functions
Calls the follower, handled by its handle_call/3.
Casts to the follower, handled by its handle_cast/2.
Returns a specification to start this module under a supervisor.
See Supervisor.
Replies to a call, see GenServer.reply/2.
@spec retry_now(follower()) :: :ok
Reconnects a follower that is waiting after {:retry, :infinity, state}
from handle_error/2. A no-op while a request is in flight.
@spec start(module(), term(), GenServer.options()) :: on_start()
Starts an unlinked follower, see GenServer.start/3.
@spec start_link(module(), term(), GenServer.options()) :: on_start()
Starts a follower linked to the current process, see GenServer.start_link/3.
Stops a follower, closing its connection and calling terminate/2.
Resolves a registered name to a pid, see GenServer.whereis/1.