Slap.Yjs.DocServer (slap_yjs v0.3.0)

Copy Markdown View Source

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) - the Slap.Yjs.Store document, {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 for Slap.SnapshotLog.snapshot/4, including :history, :cluster, and a per-call :timeout (default []).
  • :idle_timeout - milliseconds without subscribers before stopping (default :infinity; Slap.Yjs.Docs starts servers with 30 s).
  • :terminate_timeout - milliseconds to wait for appends and a running compaction on stop (default 5000).
  • :store - Slap.Yjs.Store options, including :prefix and :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 :offset in 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

doc(server, timeout)

@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.

encode_message(arg)

@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.

handle_awareness_update(awareness, change, origin, state)

@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.

handle_info(arg1, state)

@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.

handle_update_v1(doc, update, origin, state)

@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.

init(arg, state)

@spec init(keyword(), Yex.DocServer.State.t()) ::
  {:ok, Yex.DocServer.State.t()} | {:stop, term()}

Starts loading the document. Called by the default init/2.

subscribe(server, pid, timeout)

@spec subscribe(GenServer.server(), pid(), timeout()) :: :ok | {:error, term()}

Subscribes pid to updates after loading finishes. timeout bounds both waits.

sync(server, timeout)

@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.

terminate(reason, state)

@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.

unsubscribe(server, pid, timeout)

@spec unsubscribe(GenServer.server(), pid(), timeout()) :: :ok | {:error, term()}

Unsubscribes pid, and removes the awareness states it set.