Synchronization
Replicate documents and attachments between databases with a version-vector protocol, in the same VM or over HTTP.
Barrel replicates documents between databases with a version-vector protocol:
causal ordering from a hybrid logical clock (HLC), deterministic winners,
idempotent delivery, and checkpoints for resumption. Replication runs in the
same VM or over HTTP against a barrel_server. Read this when you need to
copy or keep two databases in sync, replicate a subset with channels, or move
attachments along with documents.
What it is
Replication lives in barrel_docdb (barrel_rep). One run:
- Read the checkpoint (a local doc on both sides) to find the last shipped HLC.
- Fetch changes from the source since that HLC, filter applied at the source.
- Diff the offered version tokens against the target in one round trip.
put_versionthe missing documents on the target (idempotent, so at-least-once delivery is safe).- Checkpoint per batch, then run the attachment phase (see below).
Both sides converge to the same winner, body, and deletion state for every document, whatever the interleaving of writes. Conflicts are recorded where the concurrency was observed and resolve by writing a superseding version.
Transports implement the barrel_rep_transport behaviour. Two ship today:
barrel_rep_transport_local (same VM, database names) and
barrel_rep_transport_http (hackney client speaking the
/db/:db/_sync/* wire served by barrel_server).
When to use it
- Copy a database to another database, in the same VM or on another node.
- Keep replicas eventually consistent, one-shot per run or continuously.
- Replicate a subset of documents with a channel, path, or query filter.
- Move attachment blobs with their documents, content-addressed.
How (same VM)
{ok, _} = barrel_docdb:create_db(<<"source">>),
{ok, _} = barrel_docdb:create_db(<<"target">>),
{ok, _} = barrel_docdb:put_doc(<<"source">>, #{<<"id">> => <<"a">>, <<"v">> => 1}),
{ok, Result} = barrel_rep:replicate(<<"source">>, <<"target">>),
#{docs_written := N, att_sync := _} = Result.
Run it again later to pick up new changes; it resumes from the checkpoint.
How (over HTTP)
The remote side is a URL under a running barrel_server. Build an endpoint,
then pass the matching transport:
Endpoint = barrel_rep_transport_http:endpoint(
<<"http://edge-1.example.com:8080/db/inventory">>),
%% push
{ok, _} = barrel_rep:replicate(<<"inventory">>, Endpoint,
#{target_transport => barrel_rep_transport_http}),
%% pull
{ok, _} = barrel_rep:replicate(Endpoint, <<"inventory">>,
#{source_transport => barrel_rep_transport_http}).
endpoint/1 normalizes the URL (scheme and host lowercased, no trailing
slash). The normalized URL is the replication identity: changing its text
starts a fresh checkpoint, credentials and tuning do not. The endpoint map
also takes pool, connect_timeout, recv_timeout, and headers.
How (auth)
Protect a server with static bearer tokens; a list accepts old and new during
a rotation. /health stays open, everything else requires the token:
%% server side (app env, before barrel_server starts)
application:set_env(barrel_server, auth, #{tokens => [<<"s3cret">>]}).
Give the client its token on the endpoint, or keyed by origin in the app env so persisted task configs stay secret-free:
Authed = Endpoint#{auth => #{token => <<"s3cret">>}},
%% or
application:set_env(barrel_docdb, sync_auth,
#{<<"http://edge-1.example.com:8080">> => <<"s3cret">>}).
How (selective replication)
Filters apply at the source. Path and query filters compose with AND:
{ok, _} = barrel_rep:replicate(<<"source">>, <<"target">>, #{
filter => #{
paths => [<<"users/#">>],
query => #{where => [{path, [<<"status">>], <<"active">>}]}
}
}).
Channels are the indexed alternative: named pattern sets declared at database creation, materialized into a per-channel feed at write time, so a filtered pull is one bounded scan instead of a full feed walk:
{ok, _} = barrel_docdb:create_db(<<"source">>, #{
channels => #{<<"mobile">> => [<<"type/task">>, <<"owner/+">>]}
}),
{ok, _} = barrel_rep:replicate(<<"source">>, <<"target">>, #{
filter => #{channel => <<"mobile">>}
}).
Channel notes:
- Patterns are MQTT style (
+one segment, trailing#for the rest). - Channels are fixed at creation and index writes made after creation.
- A document that stops matching stops being sent; replicas keep their last copy (no target-side delete). A fresh replica never sees departed docs.
- A filtered replication keeps its own checkpoint (the filter joins the replication id).
Attachments
Attachment sync rides every run (attachments => true by default). It is a
second phase after the document loop: the source’s attachment feed is
digest-diffed against the target, only missing content transfers, and writes
converge last-write-wins on their origin HLC. The result lands under
att_sync in the replication result:
{ok, #{att_sync := #{atts_written := _, atts_skipped := _}}} =
barrel_rep:replicate(<<"source">>, Endpoint,
#{target_transport => barrel_rep_transport_http}).
Notes:
- Blobs stream in chunks both directions over HTTP; nothing buffers a blob
whole. Pulls have no size ceiling. Pushes are bounded by the server’s
{barrel_server, max_body, Bytes}env (default 1 GiB,infinityaccepted); a larger push gets a 413, fails for that attachment only, and the run reports it inatt_write_failures. - There is no ordering between documents and their attachments: a doc can arrive before its blob (reads 404 until the attachment phase lands).
- Attachments written before this feature do not sync until rewritten, or
synthesize feed rows once with
barrel_docdb:rebuild_attachment_feed/1. - Turn the phase off with
attachments => false; it degrades toatt_sync => skippedon its own when a side cannot track attachments.
Continuous replication (tasks)
The task manager (barrel_rep_tasks) persists replications across restarts.
Remote ends are URLs; the HTTP transport is picked automatically:
{ok, TaskId} = barrel_rep_tasks:start_task(#{
source => <<"inventory">>,
target => <<"http://edge-1.example.com:8080/db/inventory">>,
mode => continuous, %% or one_shot
direction => push, %% push | pull | both
filter => #{channel => <<"mobile">>}
}),
{ok, #{status := running}} = barrel_rep_tasks:get_task(TaskId),
ok = barrel_rep_tasks:stop_task(TaskId).
Continuous behavior:
- Local sources are event driven: the task wakes on the changes stream and drains through its filter, so local convergence is tens of milliseconds.
- Remote sources poll adaptively, 500 ms after data and backing off to 15 s while idle.
- Transient errors do not kill a continuous task: it backs off (1 s to 60 s,
with jitter), records
last_erroron the task doc, and staysrunning. One-shot tasks fail fast. - Task docs store endpoints as URLs only; tokens come from
sync_auth.
Clocks
Every wire exchange carries an x-barrel-hlc header and both sides fold it
into their clock, on top of the explicit handshake at the start of each run.
A peer too far ahead is rejected with {error, clock_skew} (409 on the
wire). To couple clocks by hand:
{ok, _Merged} = barrel_docdb:sync_hlc(RemoteHlc).
Browser clients
The same /db/:db/_sync/* wire drives barrel-lite, the TypeScript browser
client: it keeps an offline-first OPFS store, stamps local mutations with its
own source id, and pushes and pulls over this protocol (adaptive polling, not
SSE). Enable CORS and issue a capability token for it. See
barrel-lite.
What is not covered yet
- TLS serving.
barrel_serverlistens on plain HTTP; the client can reach https servers. Put a TLS terminator in front for now. - Vectors. Vector indexes are not shipped; with record mode the text and metadata replicate as documents and the index rebuilds locally. Quantized vector sync is planned with the TypeScript client.
- Barrel API.
barrel(the full embeddable database) does not expose replication; callbarrel_repandbarrel_rep_taskson the underlying docdb name. - Provenance. The actor/session/source a write carries (see
audit-provenance) does not travel on the wire.
Replicated arrivals are attributed
cause: replicatedwith the origin database’s identity; the acting agent stays on the origin’s own trail.