diff --git a/README.md b/README.md index 720d5e1..3cd93a4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,11 @@ # CRDT -**TODO: Add description** +This is a set of basic, composable and extensible CRDTs. + +A CRDT is defined as Conflict-free Replicated Data Type, +see https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type + +Please refer to the API Reference for usage documentation and examples. ## Installation @@ -19,3 +24,213 @@ Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_do and published on [HexDocs](https://hexdocs.pm). Once published, the docs can be found at . +## List of implemented CRDTs + +``` + +----------------++---------++-----------++------------++---------++----------------+ +Data Type | LWW-Register || AWORSet || G-Counter || PN-Counter || AWORMap || Delta-AWORSet | + +----------------++---------++-----------++------------++---------++----------------+ +CRDT Type | Commutative | Convergent | + +-----------------+-----------------------------------------------------------------+ +``` + +## Examples + +These are simple examples that should give an idea of how to use some of the data types, +and what to expect in each case. + +### `CRDT.GCounter` + +A `CRDT.GCounter` is a growth-only counter. It can be initialized with positive values on +behalf of actors. The resulting value will always be the sum of the values across actors. +An empty counter will have the value '0'. + + +#### Initializing + +``` elixir +counter = CRDT.GCounter.new +CRDT.value(counter) # => 0 + +counter = CRDT.GCounter.new(actor1: 5, actor2: 10) +CRDT.value(counter) # => 15 +``` + +#### Incrementing + +Incrementing a `CRDT.GCounter` is done via the `CRDT.GCounter.inc/2` function. +If the actor key does not exist yet, it is assumed that the given value is the starting +value. + +``` elixir +counter = CRDT.GCounter.new +counter = counter |> CRDT.GCounter.inc(:a, 5) # => %CRDT.GCounter{value: %{a: 5}} +counter = counter |> CRDT.GCounter.inc(:a, 2) # => %CRDT.GCounter{value: %{a: 7}} +CRDT.value(counter) # => 7 +``` + +#### Merging + +Merging two GCounters preserves all actors in both, taking the higher value if an actor exists in both GCounters. + +``` elixir +counter1 = CRDT.GCounter.new(actor1: 5, actor2: 3) +counter2 = CRDT.GCounter.new(actor2: 1, actor3: 8) +CRDT.merge(counter1, counter2) # => %CRDT.GCounter{value: %{actor1: 5, actor2: 3, actor3: 8}} +``` + +### `CRDT.PNCounter` + +A `CRDT.PNCounter` is used to process events that can increment or decrement the value. + +#### Initializing + +When initialized without starting values, the `CRDT.PNCounter` initial value is '0'. + +``` elixir +counter = CRDT.PNCounter.new #=> %CRDT.PNCounter{pos: %{}, neg: %{}} +CRDT.value(counter) #=> 0 +``` + +Initial values can be supplied as positive and negative actor => value maps. + +``` elixir +counter = CRDT.PNCounter.new(pos: %{a: 1, b: 2}, neg: %{a: 8, b: 7}) +CRDT.value(counter) #=> -12 +``` + +#### Incrementing +Incrementing a `CRDT.PNCounter` is done via the `CRDT.PNCounter.increment/3` function. +Incrementing a pncounter will update only the `pos` actor => value map. +If no value is given, it will be updated by 1 by default. + +``` elixir +pncounter = CRDT.PNCounter.new +pncounter = pncounter |> CRDT.PNCounter.increment(:a, 5) # => %CRDT.PNCounter{pos: %{a: 2}, neg: %{}} +pncounter = pncounter |> CRDT.PNCounter.increment(:a, 2) # => %CRDT.PNCounter{pos: %{a: 4}, neg: %{}} +CRDT.value(pncounter) # => 7 +``` + +#### Decrementing +Decrementing a `CRDT.PNCounter` is done via the `CRDT.PNCounter.decrement/3` function. +Incrementing a pncounter will update only the `neg` actor => value map. +If no value is given, it will be updated by 1 by default. + +``` elixir +pncounter = CRDT.PNCounter.new +pncounter = pncounter |> CRDT.PNCounter.decrement(:a, 5) # => %CRDT.PNCounter{pos: %{}, neg: %{a: 5}} +pncounter = pncounter |> CRDT.PNCounter.decrement(:a, 2) # => %CRDT.PNCounter{pos: %{}, neg: %{a: 7}} +CRDT.value(pncounter) # => -7 +``` + +#### Merging +Merging two PNCounters preserves all actors in both, taking the total sum of all positive and negative values. + +``` elixir +pncounter1 = CRDT.PNCounter.new |> CRDT.PNCounter.increment(actor1: 5, actor2: 3) +pncounter2 = CRDT.PNCounter.new |> CRDT.PNCounter.decrement(actor1: 5, actor3: 3) +merged = CRDT.merge(pncounter1, pncounter2) # => %CRDT.PNCounter{value: %{actor1: 5, actor2: 3, actor3: 8}} +CRDT.value(merged) # => 0 +``` + +### `CRDT.LWWRegister` + +A `CRDT.LWWRegister` is a crdt used when we are interested in having the most recent information available (Least Write Wins). +It contains a value and the corresponding timestamp. + +#### Initializing + +When initialized without starting values, the `CRDT.LWWRegister` initial value is 'nil'. + +``` elixir +register = CRDT.LWWRegister.new #=> %CRDT.LWWRegister{value: nil, timestamp: 1698400752943930708} +CRDT.value(counter) #=> nil +``` + +#### Updating + +Updating a `CRDT.LWWRegister` is done via the `CRDT.LWWRegister.set/2` function. +This will update the value with the current system time. + +``` elixir +register = CRDT.LWWRegister.new +register = register |> CRDT.LWWRegister.set("hello register") # => %CRDT.LWWRegister{value: "hello register", timestamp: 1700218890688914751} +CRDT.value(register) # => "hello register" +``` + +#### Merging + +Merging two LWWRegisters will simply take the most recent value. + +``` elixir +register1 = CRDT.LWWRegister.new |> CRDT.LWWRegister.set("hello") +register2 = CRDT.LWWRegister.new |> CRDT.LWWRegister.set("latest_hello") +merged = CRDT.merge(register1, register2) +CRDT.value(merged) # => "latest_hello" +``` + +### `CRDT.AWORMap` + +A `CRDT.AWORMap` is used to store events in a key => value map. The values stored in an AWORMap are crdts themselves. +Merging strategy follows those of the crdts contained in the map. + +#### Initializing +The value of a new map is an empty map. + +``` elixir +map = CRDT.AWORMap.new +CRDT.value(map) #=> %{} +``` +When initialized the `CRDT.AWORMap` has this structure: + +``` elixir +%CRDT.AWORMap{ + keys: %CRDT.AWORSet{ + dot_kernel: %CRDT.DotKernel{ + dot_context: %CRDT.DotContext{version_vector: %{}, dot_cloud: []}, + entries: %{} + } + }, + entries: %{} +} +``` + +Inside `CRDT.DotKernel` are the operations performed on the map: which actor made the change, the number of operation and the value added. +Inside the outer entries there is the map keylist with the current values. + +It's possible to put a crdt in the `CRDT.AWORMap` through the `CRDT.AWORMap.put/4` function. +It's necessary to specify the actor who make the change, the key under which the crdt will be stored and the crdt itself. + +``` elixir +map = CRDT.AWORMap.new +map = map |> CRDT.AWORMap.put(:a, :key, CRDT.GCounter.new()) +CRDT.value(map) #=> %{key: 0} +``` + +#### Updating + +Updating a `CRDT.AWORMap` is done via the `CRDT.AWORMap.update!/4` function. +It's possible to pass a function as an argument that will be applied as the updated value of `key` + +``` elixir +map = CRDT.AWORMap.new +map = map |> CRDT.AWORMap.put(:a, :key, CRDT.GCounter.new() |> CRDT.GCounter.inc(:a, 1)) +CRDT.value(map) #=> %{key: 1} +map = map |> CRDT.AWORMap.update!(:a, :key, &(CRDT.GCounter.inc(&1, :a, 100))) +CRDT.value(map) #=> %{key: 101} +``` + +##### Update vs Update! +- `CRDT.AWORMap.update!/4` if the given "key" is not present in the AWORMap, a `KeyError` exception is raised. +- `CRDT.AWORMap.update/5` if the given "key" is not present in the AWORMap, the default value is inserted as the crdt of `key` + +#### Merging + +Merging two AWORMaps uses the same merge strategies as their crdts stored inside them. + +``` elixir +map1 = CRDT.AWORMap.new |> CRDT.AWORMap.put(:a, :key, CRDT.GCounter.new() |> CRDT.GCounter.inc(:a, 1)) +map2 = CRDT.AWORMap.new |> CRDT.AWORMap.put(:a, :key2, CRDT.GCounter.new() |> CRDT.GCounter.inc(:a, 100)) +merged_map = CRDT.merge(map1, map2) +CRDT.value(merged_map) #=> %{key: 100} +``` diff --git a/lib/crdt.ex b/lib/crdt.ex index 94c9d9a..18b9d64 100644 --- a/lib/crdt.ex +++ b/lib/crdt.ex @@ -1,6 +1,7 @@ defprotocol CRDT do @moduledoc """ Protocol defining the interface for CRDTs. + """ @type actor :: term() diff --git a/lib/crdt/access.ex b/lib/crdt/access.ex index 1151584..a10ea5a 100644 --- a/lib/crdt/access.ex +++ b/lib/crdt/access.ex @@ -1,8 +1,22 @@ defprotocol CRDT.Access do + @moduledoc """ + This protocol defines the access methods for CRDTs. + """ + @doc """ + Returns the value at the given path. + """ @spec get_in(t(), nonempty_list(term())) :: term() def get_in(t, list) + + @doc """ + Adds the given value to the given path. + """ @spec put_in(t(), term(), nonempty_list(term()), CRDT.crdt()) :: CRDT.crdt() def put_in(t, actor, list, value) + + @doc """ + Updates the value at the given path. + """ @spec update_in(t(), term(), nonempty_list(term()), (term() -> term())) :: CRDT.crdt() def update_in(t, actor, list, fun) end diff --git a/lib/crdt/awor_map.ex b/lib/crdt/awor_map.ex index fe75d4c..908f1a5 100644 --- a/lib/crdt/awor_map.ex +++ b/lib/crdt/awor_map.ex @@ -1,5 +1,10 @@ defmodule CRDT.AWORMap do - @moduledoc false + @moduledoc """ + An add-wins observed-remove map (AWORMap) is a map of keys to CRDTs based on sets. + + An AWORMap is a map of keys to CRDTs. It is a CRDT itself and can be used to + implement other CRDTs. + """ @type t :: %__MODULE__{ keys: CRDT.AWORSet.t(), @@ -102,7 +107,7 @@ defmodule CRDT.AWORMap do def get(%__MODULE__{entries: entries}, key, default \\ nil), do: Map.get(entries, key, default) @doc """ - Updates the `key` in the AWORMap with the the given function to the crdt on behalf of `actor`. + Updates the `key` in the AWORMap with the given function to the crdt on behalf of `actor`. If `key` is present in AWORMap then the existing crdt is passed to `fun` and its result is used as the updated crdt of `key`. If `key` is diff --git a/lib/crdt/awor_set.ex b/lib/crdt/awor_set.ex index 7c81d5c..fcd17b3 100644 --- a/lib/crdt/awor_set.ex +++ b/lib/crdt/awor_set.ex @@ -1,5 +1,10 @@ defmodule CRDT.AWORSet do - @moduledoc false + @moduledoc """ + An add-wins observed-remove set (AWORSet) is a set that allows adding and + removing elements. + + It is a variant of a 2P-Set that uses a DotKernel to track the additions and removals. + """ @type actor :: term @type value :: term diff --git a/lib/crdt/delta_awor_set.ex b/lib/crdt/delta_awor_set.ex index 8e8da0f..47e6bd2 100644 --- a/lib/crdt/delta_awor_set.ex +++ b/lib/crdt/delta_awor_set.ex @@ -1,5 +1,12 @@ defmodule CRDT.DeltaAWORSet do - @moduledoc false + @moduledoc """ + A delta add-wins observed-remove set (DeltaAWORSet) is an opimized set that + allows adding and removing elements. + + It is a variant of a 2P-Set that uses a DotKernel to track the additions and removals. + The delta variant optimizes the merge operation by only merging the delta of the + two sets. + """ @type actor :: term @type value :: term diff --git a/lib/crdt/dot_context.ex b/lib/crdt/dot_context.ex index 86b96c4..f35da5e 100644 --- a/lib/crdt/dot_context.ex +++ b/lib/crdt/dot_context.ex @@ -1,5 +1,10 @@ defmodule CRDT.DotContext do - @moduledoc false + @moduledoc """ + A DotContext is a data structure that contains a version vector and a dot cloud. + + The version vector is a map from actors to the maximum version of a dot that has been added to + the dot context on behalf of that actor. + """ @type actor :: term @type version :: pos_integer diff --git a/lib/crdt/dot_kernel.ex b/lib/crdt/dot_kernel.ex index 1c04f08..7550fc3 100644 --- a/lib/crdt/dot_kernel.ex +++ b/lib/crdt/dot_kernel.ex @@ -1,5 +1,12 @@ defmodule CRDT.DotKernel do - @moduledoc false + @moduledoc """ + A DotKernel is a data structure that contains a DotContext and a map of dots to values. + + The DotContext is a data structure that contains a version vector and a dot cloud. + + The version vector is a map from actors to the maximum version of a dot that has been added to + the dot context on behalf of that actor. + """ @type actor :: term @type version :: pos_integer @@ -121,7 +128,7 @@ defmodule CRDT.DotKernel do for {dot, entry_value} <- entries, entry_value == value, reduce: entries do entries -> # The corresponding dot says in the dot context and acts as a tombstone. - # So we can avoid readding it when merging with an out of date replica. + # So we can avoid reading it when merging with an out of date replica. Map.delete(entries, dot) end diff --git a/lib/crdt/g_counter.ex b/lib/crdt/g_counter.ex index 7d2c24a..f606aab 100644 --- a/lib/crdt/g_counter.ex +++ b/lib/crdt/g_counter.ex @@ -47,33 +47,33 @@ defmodule CRDT.GCounter do def inc(%__MODULE__{value: value}, actor, amount \\ 1) do %__MODULE__{value: Map.update(value, actor, amount, &(&1 + amount))} end +end + +defimpl CRDT, for: CRDT.GCounter do + @doc """ + Returns the value of the counter. + + ## Examples - defimpl CRDT do - @doc """ - Returns the value of the counter. - - ## Examples - - iex> CRDT.GCounter.new() - ...> |> CRDT.GCounter.inc(:a, 1) - ...> |> CRDT.GCounter.inc(:b, 2) - ...> |> CRDT.value() - 3 - """ - def value(%CRDT.GCounter{value: value}) do - Map.values(value) |> Enum.sum() - end - - @doc """ - Merges two Growning-only counters. - - ## Examples - - iex> CRDT.merge(CRDT.GCounter.new(a: 1, b: 2), CRDT.GCounter.new(a: 2, c: 3)) - %CRDT.GCounter{value: %{a: 2, b: 2, c: 3}} - """ - def merge(%CRDT.GCounter{value: value1}, %CRDT.GCounter{value: value2}) do - %CRDT.GCounter{value: Map.merge(value1, value2, fn _k, v1, v2 -> max(v1, v2) end)} - end + iex> CRDT.GCounter.new() + ...> |> CRDT.GCounter.inc(:a, 1) + ...> |> CRDT.GCounter.inc(:b, 2) + ...> |> CRDT.value() + 3 + """ + def value(%CRDT.GCounter{value: value}) do + Map.values(value) |> Enum.sum() + end + + @doc """ + Merges two Growning-only counters. + + ## Examples + + iex> CRDT.merge(CRDT.GCounter.new(a: 1, b: 2), CRDT.GCounter.new(a: 2, c: 3)) + %CRDT.GCounter{value: %{a: 2, b: 2, c: 3}} + """ + def merge(%CRDT.GCounter{value: value1}, %CRDT.GCounter{value: value2}) do + %CRDT.GCounter{value: Map.merge(value1, value2, fn _k, v1, v2 -> max(v1, v2) end)} end end diff --git a/lib/crdt/lww_register.ex b/lib/crdt/lww_register.ex index 94644b1..e893de9 100644 --- a/lib/crdt/lww_register.ex +++ b/lib/crdt/lww_register.ex @@ -47,6 +47,9 @@ defmodule CRDT.LWWRegister do end defimpl CRDT, for: CRDT.LWWRegister do + @moduledoc """ + Implements the CRDT behaviour for the LWWRegister + """ @doc """ Returns the value of the register diff --git a/mix.exs b/mix.exs index d26ee53..98bdce6 100644 --- a/mix.exs +++ b/mix.exs @@ -7,7 +7,16 @@ defmodule CRDT.MixProject do version: "0.1.0", elixir: "~> 1.15", start_permanent: Mix.env() == :prod, - deps: deps() + deps: deps(), + name: "CRDT", + description: "CRDTs in Elixir", + source_url: "https://github.com/platogo/crdt", + homepage_url: "", + docs: [ + main: "readme", + extras: ["README.md"], + authors: ["Peter Krenn ", "Anton Bangratz "] + ] ] end @@ -22,7 +31,8 @@ defmodule CRDT.MixProject do defp deps do [ {:credo, "~> 1.7", only: [:dev], runtime: false}, - {:dialyxir, "~> 1.4", only: [:dev], runtime: false} + {:dialyxir, "~> 1.4", only: [:dev], runtime: false}, + {:ex_doc, "~> 0.27", only: :dev, runtime: false} # {:dep_from_hexpm, "~> 0.3.0"}, # {:dep_from_git, git: "https://github.com/elixir-lang/my_dep.git", tag: "0.1.0"} ] diff --git a/mix.lock b/mix.lock index b5fe0ca..9489b6b 100644 --- a/mix.lock +++ b/mix.lock @@ -2,7 +2,13 @@ "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, "credo": {:hex, :credo, "1.7.1", "6e26bbcc9e22eefbff7e43188e69924e78818e2fe6282487d0703652bc20fd62", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e9871c6095a4c0381c89b6aa98bc6260a8ba6addccf7f6a53da8849c748a58a2"}, "dialyxir": {:hex, :dialyxir, "1.4.1", "a22ed1e7bd3a3e3f197b68d806ef66acb61ee8f57b3ac85fc5d57354c5482a93", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "84b795d6d7796297cca5a3118444b80c7d94f7ce247d49886e7c291e1ae49801"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.37", "2ad73550e27c8946648b06905a57e4d454e4d7229c2dafa72a0348c99d8be5f7", [:mix], [], "hexpm", "6b19783f2802f039806f375610faa22da130b8edc21209d0bff47918bb48360e"}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, + "ex_doc": {:hex, :ex_doc, "0.30.9", "d691453495c47434c0f2052b08dd91cc32bc4e1a218f86884563448ee2502dd2", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "d7aaaf21e95dc5cddabf89063327e96867d00013963eadf2c6ad135506a8bc10"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, + "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, + "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, }