A Yex.DocServer whose document is stored with Slap.Yjs.Store, and shared
with the servers of the same document on other nodes through it.
defmodule MyApp.DocServer do
use Slap.Yjs.DocServer
end
{:ok, pid} = Slap.Yjs.Docs.join(MyApp.DocServer, {"my-service", "doc-1"})Several servers may run for one document, one per node with clients
(Slap.Yjs.Docs starts them). None owns the document: each appends its own
clients' updates to the document's .updates stream and follows that
stream, applying everything in it, so each converges on
the stream's contents. Yjs updates commute and applying one twice changes
nothing, so the order of the appends and the server's own updates coming
back do not matter.
Storing
Before serving requests the server loads the document (the current
snapshot, then the updates after it) and follows the stream from there.
Every update from a client or a local edit is framed into a buffer. The
buffer is appended after :flush_after milliseconds or once it holds
:flush_bytes, whichever comes first. One append runs at a time: what is
buffered meanwhile goes in the next one, once it is acknowledged. An
append that fails for a transient reason (:unavailable, :timeout,
:overloaded, as when the document's shard moves) is retried; any other
failure stops the server with {:slap_yjs_append_failed, reason}.
The server relays a client's update to its other subscribers before storing it. If the server stops first, the update is lost unless a client that has it resynchronises. Servers on other nodes receive only stored updates.
A server started through Slap.Yjs.Docs begins loading after init/2
returns. Overrides of init/2 cannot assume stored updates are present.
Slap.Yjs.Docs.join/3 and subscribe/3 wait for loading to finish.
Compaction
Once the updates read since the last snapshot reach
max(:compact_bytes, last snapshot size / 2), the server compacts the
document (Slap.SnapshotLog.snapshot/4) in a task, one compaction at a
time, and keeps serving meanwhile. The snapshot is the document's state
at the offset it has read up to: every update before that offset has been
applied. It may hold more (updates pending, and local ones not appended
yet), which Slap.SnapshotLog allows because applying a Yjs update twice
changes nothing. If another server indexed a newer snapshot meanwhile,
the compaction is superseded, which is not an error. A compaction that
fails is tried again once the server reads more.
Clients
A client process subscribes with Slap.Yjs.Docs.join/3 (or subscribe/3),
sends its messages with process_message_v1(server, message, self()),
and receives:
{:slap_yjs_update, doc_id, update}- a document update (v1) from another client, on any node;{:slap_yjs_awareness, doc_id, update}- an awareness (presence) update.
encode_message/1 encodes these messages for the client.
The server monitors its subscribers. When one goes down, the awareness
states it set are removed. After :idle_timeout with no subscribers the
server stops.
Awareness
Awareness is not stored. The servers of a document form a :pg group
(scope Slap.Yjs.PG, started by Slap.Yjs.Docs) and send each other their
clients' awareness updates. Each monitors the group (:pg.monitor/2,
which needs OTP 26.2 or later): when a server joins, each of the others
sends it the states its own clients set (not those it learned from other
servers); when one leaves (it stops, or its node goes down), the others
remove the states that came through it.
Deletion
When the document is deleted (Slap.Yjs.Docs.delete/2), each of its
servers stops with {:shutdown, :deleted}, told by the delete or once
its follower or an append finds out, and stores nothing more: the
deletion is permanent, and what it had buffered is dropped. Starting a
deleted document directly returns {:error, {:shutdown, :deleted}};
Slap.Yjs.Docs.join/3 returns {:error, :deleted}.
Stopping
Unless an append failed or the document was deleted, the server appends
what it has buffered on stop, waits up to :terminate_timeout for its
appends and a running compaction, then compacts if it has read anything
since the last snapshot (:compact_on_stop). It traps exits so that a
supervisor's shutdown reaches terminate/2. By default, its
child_spec/1 and Slap.Yjs.Docs allow :terminate_timeout plus 30 seconds
per server.
Docs stops its document servers concurrently, so its shutdown takes at
most the largest child shutdown timeout, plus supervisor overhead. With
defaults, that is about 35 seconds. A server still stopping at its limit
is killed; a partial snapshot publication is safe to retry.
Options
Besides Yex.DocServer's options, such as :assigns:
:doc_id(required) - theSlap.Yjs.Storedocument,{service, name}.:flush_after- milliseconds to buffer an update (default 10).:flush_bytes- buffered bytes that start an append (default 256 KiB).:compact_bytes- the least bytes read that start a compaction (default 1 MiB).:compact_on_stop- compact on stop (default true).:compaction- options forSlap.SnapshotLog.snapshot/4, including:history,:cluster, and a per-call:timeout(default[]).:idle_timeout- milliseconds without subscribers before stopping (default:infinity;Slap.Yjs.Docsstarts servers with 30 s).:terminate_timeout- milliseconds to wait for appends and a running compaction on stop (default 5000).:store-Slap.Yjs.Storeoptions, including:prefixand:cluster.
A module that overrides init/2, handle_update_v1/4,
handle_awareness_update/4, handle_info/2 or terminate/2 must call
super (for handle_info/2, with the messages it does not handle
itself).
Telemetry
[:slap, :yjs, :doc_server, :append]when an append starts, with measurements%{bytes: b, updates: n}.[:slap, :yjs, :doc_server, :compact]when a compaction finishes, with measurements%{bytes: snapshot_size}and:offsetin the metadata.
The metadata of both includes :doc_id.
Summary
Functions
Returns the server's Yex.Doc. y_ex functions called on it run in the
server. Edits are relayed and stored like a client's updates. The handle
is valid only while the server runs.
Encodes a {:slap_yjs_update, _, _} or {:slap_yjs_awareness, _, _}
message as the y-protocols v1 message to send to a client.
Broadcasts an awareness change to the subscribers and, unless it came
from another server, to the document's other servers. Called by the
default handle_awareness_update/4.
Handles the server's own messages: requests, timers, what the follower
reads, awareness from other servers, and exits, among them its tasks'.
Called by the default handle_info/2; other messages are ignored.
Broadcasts an update to the subscribers, and buffers it for appending
unless it was read from the store. Called by the default
handle_update_v1/4.
Starts loading the document. Called by the default init/2.
Subscribes pid to updates after loading finishes. timeout bounds both waits.
Waits until the server has appended everything it had buffered, has read the document's stream up to its tail as of the call (so it has applied what other servers had stored), and has no compaction running.
Stops the follower; then, unless an append failed or the document was
deleted, appends what is buffered, waits for the appends and a running
compaction, and compacts if anything was read since the last snapshot.
Called by the default terminate/2.
Unsubscribes pid, and removes the awareness states it set.
Functions
@spec doc(GenServer.server(), timeout()) :: {:ok, Yex.Doc.t()} | {:error, term()}
Returns the server's Yex.Doc. y_ex functions called on it run in the
server. Edits are relayed and stored like a client's updates. The handle
is valid only while the server runs.
@spec encode_message( {:slap_yjs_update | :slap_yjs_awareness, Slap.Yjs.Store.doc(), binary()} ) :: binary()
Encodes a {:slap_yjs_update, _, _} or {:slap_yjs_awareness, _, _}
message as the y-protocols v1 message to send to a client.
@spec handle_awareness_update( Yex.Awareness.t(), map(), term(), Yex.DocServer.State.t() ) :: {:noreply, Yex.DocServer.State.t()}
Broadcasts an awareness change to the subscribers and, unless it came
from another server, to the document's other servers. Called by the
default handle_awareness_update/4.
@spec handle_info(term(), Yex.DocServer.State.t()) :: {:noreply, Yex.DocServer.State.t()} | {:stop, term(), Yex.DocServer.State.t()}
Handles the server's own messages: requests, timers, what the follower
reads, awareness from other servers, and exits, among them its tasks'.
Called by the default handle_info/2; other messages are ignored.
@spec handle_update_v1(Yex.Doc.t(), binary(), term(), Yex.DocServer.State.t()) :: {:noreply, Yex.DocServer.State.t()}
Broadcasts an update to the subscribers, and buffers it for appending
unless it was read from the store. Called by the default
handle_update_v1/4.
@spec init(keyword(), Yex.DocServer.State.t()) :: {:ok, Yex.DocServer.State.t()} | {:stop, term()}
Starts loading the document. Called by the default init/2.
@spec subscribe(GenServer.server(), pid(), timeout()) :: :ok | {:error, term()}
Subscribes pid to updates after loading finishes. timeout bounds both waits.
@spec sync(GenServer.server(), timeout()) :: :ok | {:error, term()}
Waits until the server has appended everything it had buffered, has read the document's stream up to its tail as of the call (so it has applied what other servers had stored), and has no compaction running.
@spec terminate(term(), Yex.DocServer.State.t()) :: :ok
Stops the follower; then, unless an append failed or the document was
deleted, appends what is buffered, waits for the appends and a running
compaction, and compacts if anything was read since the last snapshot.
Called by the default terminate/2.
@spec unsubscribe(GenServer.server(), pid(), timeout()) :: :ok | {:error, term()}
Unsubscribes pid, and removes the awareness states it set.