Skip to content
This repository was archived by the owner on Jan 29, 2019. It is now read-only.
Open
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
e49ceab
Introduce Firenest.PG
michalmuskala May 14, 2018
aac1383
Remove race condition from PG.multicall
michalmuskala May 14, 2018
32ea17f
Implement basic local tracking
michalmuskala May 15, 2018
87ebdc8
More basic functions for local tracker
michalmuskala May 16, 2018
46051ca
All local elements of PG work correctly
michalmuskala May 16, 2018
fb5b530
Fix start_supervised! only in 1.7
michalmuskala May 16, 2018
d5efd6e
formatter
michalmuskala May 16, 2018
6d9ff1a
Add specs to pg
michalmuskala May 16, 2018
e46a07f
Fix bug in SyncedServer where hello was not always sent
michalmuskala Jun 6, 2018
ab0bea3
Add handshake_data callback to SyncedServer
michalmuskala Jun 7, 2018
84dbf51
First implementation of remote data transfer for PG
michalmuskala Jun 11, 2018
c474be8
Remove key from PG
michalmuskala Jun 11, 2018
d9e2b61
Add up/down/up test to PG
michalmuskala Jun 12, 2018
53890e9
Rename PG to ReplicatedState
michalmuskala Jun 12, 2018
0421ca9
Docs for the replicated state server
michalmuskala Jun 12, 2018
ccd107c
Doc updates
michalmuskala Jun 12, 2018
2b333d9
Update docs for ReplicatedState
michalmuskala Jun 13, 2018
6e9b92c
Change how deleyed_update and delete work
michalmuskala Jun 13, 2018
5ff41b7
Local replicated state tests
michalmuskala Aug 7, 2018
94aa7cb
Initial delta returned again in init/1
michalmuskala Aug 8, 2018
cb87326
Move ReplicatedState.Server to its own file
michalmuskala Aug 8, 2018
935373a
wip
michalmuskala Oct 30, 2018
7461cd9
Extract handler code from ReplicatedState.Server
michalmuskala Nov 5, 2018
bfc0218
wip remote replicated state
michalmuskala Nov 5, 2018
0182baf
First remote implementation for replicatedstate
michalmuskala Nov 12, 2018
9352787
Properly handle broadcasts in remote server
michalmuskala Nov 12, 2018
055b900
Fix tests
michalmuskala Nov 12, 2018
b87a6ff
Formatter and remove old code
michalmuskala Nov 13, 2018
906e6d9
Add ReplicatedState.Store tests
michalmuskala Nov 14, 2018
451ef3e
Move remote changes to handler return
michalmuskala Nov 15, 2018
c75d98a
Poor man's bounded queue for last deltas in remote
michalmuskala Nov 15, 2018
61792d4
Some remote unit tests
michalmuskala Nov 16, 2018
3270773
Update replicated state docs
michalmuskala Nov 21, 2018
853e808
Refactor ReplicatedState.multicall
michalmuskala Nov 21, 2018
6741955
Address code review feedback
michalmuskala Nov 21, 2018
37f8df4
More tests for ReplicatedState.Remote
michalmuskala Nov 23, 2018
802bcf9
Distributed tests for ReplicatedState
michalmuskala Nov 23, 2018
445b771
Don't pass funs between processes
michalmuskala Nov 23, 2018
68710f6
Format & minor refactor
michalmuskala Nov 23, 2018
1c8ef25
Documentation typos
michalmuskala Nov 23, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
343 changes: 343 additions & 0 deletions lib/firenest/replicated_state.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,343 @@
defmodule Firenest.ReplicatedState do
@moduledoc """
Distributed key-value store for ephemeral data.

The key-value pairs are always attached to a lifetime of a
process and the state is replicated to all nodes across the
topology. When the attached process dies the state will be
removed on all nodes and in case the all the state from
disconnected nodes will be (temporarily) removed. The state
can only be attached to local processes.
Comment thread
michalmuskala marked this conversation as resolved.

The state is managed through callbacks that are invoked on the
node where the process lives and remotely on other nodes when local
changes are propagated.

The state is replicated incrementally through building local "deltas"
(changes to the state) and periodically replicating them remotely. Only
some amount of recent deltas are retained for "catching up" remote nodes.
If remote node is too far behind the current state and a delta-based
catch-up can't be performed, the server falls back to transferring the entire
local state. If keeping track of incremental changes is not convenient
for a particular state type, the value of a delta can be set to be equal
to the current state - this will always cause full state transfers.
"""

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would add a full working example to the docs or maybe even two (maps and rate limiter).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should it be in here or should we have an examples directory? And if we have them as examples - would it actually make sense to release them as components?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would have them inline for now, unless they become too big.

alias Firenest.SyncedServer

@type server() :: atom()
@type key() :: term()

@type local_delta() :: term()
@type remote_delta() :: term()
@type state() :: term()
@type callback_config() :: term()

@type extra_action :: :delete | {:update_after, update :: term(), time :: pos_integer()}

@type server_opt ::
{:remote_changes, :ignore | :observe_collapsed | :observe_full}

@doc """
Called when a partition starts up.

It returns:

* an `initial_delta` that will be passed to `c:local_put/3`
callback on new entries and to `c:local_update/4` after remote
broadcast resets the local delta.
* an immutable `callback_config` value that will be passed to all
callbacks.
* a list of server_opt` settings configuring the behaviour of the server
Comment thread
michalmuskala marked this conversation as resolved.

"""
@callback init(opts :: keyword()) ::
{initial_delta :: local_delta(), callback_config(), [server_opt]}

@doc """
Called whenever the `put/4` function is called to create a new state.

The `arg` is received from the corresponding `put/4` call. For the
explanation of the `extra_action` return values see the
`c:local_update/4` callback.

The value of `local_delta` argument is always the initial delta as
returned by the `init/1` callback.

This is a good place for broadcasting local state changes.
"""
@callback local_put(arg :: term(), local_delta(), callback_config()) ::
{local_delta(), initial_state} | {local_delta(), initial_state, extra_action()}
when initial_state: state()

@doc """
Called whenever the `update/4` function is called to update a state.

This is a good place for broadcasting local state changes.

The `local_delta` argument is either the accumulated delta from
`c:local_put/3` and `c:local_update/4` calls or the initial delta
from `c:init/1` after remote broadcast resets the local delta.

## Delayed update and delete

If the function returns a third element in the tuple consisting of
`{:update_after, update, time, update}`, a timer is started by the
server and the `c:local_update/4` callback will be called with the
`update` value after `time` milliseconds.

If the third element is `:delete`, the state will be immediately
deleted and the `c:local_delete/2` callback triggered.
"""
@callback local_update(update :: term(), local_delta(), state(), callback_config()) ::
{local_delta(), state()} | {local_delta(), state(), extra_action()}

@doc """
Called whenever attached process dies or the `delete/3` or `delete/2`
function is called and state is about to be deleted.

The return value is ignored.

This is a good place for broadcasting local state changes.
"""
@callback local_delete(state(), callback_config()) :: term()

@doc """
Called whenever the server is about to incrementally replicate local
state to a remote node.

It takes in the local delta value constructed in the `c:local_update/4`
calls and returns a remote delta value that will be replicated to other
servers. The value of the local delta is reset to the initial delta value

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm pretty sure I'm missing something obvious here 😅 Does it mean that local_delta argument will be the delta returned by local_update/4?

returned from the `c:local_join/2` callback.
Comment thread
michalmuskala marked this conversation as resolved.
Outdated

In case the callback is not provided it defaults to just returning local delta.
"""
@callback prepare_remote_delta(local_delta(), callback_config()) :: remote_delta()

@doc """
Called whenever a remote delta is received from another node.

The `remote_delta` value is the return value of the `c:prepare_remote_delta/2`
callback. The result of applying the remote delta to state must be
exactly the same as the result of applying local updates to the
state in the `c:local_update/3` callback.
"""
@callback handle_remote_delta(remote_delta(), state(), callback_config()) :: state()

@doc """
Called when remote changes are received by the local server.

It receives a list of observed remote changes for all the tracked keys.
A `process_state_change` specifies how the state for a single process changed.
In particular the change can be:

* `{initial_state, [:put, ...]}` in case the process just joined
* `{last_known_state, [..., :delete]}` in case the process just left
* `{last_known_state, [{:replace, state}, ...]}` in case it was not possible
to replicate incremental changes to remote state through deltas and a full
state transfer was performed.
* `{last_known_state, [..., {:delta, remote_delta}, ...]}` in case
incremental changes to the remote state were communicated.

This callback is optional and its behaviour depends on the value
of the `:remote_changes` option returned from the `c:init/2` callback.

* `:ignore` - the callback is not invoked and the server skips
all operations required for tracking the changes. This is the
default and should be chosen if precise state change tracking
is not required.

* `:observe_full` - calls the callback with as precise information
about the remote state changes as possible. This means that, for
example, for a very short-lived process a `[:put, :delete]` sequence
of changes is possible.

* `:observe_collapsed` - collapses the information about remote state
changes. For example a `[:put, {:delta, ...}]` sequence is collapsed
into just `[:put]` with all the state changes already applied and a
`[{:delta, ...}, :delete]` sequence is collapsed into just `[:delete]`.
This also means that for short-lived processes, no information
may be transferred if the changes would contain both `:put` and `:delete`.

This is a good place for broadcasting remote state changes.

The return value is ignored.
"""
@callback observe_remote_changes(observed_remote_changes, callback_config()) :: term()
when observed_remote_changes: [{key(), [process_state_change]}],
process_state_change: {current_state :: state(), [change]},
change:
:put | {:replace, new_state :: state()} | {:delta, remote_delta()} | :delete

@optional_callbacks [observe_remote_changes: 2, prepare_remote_delta: 2]

@doc """
Returns a child spec for the replicated state server.

Once started, each partition will call the `c:init/1` callback
passing all the provided options.

## Options

* `:name` - name for the process, required;
* `:topology` - name of the supporting topology, required;
* `:partitions` - number of partitions, defaults to 1;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PubSub defaults to "By default uses one partition for every 4 cores". Contention in PubSub is mostly ets so we don't need many partitions. My point is, we have precedent for not having a default of 1 in case we want to start with many. Although more partitions make it more chatty, so we may want to document that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went with one for now for compatibility with phoenix presence, but we could split it up more. I'm fine with whatever default.

* `:broadcast_timeout` - delay of broadcasting local events to other nodes,
defaults to 50 ms;
* `:remote_changes` - specifies behaviour of the `c:observe_remote_changes/2`
callback and can be `:observe_full | :observe_collapsed | :ignore`,
defaults to `:ignore`;
Comment thread
michalmuskala marked this conversation as resolved.
Outdated

"""
defdelegate child_spec(opts), to: Firenest.ReplicatedState.Supervisor

@doc """
Puts new state under `key` attached to lifetime of `pid`.

A special value `:partition` can be used for `pid` to indicate that the
lifetime of the value will be attached to the partition itself and
should be managed manually through `delete/3`, `delete/2` and `update/4`
calls with `:delete` returns from the `c:local_update/4` callback.

This calls the `c:local_put/3` callback with `arg` inside the server.
"""
@spec put(server(), key(), pid() | :partition, term()) :: :ok | {:error, :already_joined}
def put(server, key, pid, arg) when pid == :parittion or node(pid) == node() do
Comment thread
michalmuskala marked this conversation as resolved.
partition = partition_info!(server, key)
SyncedServer.call(partition, {:put, key, pid, arg})
end

@doc """
Deletes state under `key` attached to lifetime of `pid`.

For the explanation of `:partition` value for `pid` see `put/4`.

This calls the `c:local_delete/2` callback inside the server.
"""
@spec delete(server(), key(), pid() | :partition) :: :ok | {:error, :not_member}
def delete(server, key, pid) when pid == :partition or node(pid) == node() do
partition = partition_info!(server, key)
SyncedServer.call(partition, {:delete, key, pid})
end

@doc """
Deletes state under all keys attached to lifetime of `pid`.

This calls the `c:local_delete/2` callback inside the server for
each key the process is leaving.
"""
@spec delete(server(), pid()) :: :ok | {:error, :not_member}
def delete(server, pid) when node(pid) == node() do
partitions = partition_infos!(server)
replies = multicall(partitions, {:delete, pid}, 5_000)

if :ok in replies do
:ok
else
{:error, :not_member}
end
end

@doc """
Updates state under `key` attached to lifetime of `pid`.

For the explanation of `:partition` value for `pid` see `put/4`.

This calls the `c:local_update/4` callback inside the server passing
the value of `update`.
"""
@spec update(server(), key(), pid() | :partition, term()) :: :ok | {:error, :not_member}
def update(server, key, pid, update) when pid == :partition or node(pid) == node() do
partition = partition_info!(server, key)
SyncedServer.call(partition, {:update, key, pid, update})
end

@doc """
Lists all states present for a given `key`.
"""
@spec list(server(), key()) :: [state()]
def list(server, key) do
partition = partition_info!(server, key)
SyncedServer.call(partition, {:list, key}).()
end

# TODO
# def dirty_list(server, group)

defp multicall(servers, request, timeout) do
servers
|> Enum.map(fn server ->
pid = Process.whereis(server)
ref = Process.monitor(pid)
send(pid, {:"$gen_call", {self(), ref}, request})
ref
end)
|> Enum.map(fn ref ->
receive do
{^ref, reply} ->
Process.demonitor(ref, [:flush])
reply

{:DOWN, ^ref, _, _, reason} ->
exit(reason)
after
timeout ->
Comment thread
michalmuskala marked this conversation as resolved.
Outdated
Process.demonitor(ref, [:flush])
exit(:timeout)
end
end)
end

defp partition_info!(server, key) do
hash = :erlang.phash2(key)
extract = {:element, {:+, {:rem, {:const, hash}, :"$1"}, 1}, :"$2"}
ms = [{{:partitions, :"$1", :"$2"}, [], [extract]}]
Comment thread
michalmuskala marked this conversation as resolved.
[info] = :ets.select(server, ms)
info
catch
:error, :badarg ->
raise ArgumentError, "unknown key: #{inspect(server)}"
end

defp partition_infos!(server) do
Tuple.to_list(:ets.lookup_element(server, :partitions, 3))
catch
:error, :badarg ->
raise ArgumentError, "unknown group: #{inspect(server)}"
end
end

defmodule Firenest.ReplicatedState.Supervisor do
@moduledoc false
use Supervisor

def child_spec(opts) do
Comment thread
michalmuskala marked this conversation as resolved.
Outdated
partitions = Keyword.get(opts, :partitions, 1)
name = Keyword.fetch!(opts, :name)
topology = Keyword.fetch!(opts, :topology)
handler = Keyword.fetch!(opts, :handler)
supervisor = Module.concat(name, "Supervisor")
arg = {partitions, name, topology, handler, opts}

%{
id: __MODULE__,
start: {Supervisor, :start_link, [__MODULE__, arg, [name: supervisor]]},
type: :supervisor
}
end

def init({partitions, name, topology, handler, opts}) do
names =
for partition <- 0..(partitions - 1),
do: Module.concat(name, "Partition" <> Integer.to_string(partition))

children =
for name <- names,
do: {Firenest.ReplicatedState.Server, {name, topology, handler, opts}}

:ets.new(name, [:named_table, :set, read_concurrency: true])
:ets.insert(name, {:partitions, partitions, List.to_tuple(names)})

Supervisor.init(children, strategy: :one_for_one)
end
end
Loading