Slide 1

Slide 1 text

Building a Real-time Collaborative Editor in Phoenix ElixirConf EU 2021 • Warsaw, Poland

Slide 2

Slide 2 text

Find me anywhere @sheharyarn 02 Sheharyar Naseer Backend Engineer at Slab & 
 Manager at Google Developers Group

Slide 3

Slide 3 text

The Plan 03

Slide 4

Slide 4 text

Background – We build Slab – Solid foundation of Elixir & Phoenix – Technical Challenges – Collaboration – Consistency 04

Slide 5

Slide 5 text

The What – Build a simple text editor – Focus on real-time collaboration – Potential problems – How to solve them 05

Slide 6

Slide 6 text

The How – Discuss high-level architecture – Lay the groundwork – Implement a Document Server – Naive approach → The correct one – Solve problems along the way 06

Slide 7

Slide 7 text

Architecture 07

Slide 8

Slide 8 text

Architecture 08 Supervisor Document 1 Server Document 2 Server Document N Server Client 1 Client 2 Client N Phoenix Channels

Slide 9

Slide 9 text

Architecture 09 Supervisor Document 1 Server Document 2 Server Document N Server Client 1 Client 2 Client N Phoenix Channels

Slide 10

Slide 10 text

Architecture 10 Supervisor Document 1 Server Document 2 Server Document N Server Client 1 Client 2 Client N Phoenix Channels

Slide 11

Slide 11 text

Laying the Groundwork 11

Slide 12

Slide 12 text

Page and Template Router: scope "/", CollabWeb do pipe_through :browser get "/:id/", PageController, :show end 12 Controller: defmodule CollabWeb.PageController do use CollabWeb, :controller def show(conn, %{"id" => id}) do render(conn, "show.html", id: id) end end Template:

Document ID: <%= @id %> h2> textarea>

Slide 13

Slide 13 text

GenServer defmodule Collab.Document do use GenServer alias Collab.Document.Supervisor def start_link(id), do: GenServer.start_link( __ MODULE __ , :ok, name: name(id)) def get_contents(id), do: GenServer.call(name(id), :get_contents) def update(id, data), do: GenServer.call(name(id), {:update, change, ver}) def open(id) do case GenServer.whereis(name(id)) do nil -> DynamicSupervisor.start_child(Supervisor, { __ MODULE __ , id}) pid -> {:ok, pid} end end defp name(id), do: {:global, {:doc, id}} end 13

Slide 14

Slide 14 text

Supervisor defmodule Collab.Document.Supervisor do use DynamicSupervisor def start_link(_args) do DynamicSupervisor.start_link( _ _ MODULE _ _ , :ok, name: _ _ MODULE __ ) end @impl true def init(:ok) do DynamicSupervisor.init(strategy: :one_for_one) end end 14 defmodule Collab.Application do use Application def start(_type, _args) do children = [ Collab.Document.Supervisor, # . .. ] # ... end

Slide 15

Slide 15 text

Websocket @impl true def join("doc:" <> id, _payload, socket) do {:ok, _pid} = Document.open(id) socket = assign(socket, :id, id) send(self(), :after_join) {:ok, socket} end @impl true def handle_info(:after_join, socket) do response = Document.get_contents(socket.assigns.id) push(socket, "open", response) {:noreply, socket} end 15

Slide 16

Slide 16 text

Websocket @impl true def handle_in("update", payload, socket) do case Document.update(socket.assigns.id, payload) do {:ok, response} -> broadcast_from!(socket, "update", response) {:reply, :ok, socket} {:error, reason} -> {:reply, {:error, reason}, socket} end end 16

Slide 17

Slide 17 text

Document Server 17

Slide 18

Slide 18 text

Implementation v1 SEND THE ENTIRE DOCUMENT 18 defmodule Collab.Document do # . . . @initial_state %{contents: ""} @impl true def init(:ok), do: {:ok, @initial_state} @impl true def handle_call(:get_contents, _from, state) do {:reply, state, state} end @impl true def handle_call({:update, contents}, _from, _state) do state = %{contents: contents} {:reply, state, state) end end

Slide 19

Slide 19 text

Implementation v1 SEND THE ENTIRE DOCUMENT 19 – New updates will overwrite old ones – Edits might be lost during collaboration – Consumes a lot more bandwidth – Solution: Send diffs

Slide 20

Slide 20 text

Implementation v2 SEND EDITS AS DIFFS 20 @initial_state %{contents: []} @impl true def handle_call(:get_contents, _from, state) do {:reply, state, state} end @impl true def handle_call({:update, change}, _from, state) do state = %{ contents: Delta.compose(state.contents, change) } {:reply, {:ok, change}, state} end

Slide 21

Slide 21 text

Implementation v2 SEND EDITS AS DIFFS 21 – Latency in multiple updates – Edits can be outdated – Server and client states will diverge – Solution: Consistency Models

Slide 22

Slide 22 text

Need for Speed Consistency – Latency & Disconnections – Edits don't reach server instantaneously – But must be applied locally – Combine changes from all clients – Final result is same everywhere 22

Slide 23

Slide 23 text

Operational Transform (OT) – Algorithm for Consistency Maintenance – Highly suited for text editing – Tracking edits and comparing versions – Transforms them on changes not yet seen 23

Slide 24

Slide 24 text

Delta for Elixir – Expressive format to describe contents and changes – Supports Operational Transform – Allows formatting & arbitrary attributes – In production for 4 years 24 document = [insert("Hello World!")] change = [ retain(6), delete(5), insert("ElixirConf"), ] Delta.compose(document, change) # = > [insert("Hello ElixirConf!")]

Slide 25

Slide 25 text

Implementation v3 SEND DIFFS + OPERATIONAL TRANSFORM 25 @initial_state %{ contents: [], changes: [], version: 0, } @impl true def handle_call(:get_contents, _from, state) do response = Map.take(state, [:version, :contents]) {:reply, response, state} end

Slide 26

Slide 26 text

Implementation v3 SEND DIFFS + OPERATIONAL TRANSFORM 26 @impl true def handle_call({:update, client_change, client_version}, _from, state) do edits_since = state.version - client_version transformed_change = state.changes |> Enum.take(edits_since) |> Enum.reverse() |> Enum.reduce(client_change, &Delta.transform(&1, &2, true)) state = %{ version: state.version + 1, changes: [transformed_change | state.changes], contents: Delta.compose(state.contents, transformed_change), } response = %{version: state.version, change: transformed_change} {:reply, {:ok, response}, state} end

Slide 27

Slide 27 text

Implementation v3 SEND DIFFS + OPERATIONAL TRANSFORM 27 onRemoteUpdate({ change, version }) { let remoteDelta = new Delta(change); if (this.edit) { remoteDelta = this.edit.transform(remoteDelta); if (this.queued) { remoteDelta = this.queued.transform(remoteDelta); } } this.contents = this.contents.compose(remoteDelta); this.version += 1; this.updateEditor(); }

Slide 28

Slide 28 text

Implementation v3 28 – Exhibits Eventual Consistency – But the cursor jumps around? – Solution: Also OT SEND DIFFS + OPERATIONAL TRANSFORM

Slide 29

Slide 29 text

Implementation v3.1 29 SEND DIFFS + OPERATIONAL TRANSFORM + CURSOR FIX onRemoteUpdate({ change, version }) { // ... const currentPosition = this.editor.selectionStart; const newPosition = remoteDelta.transformPosition(currentPosition); // ... this.editor.selectionStart = newPosition; this.editor.selectionEnd = newPosition; }

Slide 30

Slide 30 text

What's Next? 30

Slide 31

Slide 31 text

Going from here... – Rich-text editing and formatting – Persisting Data: What and how often – Multiple Cursors – Attribution, Comments and Embeds – Zero Downtime 31

Slide 32

Slide 32 text

Questions? We're hiring! Join us at slab.com/jobs Live Demo → collab.slabdev.com These Slides → shyr.io/t/real-time-editor Delta for Elixir → github.com/slab/delta-elixir shyr.io [email protected] @sheharyarn