Packages

A library to handle [Differential Synchroniazation](https://neil.fraser.name/writing/sync/) in an Elixir app.

Current section

Files

Jump to
ex_sync lib ex_sync.ex
Raw

lib/ex_sync.ex

defmodule ExSync do
require Logger
alias ExSync.{Shadow, Edit, Storage}
@moduledoc """
This module implements the main parts of the server-side flow of the
Diff Sync algorithm.
The main function here is [sync cycle](#sync_cycle/5), which does a full
cycle given the object id, shadow, backup shadow, edits and the storage
adapter.
"""
@type id :: any
@type document :: map
@type error :: (atom | map)
@doc """
The Diff Cycle. Takes changes from the client (can be an empty list) and
applies them to the shadow.
Updates the doc using `ExSync.Storage.get_and_update/2` and returns the new
shadow as well as any edits between the shadow and the doc.
"""
@spec sync_cycle(any, Shadow.t, Shadow.t, [Edit.t], Storage) ::
{:ok, Shadow.t, [Edit.t]} | {:error, error}
def sync_cycle(id, shadow, backup_shadow, edits, storage) do
with {:ok, shadow} <- patch_shadows(shadow, backup_shadow, edits),
{:ok, doc} <- patch_server_doc(storage, id, edits),
{:ok, {shadow, edits}} <- get_server_doc_edits(shadow, doc),
do: {:ok, {put_in(shadow.doc, doc), edits}}
end
@doc """
This function patches the server_shadow or backup_shadow with edits.
Expects server_shadow and backup_shadow as type ExSync.Shadow,
edits as a List of ExSync.Edit.
"""
@spec patch_shadows(Shadow.t, Shadow.t, [Edit.t]) ::
{:ok, Shadow.t} | {:error, error}
def patch_shadows(server_shadow, backup_shadow, edits) do
case apply_shadow_edits(server_shadow, backup_shadow, edits) do
{:ok, {server_shadow, _backup_shadow}} ->
# TODO: Fix logic around backup shadows
{:ok, server_shadow}
{:error, reason} ->
Logger.error "Patching shadows failed: #{inspect reason}"
{:error, reason}
end
end
@doc """
This function patches the server doc.
It expects a storage adapter that implements the ExSync.Storage behaviour,
an id and a list of edits as ExSync.Edit.
It will use the `get_and_update/2` function of the storage, passing it a
function to apply the edits to the server doc. That way we can use locks
in the function to ensure data consistency between reading and writing.
"""
@spec patch_server_doc(atom, any, [Edit.t]) ::
{:ok, document} | {:error, error}
def patch_server_doc(storage_adapter, id, edits) do
storage_adapter.get_and_update(id, &apply_server_doc_edits(&1, edits))
end
@doc """
Calculates the difference between the server shadow and the server doc.
Returns the new edits as a list of one item as well as the server shadow with
an updated server_version number (if applicable).
"""
def get_server_doc_edits(server_shadow, doc) do
case diff_patch.diff(server_shadow.doc, doc) do
{:ok, diff} -> format_diff(diff, server_shadow)
error -> error
end
end
@doc false
defp format_diff(nil, server_shadow), do: {:ok, {server_shadow, []}}
defp format_diff(diff, server_shadow) do
diff = List.wrap %{
diff: diff,
serverVersion: server_shadow.server_version,
localVersion: server_shadow.client_version
}
server_shadow = update_in server_shadow.server_version, &(&1 + 1)
{:ok, {server_shadow, diff}}
end
@doc false
defp apply_server_doc_edits(doc, [edit | edits]) do
case diff_patch.patch(doc, edit["diff"]) do
# Patch succesfull
{:ok, new_doc} ->
apply_server_doc_edits new_doc, edits
# Patch failed, throwing away (Chapter 3 list step f)
{:error, _reason} ->
apply_server_doc_edits doc, edits
end
end
defp apply_server_doc_edits(doc, []), do: {:ok, doc}
@doc false
defp apply_shadow_edits(server_shadow, backup_shadow, [edit | edits]) do
cond do
# Ideal, we are on the same page:
server_shadow.server_version == edit["serverVersion"] ->
do_apply_shadow_edits(server_shadow, backup_shadow, [edit | edits])
# Not so ideal, previous edits were lost but we still have a backup:
backup_shadow.server_version == edit["serverVersion"] ->
do_apply_shadow_edits(backup_shadow, backup_shadow, [edit | edits])
# Nope, we no longer have that server version you are talking about
true ->
{:error, %{
reason: :no_matching_server_version,
shadow_server_version: server_shadow.server_version,
backup_server_version: backup_shadow.server_version,
client_server_version: edit["serverVersion"],
edit: edit
}}
end
end
defp apply_shadow_edits(server_shadow, backup_shadow, []) do
{:ok, {server_shadow, backup_shadow}}
end
@doc false
defp do_apply_shadow_edits(server_shadow, backup_shadow, [edit | edits]) do
cond do
# Ideal, we are on the same page:
server_shadow.client_version == edit["localVersion"] ->
patch_shadow_edits server_shadow, backup_shadow, edit, edits
# Not ideal, but we already saw this client version. Throw away edit.
server_shadow.client_version > edit["localVersion"] ->
apply_shadow_edits server_shadow, backup_shadow, edits
true ->
{:error, %{
reason: :no_matching_client_version,
shadow_client_version: server_shadow.client_version,
client_client_version: edit["localVersion"],
edit: edit
}}
end
end
defp patch_shadow_edits(server_shadow, backup_shadow, edit, edits) do
case diff_patch.patch(server_shadow.doc, edit["diff"]) do
{:ok, new_doc} ->
server_shadow =
server_shadow
|> Map.put(:doc, new_doc)
|> Map.update!(:client_version, &(&1 + 1))
apply_shadow_edits server_shadow, backup_shadow, edits
{:error, reason} -> {:error, reason}
end
end
@doc false
def diff_patch, do: Application.get_env(:ex_sync, :diff_patch)
end