├── .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 |
--------------------------------------------------------------------------------