├── .gitignore ├── LICENSE.md ├── README.md ├── config └── config.exs ├── lib ├── logoot.ex └── logoot │ ├── agent.ex │ └── sequence.ex ├── mix.exs ├── mix.lock └── test ├── logoot ├── agent_test.exs └── sequence_test.exs ├── logoot_test.exs └── test_helper.exs /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc 12 | 13 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Jonathan Clem 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Logoot 2 | 3 | A JavaScript implementation of the 4 | [Logoot CRDT](https://hal.archives-ouvertes.fr/inria-00432368/document). There is 5 | a JavaScript companion library to this one at 6 | [usecanvas/logoot-js](https://github.com/usecanvas/logoot-js). 7 | 8 | **This is a work-in-progress and is not battle-tested.** 9 | 10 | ## Installation 11 | 12 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 13 | as: 14 | 15 | 1. Add `logoot` to your list of dependencies in `mix.exs`: 16 | 17 | def deps do 18 | [{:logoot, "~> 0.1.0"}] 19 | end 20 | 21 | 2. Ensure `logoot` is started before your application: 22 | 23 | def application do 24 | [applications: [:logoot]] 25 | end 26 | 27 | ## What is Logoot? 28 | 29 | Logoot is a 30 | [conflict-free replicated data type](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type) 31 | that can be used to represent a sequence of atoms. Atoms in a Logoot sequence 32 | might be chunks of content or even characters in a string of text. 33 | 34 | The key to Logoot is the way in which it generates identifiers for atoms. To 35 | understand this, here are a few definitions to start with: 36 | 37 | - `identifier` A pair `` where `p` is an integer and `s` is a globally 38 | unique site identifier. A "site" represents any independent copy of a given 39 | Logoot CRDT. One web client with independent editable views of the same 40 | sequence would be two separate sites on that client. 41 | - `position` A list of identifiers. 42 | - `atom identifier` A pair `` where `pos` is a position, and `c` is the 43 | value of a vector clock at a given site. The site maintains a vector clock 44 | that increases incrementally with each no atom identifier generated. 45 | - `sequence atom` A pair `` where `ident` is an atom identifier, and 46 | `v` is any arbitrary value. This could be a single text character or a block 47 | in a text editor. 48 | - `sequence` A list of sequence atoms. This might represent a document or a 49 | list of blocks in an editor. Every sequence implicitly has a minimum sequence 50 | atom and a maximum sequence atom. All other atoms in the sequence are created 51 | somewhere between these two. 52 | 53 | Because of how these atom identifiers are structured, they are *totally 54 | ordered*, as opposed to *causally ordered*. No identifier cares about the 55 | identifier before it once it's been created, and so **tombstones are not 56 | necessary**. 57 | 58 | ### Generating a Sequence Atom 59 | 60 | *For the purposes of readability, I'm going to represent sequences in this 61 | document in Elixir terms.* 62 | 63 | Let's start with the empty sequence before any user has made edits to it: 64 | 65 | ```elixir 66 | [ 67 | {{[{0, 0}], 0}, nil}, # Minimum sequence atom 68 | {{[{32767, 0}], 1}, nil} # Maximum sequence atom 69 | ] 70 | ``` 71 | 72 | *__Aside:__ Note that the integer in the maximum sequence atom's value is 73 | `32767`. This is chosen somewhat arbitrarily, but it is common for 74 | implementations to use the maximum unsigned 16-bit integer (and the original 75 | paper recommends it). One wouldn't want to choose an integer greater than any 76 | implementation's maximum safe integer value, and all implementations that 77 | communicate with one another must share this same maximum.* 78 | 79 | Now, a user at site `1` inserts the first line into their local copy of the 80 | document: 81 | 82 | ```elixir 83 | [ 84 | {{[{0, 0}], 0}, nil}, # Minimum sequence atom 85 | {{[{6589, 1}], 0}, "Hello, world!"}, 86 | {{[{32767, 0}], 1}, nil} # Maximum sequence atom 87 | ] 88 | ``` 89 | 90 | Because there is free space between the integer of the minimum sequence atom and 91 | the integer of the maximum sequence atom, Logoot chooses a random integer 92 | between the two (how this is chosen is somewhat arbitrary—it just must be 93 | between them) and ends up with the sequence identifier: 94 | 95 | ```elixir 96 | { 97 | [ 98 | { 99 | 6589, # Number between min/max 100 | 1 # Site identifier 101 | } 102 | ], 103 | 0 # Next value of site's vector clock 104 | } 105 | ``` 106 | 107 | As a result, the document is properly sequenced. Ordering of sequence atoms is 108 | done by iterating over their position list and comparing first the integer, and 109 | then the site identifier if the integer is equal. 110 | 111 | *Note that vector clock values are not compared. Vector clock values are used to 112 | ensure unique atom identifiers, not for ordering.* 113 | 114 | Let's look at a more complex example. Start with a document that looks like 115 | this: 116 | 117 | ```elixir 118 | [ 119 | {{[{0, 0}]}, nil}, # Minimum sequence atom 120 | {{[{1, 1}, {3, 2}], 5}, "Hello, world from site 2!"}, 121 | {{[{1, 1}, {5, 4}], 1}, "I came from site 4!"}, 122 | {{[{32767, 0}]}, nil} # Maximum sequence atom 123 | ] 124 | ``` 125 | 126 | Now, at site `3`, the user wants to insert a line between the two user-created 127 | lines in the above sequence. Logoot iterates over the pairs of identifiers in 128 | the "before" and "after" positions. Because the first identifier of each 129 | position is `{1, 1}`, Logoot can not insert an identifier directly between them, 130 | so it moves on to the next pair, `{3, 2}` and `{5, 4}`. Because site 3's 131 | site identifier is greater than site 2's, it can insert the identifier `{3, 3}` 132 | here and preserve ordering, since `{3, 2} < {3, 3} < {5, 4}`. 133 | 134 | The resulting sequence would be (assuming 3's vector clock is at `1`): 135 | 136 | ```elixir 137 | [ 138 | {{[{0, 0}]}, nil}, # Minimum sequence atom 139 | {{[{1, 1}, {3, 2}], 5}, "Hello, world from site 2!"}, 140 | {{[{1, 1}, {3, 3}], 1}, "Hello from site 3!"}, 141 | {{[{1, 1}, {5, 4}], 1}, "I came from site 4!"}, 142 | {{[{32767, 0}]}, nil} # Maximum sequence atom 143 | ] 144 | ``` 145 | 146 | Note that if this were actually site `1`, things would be different, because 147 | `{3, 2}` is not less than `{3, 1}`. Instead, Logoot generates a random integer 148 | between 3 and 5 (which is of course `4`), and our resulting identifier would be: 149 | 150 | ```elixir 151 | {[{1, 1}, {4, 1}], 1} 152 | ``` 153 | 154 | Hopefully this provides a good enough explanation of what Logoot is and why it 155 | may be an excellent option for a sequence CRDT. The 156 | [paper](https://hal.archives-ouvertes.fr/inria-00432368/document) presenting it 157 | is a relatively easy read, and you may also want to look at this project's 158 | [Logoot.Sequence module](https://github.com/usecanvas/logoot_ex/blob/master/lib/logoot/sequence.ex) 159 | and its [tests](https://github.com/usecanvas/logoot_ex/blob/master/test/logoot/sequence_test.exs). 160 | 161 | ## TODO 162 | 163 | - [ ] Make min and max implicit, do not force user to provide them. 164 | - [ ] Prevent deleting min and max atoms. 165 | - [x] Make idempotent insert atom function. 166 | - [x] Make idempotent delete atom function. 167 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | use Mix.Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure for your application as: 12 | # 13 | # config :logoot, key: :value 14 | # 15 | # And access this configuration in your application as: 16 | # 17 | # Application.get_env(:logoot, :key) 18 | # 19 | # Or configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | -------------------------------------------------------------------------------- /lib/logoot.ex: -------------------------------------------------------------------------------- 1 | defmodule Logoot do 2 | @moduledoc """ 3 | Logoot is a 4 | [conflict-free replicated data type](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type) 5 | that can be used to represent a sequence of atoms. Atoms in a Logoot sequence 6 | might be chunks of content or even characters in a string of text. 7 | """ 8 | end 9 | -------------------------------------------------------------------------------- /lib/logoot/agent.ex: -------------------------------------------------------------------------------- 1 | defmodule Logoot.Agent do 2 | @moduledoc """ 3 | A GenServer which is represents a site at which a Logoot sequence is stored. 4 | The site has a unique copy of the sequence, a unique ID, and a vector clock. 5 | """ 6 | 7 | use GenServer 8 | 9 | alias Logoot.Sequence 10 | 11 | defstruct id: "", clock: 0, sequence: Sequence.empty_sequence 12 | 13 | @type t :: %__MODULE__{id: String.t, 14 | clock: non_neg_integer, 15 | sequence: Sequence.t} 16 | 17 | # Client 18 | 19 | @doc """ 20 | Start an agent whose initial clock value will be 0. 21 | """ 22 | @spec start_link :: GenServer.on_start 23 | def start_link do 24 | GenServer.start_link(__MODULE__, %__MODULE__{id: gen_id}) 25 | end 26 | 27 | @doc """ 28 | Get the current state of the agent (ID and clock). 29 | """ 30 | @spec get_state(pid) :: t 31 | def get_state(pid), do: GenServer.call(pid, :get_state) 32 | 33 | @doc """ 34 | Increment the agent's clock by 1. 35 | """ 36 | @spec tick_clock(pid) :: t 37 | def tick_clock(pid), do: GenServer.call(pid, :tick_clock) 38 | 39 | @doc """ 40 | Insert an atom into the agent's sequence. 41 | """ 42 | @spec insert_atom(pid, Sequence.sequence_atom) :: 43 | {:ok, Sequence.t} | {:error, String.t} 44 | def insert_atom(pid, atom), do: GenServer.call(pid, {:insert_atom, atom}) 45 | 46 | @doc """ 47 | Delete an atom from the agent's sequence. 48 | """ 49 | @spec delete_atom(pid, Sequence.sequence_atom) :: Sequence.t 50 | def delete_atom(pid, atom), do: GenServer.call(pid, {:delete_atom, atom}) 51 | 52 | # Generate a unique agent ID. 53 | @spec gen_id :: String.t 54 | defp gen_id, do: UUID.uuid4(:hex) 55 | 56 | # Tick an agent's clock by 1. 57 | @spec do_tick_clock(t) :: t 58 | defp do_tick_clock(agent), do: Map.put(agent, :clock, agent.clock + 1) 59 | 60 | # Server 61 | 62 | def handle_call(:get_state, _from, agent) do 63 | {:reply, agent, agent} 64 | end 65 | 66 | def handle_call({:insert_atom, atom}, _from, agent) do 67 | case Sequence.insert_atom(agent.sequence, atom) do 68 | error = {:error, _} -> {:reply, error, agent} 69 | {:ok, sequence} -> 70 | {:reply, {:ok, sequence}, Map.put(agent, :sequence, sequence)} 71 | end 72 | end 73 | 74 | def handle_call({:delete_atom, atom}, _from, agent) do 75 | sequence = Sequence.delete_atom(agent.sequence, atom) 76 | agent = Map.put(agent, :sequence, sequence) 77 | {:reply, sequence, agent} 78 | end 79 | 80 | def handle_call(:tick_clock, _from, agent) do 81 | agent = do_tick_clock(agent) 82 | {:reply, agent, agent} 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /lib/logoot/sequence.ex: -------------------------------------------------------------------------------- 1 | defmodule Logoot.Sequence do 2 | @moduledoc """ 3 | A sequence of atoms identified by `Logoot.atom_ident`s. 4 | """ 5 | 6 | @max_pos 32767 7 | @abs_min_atom_ident {[{0, 0}], 0} 8 | @abs_max_atom_ident {[{@max_pos, 0}], 1} 9 | 10 | @typedoc """ 11 | The result of a comparison. 12 | """ 13 | @type comparison :: :gt | :lt | :eq 14 | 15 | @typedoc """ 16 | A tuple `{int, site}`where `int` is an integer and `site` is a site 17 | identifier. 18 | """ 19 | @type ident :: {0..32767, term} 20 | 21 | @typedoc """ 22 | A list of `ident`s. 23 | """ 24 | @type position :: [ident] 25 | 26 | @typedoc """ 27 | A tuple `{pos, v}` generated at site `s` where 28 | `^pos = [ident_1, ident_2, {int, ^s}]` is a position and `v` is the value of 29 | the vector clock of site `s`. 30 | """ 31 | @type atom_ident :: {position, non_neg_integer} 32 | 33 | @typedoc """ 34 | An item in a sequence represented by a tuple `{atom_ident, data}` where 35 | `atom_ident` is a `atom_ident` and `data` is any term. 36 | """ 37 | @type sequence_atom :: {atom_ident, term} 38 | 39 | @typedoc """ 40 | A sequence of `sequence_atoms` used to represent an ordered set. 41 | 42 | The first atom in a sequence will always be `@min_sequence_atom` and the last 43 | will always be `@max_sequence_atom`. 44 | 45 | [ 46 | {{[{0, 0}], 0}, nil}, 47 | {{[{1, 1}], 0}, "This is an example of a Logoot Sequence"}, 48 | {{[{1, 1}, {1, 5}], 23}, "How to find a place between 1 and 1"}, 49 | {{[{1, 3}], 2}, "This line was the third made on replica 3"}, 50 | {{[{32767, 0}], 1}, nil} 51 | ] 52 | """ 53 | @type t :: [sequence_atom] 54 | 55 | @typedoc """ 56 | A `sequence_atom` that represents the beginning of any `Logoot.Sequence.t`. 57 | """ 58 | @type abs_min_atom_ident :: {nonempty_list({0, 0}), 0} 59 | 60 | @typedoc """ 61 | A `sequence_atom` that represents the end of any `Logoot.Sequence.t`. 62 | """ 63 | @type abs_max_atom_ident :: {nonempty_list({32767, 0}), 1} 64 | 65 | @doc """ 66 | Get the minimum sequence atom. 67 | """ 68 | @spec min :: abs_min_atom_ident 69 | def min, do: @abs_min_atom_ident 70 | 71 | @doc """ 72 | Get the maximum sequence atom. 73 | """ 74 | @spec max :: abs_max_atom_ident 75 | def max, do: @abs_max_atom_ident 76 | 77 | @doc """ 78 | Compare two atom identifiers. 79 | 80 | Returns `:gt` if first is greater than second, `:lt` if it is less, and `:eq` 81 | if they are equal. 82 | """ 83 | @spec compare_atom_idents(atom_ident, atom_ident) :: comparison 84 | def compare_atom_idents(atom_ident_a, atom_ident_b) do 85 | compare_positions(elem(atom_ident_a, 0), elem(atom_ident_b, 0)) 86 | end 87 | 88 | @doc """ 89 | Delete the given atom from the sequence. 90 | """ 91 | @spec delete_atom(t, sequence_atom) :: t 92 | def delete_atom([atom | tail], atom), do: tail 93 | def delete_atom([head | tail], atom), do: [head | delete_atom(tail, atom)] 94 | def delete_atom([], _atom), do: [] 95 | 96 | @doc """ 97 | Get the empty sequence. 98 | """ 99 | @spec empty_sequence :: [{abs_min_atom_ident | abs_max_atom_ident, nil}] 100 | def empty_sequence, do: [{min, nil}, {max, nil}] 101 | 102 | @doc """ 103 | Insert a value into a sequence after the given atom identifier. 104 | 105 | Returns a tuple containing `{:ok, {new_atom, updated_sequence}}` or 106 | `{:error, message}`. 107 | """ 108 | @spec get_and_insert_after(t, atom_ident, term, Logoot.Agent.t) :: 109 | {:ok, {sequence_atom, t}} | {:error, String.t} 110 | def get_and_insert_after(sequence, prev_sibling_ident, value, agent) do 111 | prev_sibling_index = 112 | Enum.find_index(sequence, fn {atom_ident, _} -> 113 | atom_ident == prev_sibling_ident 114 | end) 115 | 116 | {next_sibling_ident, _} = Enum.at(sequence, prev_sibling_index + 1) 117 | 118 | case gen_atom_ident(agent, prev_sibling_ident, next_sibling_ident) do 119 | error = {:error, _} -> error 120 | {:ok, atom_ident} -> 121 | new_atom = {atom_ident, value} 122 | 123 | {:ok, 124 | {new_atom, List.insert_at(sequence, prev_sibling_index + 1, new_atom)}} 125 | end 126 | end 127 | 128 | @doc """ 129 | Insert the given atom into the sequence. 130 | """ 131 | @spec insert_atom(t, sequence_atom) :: {:ok, t} | {:error, String.t} 132 | def insert_atom(list = [prev | tail = [next | _]], atom) do 133 | {{prev_position, _}, _} = prev 134 | {{next_position, _}, _} = next 135 | {{position, _}, _} = atom 136 | 137 | case {compare_positions(position, prev_position), 138 | compare_positions(position, next_position)} do 139 | {:gt, :lt} -> 140 | {:ok, [prev | [atom | tail]]} 141 | {:gt, :gt} -> 142 | case insert_atom(tail, atom) do 143 | error = {:error, _} -> error 144 | {:ok, tail} -> {:ok, [prev | tail]} 145 | end 146 | {:lt, :gt} -> 147 | {:error, "Sequence out of order"} 148 | {:eq, _} -> 149 | {:ok, list} 150 | {_, :eq} -> 151 | {:ok, list} 152 | end 153 | end 154 | 155 | @doc """ 156 | Generate an atom identifier between `min` and `max`. 157 | """ 158 | @spec gen_atom_ident(Logoot.Agent.t, atom_ident, atom_ident) :: 159 | {:ok, atom_ident} | {:error, String.t} 160 | def gen_atom_ident(agent, min_atom_ident, max_atom_ident) do 161 | case gen_position(agent.id, 162 | elem(min_atom_ident, 0), 163 | elem(max_atom_ident, 0)) do 164 | error = {:error, _} -> error 165 | atom_ident -> {:ok, {atom_ident, agent.clock}} 166 | end 167 | end 168 | 169 | @doc """ 170 | Return only the values from the sequence. 171 | """ 172 | @spec get_values(t) :: [term] 173 | def get_values(sequence) do 174 | sequence 175 | |> Enum.slice(1..-2) 176 | |> Enum.map(&(elem(&1, 1))) 177 | end 178 | 179 | # Compare two positions. 180 | @spec compare_positions(position, position) :: comparison 181 | defp compare_positions([], []), do: :eq 182 | defp compare_positions(_, []), do: :gt 183 | defp compare_positions([], _), do: :lt 184 | 185 | defp compare_positions([head_a | tail_a], [head_b | tail_b]) do 186 | case compare_idents(head_a, head_b) do 187 | :gt -> :gt 188 | :lt -> :lt 189 | :eq -> compare_positions(tail_a, tail_b) 190 | end 191 | end 192 | 193 | # Generate a position from an agent ID, min, and max 194 | @spec gen_position(String.t, position, position) :: 195 | nonempty_list(ident) | {:error, String.t} 196 | defp gen_position(agent_id, min_position, max_position) do 197 | {min_head, min_tail} = get_logical_head_tail(min_position, :min) 198 | {max_head, max_tail} = get_logical_head_tail(max_position, :max) 199 | 200 | {min_int, min_id} = min_head 201 | {max_int, _max_id} = max_head 202 | 203 | case compare_idents(min_head, max_head) do 204 | :lt -> 205 | case max_int - min_int do 206 | diff when diff > 1 -> 207 | [{random_int_between(min_int, max_int), agent_id}] 208 | diff when diff == 1 and agent_id > min_id -> 209 | [{min_int, agent_id}] 210 | _diff -> 211 | [min_head | gen_position(agent_id, min_tail, max_tail)] 212 | end 213 | :eq -> 214 | [min_head | gen_position(agent_id, min_tail, max_tail)] 215 | :gt -> 216 | {:error, "Max atom was lesser than min atom"} 217 | end 218 | end 219 | 220 | # Get the logical min or max head and tail. 221 | @spec get_logical_head_tail(position, :min | :max) :: {ident, position} 222 | defp get_logical_head_tail([], :min), do: {Enum.at(elem(min, 0), 0), []} 223 | defp get_logical_head_tail([], :max), do: {Enum.at(elem(max, 0), 0), []} 224 | defp get_logical_head_tail(position, _), do: {hd(position), tl(position)} 225 | 226 | # Generate a random int between two ints. 227 | @spec random_int_between(0..32767, 1..32767) :: 1..32766 228 | defp random_int_between(min, max) do 229 | :rand.uniform(max - min - 1) + min 230 | end 231 | 232 | # Compare two `ident`s, returning `:gt` if first is greater than second, 233 | # `:lt` if first is less than second, `:eq` if equal. 234 | @spec compare_idents(ident, ident) :: comparison 235 | defp compare_idents({int_a, _}, {int_b, _}) when int_a > int_b, do: :gt 236 | defp compare_idents({int_a, _}, {int_b, _}) when int_a < int_b, do: :lt 237 | defp compare_idents({_, site_a}, {_, site_b}) when site_a > site_b, do: :gt 238 | defp compare_idents({_, site_a}, {_, site_b}) when site_a < site_b, do: :lt 239 | defp compare_idents(_, _), do: :eq 240 | end 241 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Logoot.Mixfile do 2 | use Mix.Project 3 | 4 | @version "2.0.0" 5 | 6 | def project do 7 | [app: :logoot, 8 | description: "An implementation of the Logoot CRDT", 9 | package: package, 10 | docs: docs, 11 | source_url: "https://github.com/usecanvas/logoot_ex", 12 | homepage_url: "https://github.com/usecanvas/logoot_ex", 13 | version: @version, 14 | elixir: "~> 1.3", 15 | build_embedded: Mix.env == :prod, 16 | start_permanent: Mix.env == :prod, 17 | dialyzer: [plt_add_deps: true], 18 | deps: deps()] 19 | end 20 | 21 | # Configuration for the OTP application 22 | # 23 | # Type "mix help compile.app" for more information 24 | def application do 25 | [applications: [:logger, :uuid]] 26 | end 27 | 28 | # Dependencies can be Hex packages: 29 | # 30 | # {:mydep, "~> 0.3.0"} 31 | # 32 | # Or git/path repositories: 33 | # 34 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 35 | # 36 | # Type "mix help deps" for more examples and options 37 | defp deps do 38 | [{:uuid, "~> 1.1"}, 39 | {:dialyxir, "~> 0.3.5", only: [:dev]}, 40 | {:ex_doc, "> 0.0.0", only: [:dev]}] 41 | end 42 | 43 | defp package do 44 | [maintainers: ["Jonathan Clem "], 45 | licenses: ["MIT"], 46 | links: %{GitHub: "https://github.com/usecanvas/logoot_ex"}, 47 | files: ~w(lib mix.exs LICENSE.md README.md)] 48 | end 49 | 50 | defp docs do 51 | [source_ref: "v#{@version}", 52 | main: "readme", 53 | extras: ~w(README.md LICENSE.md)] 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{"dialyxir": {:hex, :dialyxir, "0.3.5", "eaba092549e044c76f83165978979f60110dc58dd5b92fd952bf2312f64e9b14", [:mix], []}, 2 | "earmark": {:hex, :earmark, "1.0.1", "2c2cd903bfdc3de3f189bd9a8d4569a075b88a8981ded9a0d95672f6e2b63141", [:mix], []}, 3 | "ex_doc": {:hex, :ex_doc, "0.13.0", "aa2f8fe4c6136a2f7cfc0a7e06805f82530e91df00e2bff4b4362002b43ada65", [:mix], [{:earmark, "~> 1.0", [hex: :earmark, optional: false]}]}, 4 | "uuid": {:hex, :uuid, "1.1.4", "36c7734e4c8e357f2f67ba57fb61799d60c20a7f817b104896cca64b857e3686", [:mix], []}} 5 | -------------------------------------------------------------------------------- /test/logoot/agent_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Logoot.AgentTest do 2 | use ExUnit.Case, async: true 3 | doctest Logoot.Agent 4 | 5 | alias Logoot.Sequence 6 | 7 | setup do 8 | {:ok, agent} = Logoot.Agent.start_link 9 | {:ok, agent: agent} 10 | end 11 | 12 | test ".get_state gets the state of the agent", %{agent: agent} do 13 | state = Logoot.Agent.get_state(agent) 14 | assert state.clock == 0 15 | assert is_binary(state.id) 16 | end 17 | 18 | test ".delete_atom deletes an atom from the agent", %{agent: agent} do 19 | agent_state = Logoot.Agent.tick_clock(agent) 20 | {:ok, atom_ident} = 21 | Sequence.gen_atom_ident(agent_state, Sequence.min, Sequence.max) 22 | atom = {atom_ident, "Hello, World"} 23 | 24 | {:ok, _sequence} = 25 | Logoot.Agent.insert_atom(agent, atom) 26 | Logoot.Agent.delete_atom(agent, atom) 27 | state = Logoot.Agent.get_state(agent) 28 | 29 | assert state.sequence == Sequence.empty_sequence 30 | end 31 | 32 | test ".insert_atom inserts the atom into the agent", %{agent: agent} do 33 | agent_state = Logoot.Agent.tick_clock(agent) 34 | {:ok, atom_ident} = 35 | Sequence.gen_atom_ident(agent_state, Sequence.min, Sequence.max) 36 | atom = {atom_ident, "Hello, World"} 37 | 38 | {:ok, sequence} = 39 | Logoot.Agent.insert_atom(agent, atom) 40 | 41 | assert sequence == [{Sequence.min, nil}, atom, {Sequence.max, nil}] 42 | end 43 | 44 | test ".insert_atom survives basic fuzzing" do 45 | {:ok, agent_a_pid} = Logoot.Agent.start_link 46 | {:ok, agent_b_pid} = Logoot.Agent.start_link 47 | 48 | for _ <- 1..100 do 49 | {:ok, _} = 50 | agent_a_pid 51 | |> Logoot.Agent.insert_atom(gen_rand_atom(agent_a_pid)) 52 | {:ok, _} = 53 | agent_b_pid 54 | |> Logoot.Agent.insert_atom(gen_rand_atom(agent_b_pid)) 55 | end 56 | 57 | agent_a = Logoot.Agent.get_state(agent_a_pid) 58 | agent_b = Logoot.Agent.get_state(agent_b_pid) 59 | 60 | agent_a_sequence = 61 | agent_a.sequence 62 | |> Enum.map(&({agent_b_pid, &1})) 63 | 64 | agent_b_sequence = 65 | agent_b.sequence 66 | |> Enum.map(&({agent_a_pid, &1})) 67 | 68 | (agent_a_sequence ++ agent_b_sequence) 69 | |> Enum.shuffle 70 | |> Enum.each(fn {pid, atom} -> 71 | Logoot.Agent.insert_atom(pid, atom) 72 | end) 73 | 74 | agent_a = Logoot.Agent.get_state(agent_a_pid) 75 | agent_b = Logoot.Agent.get_state(agent_b_pid) 76 | 77 | assert agent_a.sequence == agent_b.sequence 78 | end 79 | 80 | test ".tick_clock increments agent's clock by 1", %{agent: agent} do 81 | %{clock: initial_clock_value} = Logoot.Agent.get_state(agent) 82 | %{clock: ticked_clock_value} = Logoot.Agent.tick_clock(agent) 83 | assert ticked_clock_value == initial_clock_value + 1 84 | end 85 | 86 | defp gen_rand_atom(agent_pid) do 87 | %{sequence: sequence} = Logoot.Agent.get_state(agent_pid) 88 | prev_atom_index = :rand.uniform(length(sequence) - 1) - 1 89 | prev_atom = Enum.at(sequence, prev_atom_index) 90 | next_atom = Enum.at(sequence, prev_atom_index + 1) 91 | 92 | agent_state = Logoot.Agent.tick_clock(agent_pid) 93 | {:ok, atom_ident} = Sequence.gen_atom_ident( 94 | agent_state, elem(prev_atom, 0), elem(next_atom, 0)) 95 | {atom_ident, UUID.uuid4(:hex)} 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /test/logoot/sequence_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Logoot.SequenceTest do 2 | use ExUnit.Case 3 | 4 | alias Logoot.Sequence 5 | 6 | setup do 7 | {:ok, agent} = Logoot.Agent.start_link 8 | state = Logoot.Agent.get_state(agent) 9 | {:ok, state: state} 10 | end 11 | 12 | describe ".compare_atom_ident" do 13 | # Note that vector clock values are different in these comparisons. This is 14 | # allowed, because it is not possible for the same site to generate the 15 | # same line. 16 | test "is equal when they are empty" do 17 | assert( 18 | Sequence.compare_atom_idents({[], 1}, {[], 2}) == :eq) 19 | end 20 | 21 | test "is equal when they are identical" do 22 | comparison = 23 | Sequence.compare_atom_idents( 24 | {[{1, 3}, {1, 4}], 0}, 25 | {[{1, 3}, {1, 4}], 29}) 26 | assert comparison == :eq 27 | end 28 | 29 | test "is greater-than when they are of equal length with a greater-than" do 30 | comparison = 31 | Sequence.compare_atom_idents( 32 | {[{1, 3}, {1, 5}], 0}, 33 | {[{1, 3}, {1, 4}], 29}) 34 | assert comparison == :gt 35 | end 36 | 37 | test "is greater-than when they are of unequal length with a greater-than" do 38 | comparison = 39 | Sequence.compare_atom_idents( 40 | {[{1, 3}, {1, 4}, {1, 2}], 0}, 41 | {[{1, 3}, {1, 4}], 29}) 42 | assert comparison == :gt 43 | end 44 | 45 | test "is less-than when they are of equal length with a less-than" do 46 | comparison = 47 | Sequence.compare_atom_idents( 48 | {[{1, 3}, {1, 4}], 0}, 49 | {[{1, 3}, {1, 5}], 29}) 50 | assert comparison == :lt 51 | end 52 | 53 | test "is less-than when they are of unequal length with a less-than" do 54 | comparison = 55 | Sequence.compare_atom_idents( 56 | {[{1, 3}, {1, 4}], 0}, 57 | {[{1, 3}, {1, 4}, {1, 2}], 29}) 58 | assert comparison == :lt 59 | end 60 | end 61 | 62 | describe ".get_atom_ident" do 63 | test "returns a valid atom_ident between abs min and max", %{state: state} do 64 | {:ok, atom_ident} = 65 | Sequence.gen_atom_ident(state, Sequence.min, Sequence.max) 66 | assert( 67 | Sequence.compare_atom_idents(atom_ident, Sequence.min) == :gt) 68 | assert( 69 | Sequence.compare_atom_idents(atom_ident, Sequence.max) == :lt) 70 | end 71 | 72 | test "returns a valid atom_ident between min and max", %{state: state} do 73 | min = {[{1, 1}, {1, 3}, {1, 4}], 39} 74 | max = {[{1, 1}, {1, 3}, {1, 4}, {3, 5}], 542} 75 | {:ok, atom_ident} = 76 | Sequence.gen_atom_ident(state, min, max) 77 | assert(Sequence.compare_atom_idents(atom_ident, min) == :gt) 78 | assert(Sequence.compare_atom_idents(atom_ident, max) == :lt) 79 | end 80 | end 81 | 82 | describe ".get_and_insert_after" do 83 | test "inserts data after the given atom identifier", %{state: state} do 84 | sequence = Sequence.empty_sequence 85 | 86 | {:ok, {atom, sequence}} = 87 | sequence 88 | |> Sequence.get_and_insert_after(Sequence.min, "Hello, World!", state) 89 | 90 | assert sequence == [{Sequence.min, nil}, atom, {Sequence.max, nil}] 91 | end 92 | end 93 | 94 | describe ".insert_atom" do 95 | test "inserts an atom into its proper position", %{state: state} do 96 | min = Sequence.min 97 | max = Sequence.max 98 | mid = {elem(Sequence.gen_atom_ident(state, min, max), 1), "Foo"} 99 | sequence = [{min, nil}, mid, {max, nil}] 100 | {:ok, atom_ident} = Sequence.gen_atom_ident(state, elem(mid, 0), max) 101 | atom = {atom_ident, "Bar"} 102 | {:ok, sequence} = 103 | Sequence.insert_atom(sequence, atom) 104 | assert sequence == [{min, nil}, mid, atom, {max, nil}] 105 | end 106 | 107 | test "inserts idempotently", %{state: state} do 108 | min = Sequence.min 109 | max = Sequence.max 110 | sequence = Sequence.empty_sequence 111 | {:ok, atom_ident} = Sequence.gen_atom_ident(state, min, max) 112 | atom = {atom_ident, "Bar"} 113 | {:ok, sequence} = 114 | Sequence.insert_atom(sequence, atom) 115 | {:ok, sequence} = 116 | Sequence.insert_atom(sequence, atom) 117 | assert sequence == [{min, nil}, atom, {max, nil}] 118 | end 119 | end 120 | 121 | describe ".delete_atom" do 122 | test "is idempotent", %{state: state} do 123 | sequence = Sequence.empty_sequence 124 | 125 | {:ok, {atom, sequence}} = 126 | sequence 127 | |> Sequence.get_and_insert_after(Sequence.min, "Hello, World!", state) 128 | 129 | sequence = 130 | sequence 131 | |> Sequence.delete_atom(atom) 132 | |> Sequence.delete_atom(atom) 133 | 134 | assert sequence == [{Sequence.min, nil}, {Sequence.max, nil}] 135 | end 136 | 137 | test "deletes the given atom", %{state: state} do 138 | sequence = Sequence.empty_sequence 139 | 140 | {:ok, {atom, sequence}} = 141 | sequence 142 | |> Sequence.get_and_insert_after(Sequence.min, "Hello, World!", state) 143 | 144 | sequence = sequence |> Sequence.delete_atom(atom) 145 | assert sequence == [{Sequence.min, nil}, {Sequence.max, nil}] 146 | end 147 | end 148 | 149 | test ".values gets the values without min and max", %{state: state} do 150 | min = Sequence.min 151 | max = Sequence.max 152 | foo = {elem(Sequence.gen_atom_ident(state, min, max), 1), "Foo"} 153 | bar = {elem(Sequence.gen_atom_ident(state, elem(foo, 0), max), 1), "Bar"} 154 | 155 | values = 156 | Sequence.empty_sequence 157 | |> Sequence.insert_atom(foo) 158 | |> elem(1) 159 | |> Sequence.insert_atom(bar) 160 | |> elem(1) 161 | |> Sequence.get_values 162 | 163 | assert values == ["Foo", "Bar"] 164 | end 165 | end 166 | -------------------------------------------------------------------------------- /test/logoot_test.exs: -------------------------------------------------------------------------------- 1 | defmodule LogootTest do 2 | use ExUnit.Case 3 | doctest Logoot 4 | end 5 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------