This commit is contained in:
2026-07-14 22:50:46 +04:00
parent 27143319e3
commit bd19e0682b
3116 changed files with 467189 additions and 0 deletions

Binary file not shown.

View File

@@ -0,0 +1,45 @@
# Changelog
This new version of Phoenix.PubSub provides a simpler, more extensible, and more performant Phoenix.PubSub API. For users of Phoenix.PubSub, the API is the same, although frameworks and other adapters will have to migrate accordingly (which often means less code).
## 2.2.0 (2025-10-22)
### Enhancements
- Allow the registry size to be set separate from pool size
- Introduce `:broadcast_pool_size` option to allow safe pool size migration
### Bug fixes
- Only restart shards if they terminate unexpectedly
## 2.1.4 (2024-09-27)
### Enhancements
- Add `:permdown_on_shutdown` option
## 2.1.3 (2023-06-14)
### Bug fixes
- Fix memory leak introduced in 2.1.2
## 2.1.2 (2023-05-24)
### Bug fixes
- Fix race condition on tracker update allowing state to become out of sync
## 2.1.1 (2022-04-05)
### Enhancements
- Support compatibility with 2.0 nodes when pool_size is 1
## 2.1.0 (2022-04-01)
### Enhancements
- Support `handle_info` callback on `Phoenix.Tracker`
## 2.0.0 (2020-04-14)
### Enhancements
- Use erlang's new `:pg` module if available instead of `:pg2`
### Backwards incompatible changes
- Frameworks and other adapters will require the use of the new child_spec API

View File

@@ -0,0 +1,22 @@
# MIT License
Copyright (c) 2014 Chris McCord
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,56 @@
# Phoenix.PubSub
> Distributed PubSub and Presence platform for the Phoenix Framework
[![Build Status](https://github.com/phoenixframework/phoenix_pubsub/actions/workflows/ci.yml/badge.svg)](https://github.com/phoenixframework/phoenix_pubsub/actions/workflows/ci.yml)
## Usage
Add `phoenix_pubsub` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[{:phoenix_pubsub, "~> 2.0"}]
end
```
Then start your PubSub instance:
```elixir
defmodule MyApp do
use Application
def start(_type, _args) do
children = [
{Phoenix.PubSub, name: MyApp.PubSub}
]
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
Supervisor.start_link(children, opts)
end
end
```
Now broadcast and subscribe:
```elixir
Phoenix.PubSub.subscribe(MyApp.PubSub, "user:123")
Phoenix.PubSub.broadcast(MyApp.PubSub, "user:123", :hello_world)
```
## Testing
Testing by default spawns nodes internally for distributed tests.
To run tests that do not require clustering, exclude the `clustered` tag:
```shell
$ mix test --exclude clustered
```
If you have issues running the clustered tests try running:
```shell
$ epmd -daemon
```
before running the tests.

View File

@@ -0,0 +1,22 @@
{<<"links">>,
[{<<"github">>,<<"https://github.com/phoenixframework/phoenix_pubsub">>}]}.
{<<"name">>,<<"phoenix_pubsub">>}.
{<<"version">>,<<"2.2.0">>}.
{<<"description">>,<<"Distributed PubSub and Presence platform">>}.
{<<"elixir">>,<<"~> 1.6">>}.
{<<"files">>,
[<<"lib">>,<<"lib/phoenix">>,<<"lib/phoenix/tracker">>,
<<"lib/phoenix/tracker/clock.ex">>,<<"lib/phoenix/tracker/replica.ex">>,
<<"lib/phoenix/tracker/shutdown_handler.ex">>,
<<"lib/phoenix/tracker/delta_generation.ex">>,
<<"lib/phoenix/tracker/state.ex">>,<<"lib/phoenix/tracker/shard.ex">>,
<<"lib/phoenix/pubsub">>,<<"lib/phoenix/pubsub/adapter.ex">>,
<<"lib/phoenix/pubsub/supervisor.ex">>,
<<"lib/phoenix/pubsub/application.ex">>,<<"lib/phoenix/pubsub/pg2.ex">>,
<<"lib/phoenix/tracker.ex">>,<<"lib/phoenix/pubsub.ex">>,<<"test/shared">>,
<<"test/shared/pubsub_test.exs">>,<<"CHANGELOG.md">>,<<"LICENSE.md">>,
<<"mix.exs">>,<<"README.md">>]}.
{<<"app">>,<<"phoenix_pubsub">>}.
{<<"licenses">>,[<<"MIT">>]}.
{<<"requirements">>,[]}.
{<<"build_tools">>,[<<"mix">>]}.

View File

@@ -0,0 +1,385 @@
defmodule Phoenix.PubSub do
@moduledoc """
Realtime Publisher/Subscriber service.
## Getting started
You start Phoenix.PubSub directly in your supervision
tree:
{Phoenix.PubSub, name: :my_pubsub}
You can now use the functions in this module to subscribe
and broadcast messages:
iex> alias Phoenix.PubSub
iex> PubSub.subscribe(:my_pubsub, "user:123")
:ok
iex> Process.info(self(), :messages)
{:messages, []}
iex> PubSub.broadcast(:my_pubsub, "user:123", {:user_update, %{id: 123, name: "Shane"}})
:ok
iex> Process.info(self(), :messages)
{:messages, [{:user_update, %{id: 123, name: "Shane"}}]}
## Adapters
Phoenix PubSub was designed to be flexible and support
multiple backends. There are two officially supported
backends:
* `Phoenix.PubSub.PG2` - the default adapter that ships
as part of Phoenix.PubSub. It uses Distributed Elixir,
directly exchanging notifications between servers.
It supports a `:pool_size` option to be given alongside
the name, defaults to `1`. Note the `:pool_size` must
be the same throughout the cluster, therefore don't
configure the pool size based on `System.schedulers_online/0`,
especially if you are using machines with different specs.
* `Phoenix.PubSub.Redis` - uses Redis to exchange data between
servers. It requires the `:phoenix_pubsub_redis` dependency.
See `Phoenix.PubSub.Adapter` to implement a custom adapter.
## Custom dispatching
Phoenix.PubSub allows developers to perform custom dispatching
by passing a `dispatcher` module which is responsible for local
message deliveries.
The dispatcher must be available on all nodes running the PubSub
system. The `dispatch/3` function of the given module will be
invoked with the subscriptions entries, the broadcaster identifier
(either a pid or `:none`), and the message to broadcast.
You may want to use the dispatcher to perform special delivery for
certain subscriptions. This can be done by passing the :metadata
option during subscriptions. For instance, Phoenix Channels use a
custom `value` to provide "fastlaning", allowing messages broadcast
to thousands or even millions of users to be encoded once and written
directly to sockets instead of being encoded per channel.
## Safe pool size migration (when using `Phoenix.PubSub.PG2` adapter)
When you need to change the pool size in a running cluster,
you can use the `broadcast_pool_size` option to ensure no
messages are lost during deployment. This is particularly
important when increasing the pool size.
Here's how to safely increase the pool size from 1 to 2:
1. Initial state - Current configuration with `pool_size: 1`:
```
{Phoenix.PubSub, name: :my_pubsub, pool_size: 1}
```
```mermaid
graph TD
subgraph "Initial State"
subgraph "Node 1"
A1[Shard 1<br/>Broadcast & Receive]
end
subgraph "Node 2"
B1[Shard 1<br/>Broadcast & Receive]
end
A1 <--> B1
end
```
2. First deployment - Set the new pool size but keep broadcasting on the old size:
```
{Phoenix.PubSub, name: :my_pubsub, pool_size: 2, broadcast_pool_size: 1}
```
```mermaid
graph TD
subgraph "First Deployment"
subgraph "Node 1"
A1[Shard 1<br/>Broadcast & Receive]
A2[Shard 2<br/>Broadcast & Receive]
end
subgraph "Node 2"
B1[Shard 1<br/>Broadcast & Receive]
B2[Shard 2<br/>Receive Only]
end
A1 <--> B1
A2 --> B2
end
```
3. Final deployment - All nodes running with new pool size:
```
{Phoenix.PubSub, name: :my_pubsub, pool_size: 2}
```
```mermaid
graph TD
subgraph "Final State"
subgraph "Node 1"
A1[Shard 1<br/>Broadcast & Receive]
A2[Shard 2<br/>Broadcast & Receive]
end
subgraph "Node 2"
B1[Shard 1<br/>Broadcast & Receive]
B2[Shard 2<br/>Broadcast & Receive]
end
A1 <--> B1
A2 <--> B2
end
```
This two-step process ensures that:
- All nodes can receive messages from both old and new pool sizes
- No messages are lost during the transition
- The cluster remains fully functional throughout the deployment
To decrease the pool size, follow the same process in reverse order.
"""
@type node_name :: atom | binary
@type t :: atom
@type topic :: binary
@type message :: term
@type dispatcher :: module
defmodule BroadcastError do
defexception [:message]
def exception(msg) do
%BroadcastError{message: "broadcast failed with #{inspect(msg)}"}
end
end
@doc """
Returns a child specification for pubsub with the given `options`.
The `:name` is required as part of `options`. The remaining options
are described below.
## Options
* `:name` - the name of the pubsub to be started
* `:adapter` - the adapter to use (defaults to `Phoenix.PubSub.PG2`)
* `:pool_size` - number of pubsub partitions to launch
(defaults to one partition for every 4 cores)
* `:registry_size` - number of `Registry` partitions to launch
(defaults to `:pool_size`). This controls the number of Registry partitions
used for storing subscriptions and can be tuned independently from `:pool_size`
for better performance characteristics.
* `:broadcast_pool_size` - number of pubsub partitions used for broadcasting messages
(defaults to `:pool_size`). This option is used during pool size migrations to ensure
no messages are lost. See the "Safe Pool Size Migration" section in the module documentation.
"""
@spec child_spec(keyword) :: Supervisor.child_spec()
defdelegate child_spec(options), to: Phoenix.PubSub.Supervisor
@doc """
Subscribes the caller to the PubSub adapter's topic.
* `pubsub` - The name of the pubsub system
* `topic` - The topic to subscribe to, for example: `"users:123"`
* `opts` - The optional list of options. See below.
## Duplicate Subscriptions
Callers should only subscribe to a given topic a single time.
Duplicate subscriptions for a Pid/topic pair are allowed and
will cause duplicate events to be sent; however, when using
`Phoenix.PubSub.unsubscribe/2`, all duplicate subscriptions
will be dropped.
## Options
* `:metadata` - provides metadata to be attached to this
subscription. The metadata can be used by custom
dispatching mechanisms. See the "Custom dispatching"
section in the module documentation
"""
@spec subscribe(t, topic, keyword) :: :ok | {:error, term}
def subscribe(pubsub, topic, opts \\ [])
when is_atom(pubsub) and is_binary(topic) and is_list(opts) do
case Registry.register(pubsub, topic, opts[:metadata]) do
{:ok, _} -> :ok
{:error, _} = error -> error
end
end
@doc """
Unsubscribes the caller from the PubSub adapter's topic.
"""
@spec unsubscribe(t, topic) :: :ok
def unsubscribe(pubsub, topic) when is_atom(pubsub) and is_binary(topic) do
Registry.unregister(pubsub, topic)
end
@doc """
Broadcasts message on given topic across the whole cluster.
* `pubsub` - The name of the pubsub system
* `topic` - The topic to broadcast to, ie: `"users:123"`
* `message` - The payload of the broadcast
A custom dispatcher may also be given as a fourth, optional argument.
See the "Custom dispatching" section in the module documentation.
"""
@spec broadcast(t, topic, message, dispatcher) :: :ok | {:error, term}
def broadcast(pubsub, topic, message, dispatcher \\ __MODULE__)
when is_atom(pubsub) and is_binary(topic) and is_atom(dispatcher) do
{:ok, {adapter, name}} = Registry.meta(pubsub, :pubsub)
with :ok <- adapter.broadcast(name, topic, message, dispatcher) do
dispatch(pubsub, :none, topic, message, dispatcher)
end
end
@doc """
Broadcasts message on given topic from the given process across the whole cluster.
* `pubsub` - The name of the pubsub system
* `from` - The pid that will send the message
* `topic` - The topic to broadcast to, ie: `"users:123"`
* `message` - The payload of the broadcast
The default dispatcher will broadcast the message to all subscribers except for the
process that initiated the broadcast.
A custom dispatcher may also be given as a fifth, optional argument.
See the "Custom dispatching" section in the module documentation.
"""
@spec broadcast_from(t, pid, topic, message, dispatcher) :: :ok | {:error, term}
def broadcast_from(pubsub, from, topic, message, dispatcher \\ __MODULE__)
when is_atom(pubsub) and is_pid(from) and is_binary(topic) and is_atom(dispatcher) do
{:ok, {adapter, name}} = Registry.meta(pubsub, :pubsub)
with :ok <- adapter.broadcast(name, topic, message, dispatcher) do
dispatch(pubsub, from, topic, message, dispatcher)
end
end
@doc """
Broadcasts message on given topic only for the current node.
* `pubsub` - The name of the pubsub system
* `topic` - The topic to broadcast to, ie: `"users:123"`
* `message` - The payload of the broadcast
A custom dispatcher may also be given as a fourth, optional argument.
See the "Custom dispatching" section in the module documentation.
"""
@spec local_broadcast(t, topic, message, dispatcher) :: :ok
def local_broadcast(pubsub, topic, message, dispatcher \\ __MODULE__)
when is_atom(pubsub) and is_binary(topic) and is_atom(dispatcher) do
dispatch(pubsub, :none, topic, message, dispatcher)
end
@doc """
Broadcasts message on given topic from a given process only for the current node.
* `pubsub` - The name of the pubsub system
* `from` - The pid that will send the message
* `topic` - The topic to broadcast to, ie: `"users:123"`
* `message` - The payload of the broadcast
The default dispatcher will broadcast the message to all subscribers except for the
process that initiated the broadcast.
A custom dispatcher may also be given as a fifth, optional argument.
See the "Custom dispatching" section in the module documentation.
"""
@spec local_broadcast_from(t, pid, topic, message, dispatcher) :: :ok
def local_broadcast_from(pubsub, from, topic, message, dispatcher \\ __MODULE__)
when is_atom(pubsub) and is_pid(from) and is_binary(topic) and is_atom(dispatcher) do
dispatch(pubsub, from, topic, message, dispatcher)
end
@doc """
Broadcasts message on given topic to a given node.
* `node_name` - The target node name
* `pubsub` - The name of the pubsub system
* `topic` - The topic to broadcast to, ie: `"users:123"`
* `message` - The payload of the broadcast
**DO NOT** use this function if you wish to broadcast to the current
node, as it is always serialized, use `local_broadcast/4` instead.
A custom dispatcher may also be given as a fifth, optional argument.
See the "Custom dispatching" section in the module documentation.
"""
@spec direct_broadcast(node_name, t, topic, message, dispatcher) :: :ok | {:error, term}
def direct_broadcast(node_name, pubsub, topic, message, dispatcher \\ __MODULE__)
when is_atom(pubsub) and is_binary(topic) and is_atom(dispatcher) do
{:ok, {adapter, name}} = Registry.meta(pubsub, :pubsub)
adapter.direct_broadcast(name, node_name, topic, message, dispatcher)
end
@doc """
Raising version of `broadcast/4`.
"""
@spec broadcast!(t, topic, message, dispatcher) :: :ok
def broadcast!(pubsub, topic, message, dispatcher \\ __MODULE__) do
case broadcast(pubsub, topic, message, dispatcher) do
:ok -> :ok
{:error, error} -> raise BroadcastError, "broadcast failed: #{inspect(error)}"
end
end
@doc """
Raising version of `broadcast_from/5`.
"""
@spec broadcast_from!(t, pid, topic, message, dispatcher) :: :ok
def broadcast_from!(pubsub, from, topic, message, dispatcher \\ __MODULE__) do
case broadcast_from(pubsub, from, topic, message, dispatcher) do
:ok -> :ok
{:error, error} -> raise BroadcastError, "broadcast failed: #{inspect(error)}"
end
end
@doc """
Raising version of `direct_broadcast/5`.
"""
@spec direct_broadcast!(node_name, t, topic, message, dispatcher) :: :ok
def direct_broadcast!(node_name, pubsub, topic, message, dispatcher \\ __MODULE__) do
case direct_broadcast(node_name, pubsub, topic, message, dispatcher) do
:ok -> :ok
{:error, error} -> raise BroadcastError, "broadcast failed: #{inspect(error)}"
end
end
@doc """
Returns the node name of the PubSub server.
"""
@spec node_name(t) :: node_name
def node_name(pubsub) do
{:ok, {adapter, name}} = Registry.meta(pubsub, :pubsub)
adapter.node_name(name)
end
## Dispatch callback
@doc false
def dispatch(entries, :none, message) do
for {pid, _} <- entries do
send(pid, message)
end
:ok
end
def dispatch(entries, from, message) do
for {pid, _} <- entries, pid != from do
send(pid, message)
end
:ok
end
defp dispatch(pubsub, from, topic, message, dispatcher) do
Registry.dispatch(pubsub, topic, {dispatcher, :dispatch, [from, message]})
:ok
end
end

View File

@@ -0,0 +1,46 @@
defmodule Phoenix.PubSub.Adapter do
@moduledoc """
Specification to implement a custom PubSub adapter.
"""
@type adapter_name :: atom
@doc """
Returns the node name as an atom or a binary.
"""
@callback node_name(adapter_name) :: Phoenix.PubSub.node_name()
@doc """
Returns a child specification that mounts the processes
required for the adapter.
`child_spec` will receive all options given `Phoenix.PubSub`.
Note, however, that the `:name` under options is the name
of the complete PubSub system. The reserved key space to
be used by the adapter is under the `:adapter_name` key.
"""
@callback child_spec(keyword) :: Supervisor.child_spec()
@doc """
Broadcasts the given topic, message, and dispatcher to
all nodes in the cluster (except the current node itself).
"""
@callback broadcast(
adapter_name,
topic :: Phoenix.PubSub.topic(),
message :: Phoenix.PubSub.message(),
dispatcher :: Phoenix.PubSub.dispatcher()
) :: :ok | {:error, term}
@doc """
Broadcasts the given topic, message, and dispatcher to
given node in the cluster (it may point to itself).
"""
@callback direct_broadcast(
adapter_name,
node_name :: Phoenix.PubSub.node_name(),
topic :: Phoenix.PubSub.topic(),
message :: Phoenix.PubSub.message(),
dispatcher :: Phoenix.PubSub.dispatcher()
) :: :ok | {:error, term}
end

View File

@@ -0,0 +1,19 @@
defmodule Phoenix.PubSub.Application do
@moduledoc false
use Application
def start(_, _) do
children = pg_children()
Supervisor.start_link(children, strategy: :one_for_one)
end
if Code.ensure_loaded?(:pg) do
defp pg_children() do
[%{id: :pg, start: {:pg, :start_link, [Phoenix.PubSub]}}]
end
else
defp pg_children() do
[]
end
end
end

View File

@@ -0,0 +1,140 @@
defmodule Phoenix.PubSub.PG2 do
@moduledoc """
Phoenix PubSub adapter based on `:pg`/`:pg2`.
It runs on Distributed Erlang and is the default adapter.
"""
@behaviour Phoenix.PubSub.Adapter
use Supervisor
## Adapter callbacks
@impl true
def node_name(_), do: node()
@impl true
def broadcast(adapter_name, topic, message, dispatcher) do
case pg_members(group(adapter_name)) do
{:error, {:no_such_group, _}} ->
{:error, :no_such_group}
pids ->
message = forward_to_local(topic, message, dispatcher)
for pid <- pids, node(pid) != node() do
send(pid, message)
end
:ok
end
end
@impl true
def direct_broadcast(adapter_name, node_name, topic, message, dispatcher) do
send({group(adapter_name), node_name}, {:forward_to_local, topic, message, dispatcher})
:ok
end
defp forward_to_local(topic, message, dispatcher) do
{:forward_to_local, topic, message, dispatcher}
end
defp group(adapter_name) do
groups = :persistent_term.get(adapter_name)
elem(groups, :erlang.phash2(self(), tuple_size(groups)))
end
if Code.ensure_loaded?(:pg) do
defp pg_members(group) do
:pg.get_members(Phoenix.PubSub, group)
end
else
defp pg_members(group) do
:pg2.get_members({:phx, group})
end
end
## Supervisor callbacks
@doc false
def start_link(opts) do
name = Keyword.fetch!(opts, :name)
pool_size = Keyword.get(opts, :pool_size, 1)
broadcast_pool_size = Keyword.get(opts, :broadcast_pool_size, pool_size)
if pool_size < broadcast_pool_size do
{:error, "the :pool_size option must be greater than or equal to the :broadcast_pool_size option"}
else
adapter_name = Keyword.fetch!(opts, :adapter_name)
Supervisor.start_link(__MODULE__, {name, adapter_name, pool_size, broadcast_pool_size}, name: :"#{adapter_name}_supervisor")
end
end
@impl true
def init({name, adapter_name, pool_size, broadcast_pool_size}) do
listener_groups = groups(adapter_name, pool_size)
broadcast_groups = groups(adapter_name, broadcast_pool_size)
:persistent_term.put(adapter_name, List.to_tuple(broadcast_groups))
children =
for group <- listener_groups do
Supervisor.child_spec({Phoenix.PubSub.PG2Worker, {name, group}}, id: group)
end
Supervisor.init(children, strategy: :one_for_one)
end
defp groups(adapter_name, pool_size) do
[_ | groups] =
for number <- 1..pool_size do
:"#{adapter_name}_#{number}"
end
# Use `adapter_name` for the first in the pool for backwards compatibility
# with v2.0 when the pool_size is 1.
[adapter_name | groups]
end
end
defmodule Phoenix.PubSub.PG2Worker do
@moduledoc false
use GenServer
@doc false
def start_link({name, group}) do
GenServer.start_link(__MODULE__, {name, group}, name: group)
end
@impl true
def init({name, group}) do
:ok = pg_join(group)
{:ok, name}
end
@impl true
def handle_info({:forward_to_local, topic, message, dispatcher}, pubsub) do
Phoenix.PubSub.local_broadcast(pubsub, topic, message, dispatcher)
{:noreply, pubsub}
end
@impl true
def handle_info(_, pubsub) do
{:noreply, pubsub}
end
if Code.ensure_loaded?(:pg) do
defp pg_join(group) do
:ok = :pg.join(Phoenix.PubSub, group, self())
end
else
defp pg_join(group) do
namespace = {:phx, group}
:ok = :pg2.create(namespace)
:ok = :pg2.join(namespace, self())
:ok
end
end
end

View File

@@ -0,0 +1,42 @@
defmodule Phoenix.PubSub.Supervisor do
@moduledoc false
use Supervisor
def start_link(opts) do
unless Code.ensure_loaded?(:persistent_term) do
raise "Phoenix.PubSub v2.1 requires at least Erlang/OTP 21.3"
end
name =
opts[:name] ||
raise ArgumentError, "the :name option is required when starting Phoenix.PubSub"
sup_name = Module.concat(name, "Supervisor")
Supervisor.start_link(__MODULE__, opts, name: sup_name)
end
@impl true
def init(opts) do
name = opts[:name]
adapter = opts[:adapter] || Phoenix.PubSub.PG2
adapter_name = Module.concat(name, "Adapter")
partitions =
opts[:registry_size] || opts[:pool_size] ||
System.schedulers_online() |> Kernel./(4) |> Float.ceil() |> trunc()
registry = [
meta: [pubsub: {adapter, adapter_name}],
partitions: partitions,
keys: :duplicate,
name: name
]
children = [
{Registry, registry},
{adapter, Keyword.put(opts, :adapter_name, adapter_name)}
]
Supervisor.init(children, strategy: :rest_for_one)
end
end

View File

@@ -0,0 +1,372 @@
defmodule Phoenix.Tracker do
@moduledoc ~S"""
Provides distributed presence tracking to processes.
Tracker shards use a heartbeat protocol and CRDT to replicate presence
information across a cluster in an eventually consistent, conflict-free
manner. Under this design, there is no single source of truth or global
process. Each node runs a pool of trackers and node-local changes are
replicated across the cluster and handled locally as a diff of changes.
## Implementing a Tracker
To start a tracker, first add the tracker to your supervision tree:
children = [
# ...
{MyTracker, [name: MyTracker, pubsub_server: MyApp.PubSub]}
]
Next, implement `MyTracker` with support for the `Phoenix.Tracker`
behaviour callbacks. An example of a minimal tracker could include:
defmodule MyTracker do
use Phoenix.Tracker
def start_link(opts) do
opts = Keyword.merge([name: __MODULE__], opts)
Phoenix.Tracker.start_link(__MODULE__, opts, opts)
end
def init(opts) do
server = Keyword.fetch!(opts, :pubsub_server)
{:ok, %{pubsub_server: server, node_name: Phoenix.PubSub.node_name(server)}}
end
def handle_diff(diff, state) do
for {topic, {joins, leaves}} <- diff do
for {key, meta} <- joins do
IO.puts "presence join: key \"#{key}\" with meta #{inspect meta}"
msg = {:join, key, meta}
Phoenix.PubSub.direct_broadcast!(state.node_name, state.pubsub_server, topic, msg)
end
for {key, meta} <- leaves do
IO.puts "presence leave: key \"#{key}\" with meta #{inspect meta}"
msg = {:leave, key, meta}
Phoenix.PubSub.direct_broadcast!(state.node_name, state.pubsub_server, topic, msg)
end
end
{:ok, state}
end
end
Trackers must implement `start_link/1`, `c:init/1`, and `c:handle_diff/2`.
The `c:init/1` callback allows the tracker to manage its own state when
running within the `Phoenix.Tracker` server. The `handle_diff` callback
is invoked with a diff of presence join and leave events, grouped by
topic. As replicas heartbeat and replicate data, the local tracker state is
merged with the remote data, and the diff is sent to the callback. The
handler can use this information to notify subscribers of events, as
done above.
An optional `handle_info/2` callback may also be invoked to handle
application specific messages within your tracker.
## Stability and Performance Considerations
Operations within `handle_diff/2` happen *in the tracker server's context*.
Therefore, blocking operations should be avoided when possible, and offloaded
to a supervised task when required. Also, a crash in the `handle_diff/2` will
crash the tracker server, so operations that may crash the server should be
offloaded with a `Task.Supervisor` spawned process.
## Application Shutdown
When a tracker shuts down, the other nodes do not assume it is gone
for good. After all, in a distributed system, it is impossible to know if something
is just temporarily unavailable or if it has crashed.
For this reason, when you call `System.stop()` or the Erlang VM receives a
`SIGTERM`, any presences that the local tracker instance has will continue to
be seen as present by other trackers in the cluster until the `:down_period`
for the instance has passed.
If you want a normal shutdown to immediately cause other nodes to see that
tracker's presences as leaving, pass `permdown_on_shutdown: true`. On the
other hand, if you are using `Phoenix.Presence` for clients which will
immediately attempt to connect to a new node, it may be preferable to use
`permdown_on_shutdown: false`, allowing the disconnected clients time to
reconnect before removing their old presences, to avoid overwhelming clients
with notifications that many users left and immediately rejoined.
If the application crashes or is halted non-gracefully (for instance, with a
`SIGKILL` or a `Ctrl+C` in `iex`), other nodes will still have to wait the
`:down_period` to notice that the tracker's presences are gone.
"""
use Supervisor
alias Phoenix.Tracker.Shard
@type presence :: {key :: String.t(), meta :: map}
@type topic :: String.t()
@callback init(Keyword.t()) :: {:ok, state :: term} | {:error, reason :: term}
@callback handle_diff(%{topic => {joins :: [presence], leaves :: [presence]}}, state :: term) ::
{:ok, state :: term}
@callback handle_info(message :: term, state :: term) :: {:noreply, state :: term}
@optional_callbacks handle_info: 2
defmacro __using__(_opts) do
quote location: :keep do
@behaviour Phoenix.Tracker
if Module.get_attribute(__MODULE__, :doc) == nil do
@doc """
Returns a specification to start this module under a supervisor.
See `Supervisor`.
"""
end
def child_spec(init_arg) do
default = %{
id: __MODULE__,
start: {__MODULE__, :start_link, [init_arg]},
type: :supervisor
}
end
defoverridable child_spec: 1
end
end
## Client
@doc """
Tracks a presence.
* `tracker_name` - The registered name of the tracker server
* `pid` - The Pid to track
* `topic` - The `Phoenix.PubSub` topic for this presence
* `key` - The key identifying this presence
* `meta` - The map of metadata to attach to this presence
A process may be tracked multiple times, provided the topic and key pair
are unique for any prior calls for the given process.
## Examples
iex> Phoenix.Tracker.track(MyTracker, self(), "lobby", u.id, %{stat: "away"})
{:ok, "1WpAofWYIAA="}
iex> Phoenix.Tracker.track(MyTracker, self(), "lobby", u.id, %{stat: "away"})
{:error, {:already_tracked, #PID<0.56.0>, "lobby", "123"}}
"""
@spec track(atom, pid, topic, term, map) :: {:ok, ref :: binary} | {:error, reason :: term}
def track(tracker_name, pid, topic, key, meta) when is_pid(pid) and is_map(meta) do
tracker_name
|> Shard.name_for_topic(topic, pool_size(tracker_name))
|> GenServer.call({:track, pid, topic, key, meta})
end
@doc """
Untracks a presence.
* `tracker_name` - The registered name of the tracker server
* `pid` - The Pid to untrack
* `topic` - The `Phoenix.PubSub` topic to untrack for this presence
* `key` - The key identifying this presence
All presences for a given Pid can be untracked by calling the
`Phoenix.Tracker.untrack/2` signature of this function.
## Examples
iex> Phoenix.Tracker.untrack(MyTracker, self(), "lobby", u.id)
:ok
iex> Phoenix.Tracker.untrack(MyTracker, self())
:ok
"""
@spec untrack(atom, pid, topic, term) :: :ok
def untrack(tracker_name, pid, topic, key) when is_pid(pid) do
tracker_name
|> Shard.name_for_topic(topic, pool_size(tracker_name))
|> GenServer.call({:untrack, pid, topic, key})
end
def untrack(tracker_name, pid) when is_pid(pid) do
shard_multicall(tracker_name, {:untrack, pid})
:ok
end
@doc """
Updates a presence's metadata.
* `tracker_name` - The registered name of the tracker server
* `pid` - The Pid being tracked
* `topic` - The `Phoenix.PubSub` topic to update for this presence
* `key` - The key identifying this presence
* `meta` - Either a new map of metadata to attach to this presence,
or a function. The function will receive the current metadata as
input and the return value will be used as the new metadata
## Examples
iex> Phoenix.Tracker.update(MyTracker, self(), "lobby", u.id, %{stat: "zzz"})
{:ok, "1WpAofWYIAA="}
iex> Phoenix.Tracker.update(MyTracker, self(), "lobby", u.id, fn meta -> Map.put(meta, :away, true) end)
{:ok, "1WpAofWYIAA="}
"""
@spec update(atom, pid, topic, term, map | (map -> map)) ::
{:ok, ref :: binary} | {:error, reason :: term}
def update(tracker_name, pid, topic, key, meta)
when is_pid(pid) and (is_map(meta) or is_function(meta)) do
tracker_name
|> Shard.name_for_topic(topic, pool_size(tracker_name))
|> GenServer.call({:update, pid, topic, key, meta})
end
@doc """
Lists all presences tracked under a given topic.
* `tracker_name` - The registered name of the tracker server
* `topic` - The `Phoenix.PubSub` topic
Returns a list of presences in key/metadata tuple pairs.
## Examples
iex> Phoenix.Tracker.list(MyTracker, "lobby")
[{123, %{name: "user 123"}}, {456, %{name: "user 456"}}]
"""
@spec list(atom, topic) :: [presence]
def list(tracker_name, topic) do
tracker_name
|> Shard.name_for_topic(topic, pool_size(tracker_name))
|> Phoenix.Tracker.Shard.list(topic)
end
@doc """
Gets presences tracked under a given topic and key pair.
* `tracker_name` - The registered name of the tracker server
* `topic` - The `Phoenix.PubSub` topic
* `key` - The key of the presence
Returns a list of presence metadata.
## Examples
iex> Phoenix.Tracker.get_by_key(MyTracker, "lobby", "user1")
[{#PID<0.88.0>, %{name: "User 1"}}, {#PID<0.89.0>, %{name: "User 1"}}]
"""
@spec get_by_key(atom, topic, term) :: [{pid, map}]
def get_by_key(tracker_name, topic, key) do
tracker_name
|> Shard.name_for_topic(topic, pool_size(tracker_name))
|> Phoenix.Tracker.Shard.get_by_key(topic, key)
end
@doc """
Gracefully shuts down by broadcasting permdown to all replicas.
## Examples
iex> Phoenix.Tracker.graceful_permdown(MyTracker)
:ok
"""
@spec graceful_permdown(atom) :: :ok
def graceful_permdown(tracker_name) do
shard_multicall(tracker_name, :graceful_permdown)
Supervisor.stop(tracker_name)
end
@doc """
Starts a tracker pool.
* `tracker` - The tracker module implementing the `Phoenix.Tracker` behaviour
* `tracker_arg` - The argument to pass to the tracker handler `c:init/1`
* `pool_opts` - The list of options used to construct the shard pool
## Required `pool_opts`:
* `:name` - The name of the server, such as: `MyApp.Tracker`
This will also form the common prefix for all shard names
* `:pubsub_server` - The name of the PubSub server, such as: `MyApp.PubSub`
## Optional `pool_opts`:
* `:broadcast_period` - The interval in milliseconds to send delta broadcasts
across the cluster. Default `1500`
* `:max_silent_periods` - The max integer of broadcast periods for which no
delta broadcasts have been sent. Default `10` (15s heartbeat)
* `:down_period` - The interval in milliseconds to flag a replica
as temporarily down. Default `broadcast_period * max_silent_periods * 2`
(30s down detection). Note: This must be at least 2x the `broadcast_period`.
* `permdown_on_shutdown` - boolean; whether to immediately call
`graceful_permdown/1` on the tracker during a graceful shutdown. See
'Application Shutdown' section. You can only safely set this if `Phoenix.Tracker`
is mounted at the root of your supervision tree and the strategy is `:one_for_one`.
Default `false`.
* `:permdown_period` - The interval in milliseconds to flag a replica
as permanently down, and discard its state.
Note: This must be at least greater than the `down_period`.
Default `1_200_000` (20 minutes)
* `:clock_sample_periods` - The numbers of heartbeat windows to sample
remote clocks before collapsing and requesting transfer. Default `2`
* `:max_delta_sizes` - The list of delta generation sizes to keep before
falling back to sending entire state. Defaults `[100, 1000, 10_000]`.
* `:log_level` - The log level to log events, defaults `:debug` and can be
disabled with `false`
* `:pool_size` - The number of tracker shards to launch. Default `1`
"""
def start_link(tracker, tracker_arg, pool_opts) do
name = Keyword.fetch!(pool_opts, :name)
Supervisor.start_link(__MODULE__, [tracker, tracker_arg, pool_opts, name], name: name)
end
@impl true
def init([tracker, tracker_opts, opts, name]) do
pool_size = Keyword.get(opts, :pool_size, 1)
permdown_on_shutdown = Keyword.get(opts, :permdown_on_shutdown, false)
^name = :ets.new(name, [:set, :named_table, read_concurrency: true])
true = :ets.insert(name, {:pool_size, pool_size})
shards =
for n <- 0..(pool_size - 1) do
shard_name = Shard.name_for_number(name, n)
shard_opts = Keyword.put(opts, :shard_number, n)
%{
id: shard_name,
start: {Phoenix.Tracker.Shard, :start_link, [tracker, tracker_opts, shard_opts]},
restart: :transient
}
end
children =
if permdown_on_shutdown do
shards ++
[
%{
id: :shutdown_handler,
start: {Phoenix.Tracker.ShutdownHandler, :start_link, [tracker]}
}
]
else
shards
end
opts = [
strategy: :one_for_one,
max_restarts: pool_size * 2,
max_seconds: 1
]
Supervisor.init(children, opts)
end
defp pool_size(tracker_name) do
[{:pool_size, size}] = :ets.lookup(tracker_name, :pool_size)
size
end
defp shard_multicall(tracker_name, message) do
for shard_number <- 0..(pool_size(tracker_name) - 1) do
tracker_name
|> Shard.name_for_number(shard_number)
|> GenServer.call(message)
end
end
end

View File

@@ -0,0 +1,104 @@
defmodule Phoenix.Tracker.Clock do
@moduledoc false
alias Phoenix.Tracker.State
@type context :: State.context
@type clock :: {State.name, context}
@doc """
Returns a list of replicas from a list of contexts.
"""
@spec clockset_replicas([clock]) :: [State.name]
def clockset_replicas(clockset) do
for {replica, _} <- clockset, do: replica
end
@doc """
Adds a replicas context to a clockset, keeping only dominate contexts.
"""
@spec append_clock([clock], clock) :: [clock]
def append_clock(clockset, {_, clock}) when map_size(clock) == 0, do: clockset
def append_clock(clockset, {node, clock}) do
big_clock = combine_clocks(clockset)
cond do
dominates?(clock, big_clock) -> [{node, clock}]
dominates?(big_clock, clock) -> clockset
true -> filter_clocks(clockset, {node, clock})
end
end
@doc """
Checks if one clock causally dominates the other for all replicas.
"""
@spec dominates?(context, context) :: boolean
def dominates?(c1, c2) when map_size(c1) < map_size(c2), do: false
def dominates?(c1, c2) do
Enum.reduce_while(c2, true, fn {replica, clock}, true ->
if Map.get(c1, replica, 0) >= clock do
{:cont, true}
else
{:halt, false}
end
end)
end
@doc """
Checks if one clock causally dominates the other for their shared replicas.
"""
def dominates_or_equal?(c1, c2) when c1 == %{} and c2 == %{}, do: true
def dominates_or_equal?(c1, _c2) when c1 == %{}, do: false
def dominates_or_equal?(c1, c2) do
Enum.reduce_while(c1, true, fn {replica, clock}, true ->
if clock >= Map.get(c2, replica, 0) do
{:cont, true}
else
{:halt, false}
end
end)
end
@doc """
Returns the upper bound causal context of two clocks.
"""
def upperbound(c1, c2) do
Map.merge(c1, c2, fn _, v1, v2 -> max(v1, v2) end)
end
@doc """
Returns the lower bound causal context of two clocks.
"""
def lowerbound(c1, c2) do
Map.merge(c1, c2, fn _, v1, v2 -> min(v1, v2) end)
end
@doc """
Returns the clock with just provided replicas.
"""
def filter_replicas(c, replicas), do: Map.take(c, replicas)
@doc """
Returns replicas from the given clock.
"""
def replicas(c), do: Map.keys(c)
defp filter_clocks(clockset, {node, clock}) do
clockset
|> Enum.reduce({[], false}, fn {node2, clock2}, {set, insert} ->
if dominates?(clock, clock2) do
{set, true}
else
{[{node2, clock2}| set], insert || !dominates?(clock2, clock)}
end
end)
|> case do
{new_clockset, true} -> [{node, clock} | new_clockset]
{new_clockset, false} -> new_clockset
end
end
defp combine_clocks(clockset) do
clockset
|> Enum.map(fn {_, clocks} -> clocks end)
|> Enum.reduce(%{}, &upperbound(&1, &2))
end
end

View File

@@ -0,0 +1,69 @@
defmodule Phoenix.Tracker.DeltaGeneration do
@moduledoc false
require Logger
alias Phoenix.Tracker.{State, Clock, Replica}
@doc """
Extracts minimal delta from generations to satisfy remote clock.
Falls back to extracting entire crdt if unable to match delta.
"""
@spec extract(State.t, [State.delta], State.name, State.context) :: State.delta | State.t
def extract(%State{mode: :normal} = state, generations, remote_ref, remote_context) do
case delta_fulfilling_clock(generations, remote_context) do
{delta, index} ->
if index, do: Logger.debug "#{inspect state.replica}: sending delta generation #{index + 1}"
State.extract(delta, remote_ref, remote_context)
nil ->
Logger.debug "#{inspect state.replica}: falling back to sending entire crdt"
State.extract(state, remote_ref, remote_context)
end
end
@spec push(State.t, [State.delta], State.delta, [pos_integer]) :: [State.delta]
def push(%State{mode: :normal} = parent, [] = _generations, %State{mode: :delta} = delta, opts) do
parent.delta
|> List.duplicate(Enum.count(opts))
|> do_push(delta, opts, {delta, []})
end
def push(%State{mode: :normal} = _parent, generations, %State{mode: :delta} = delta, opts) do
do_push(generations, delta, opts, {delta, []})
end
defp do_push([], _delta, [], {_prev, acc}), do: Enum.reverse(acc)
defp do_push([gen | generations], delta, [gen_max | opts], {prev, acc}) do
case State.merge_deltas(gen, delta) do
{:ok, merged} ->
if State.delta_size(merged) <= gen_max do
do_push(generations, delta, opts, {merged, [merged | acc]})
else
do_push(generations, delta, opts, {merged, [prev | acc]})
end
{:error, :not_contiguous} ->
do_push(generations, delta, opts, {gen, [gen | acc]})
end
end
@doc """
Prunes permanently downed replicates from the delta generation list
"""
@spec remove_down_replicas([State.delta], Replica.replica_ref) :: [State.delta]
def remove_down_replicas(generations, replica_ref) do
Enum.map(generations, fn %State{mode: :delta} = gen ->
State.remove_down_replicas(gen, replica_ref)
end)
end
defp delta_fulfilling_clock(generations, remote_context) do
generations
|> Enum.with_index()
|> Enum.find(fn {%State{range: {local_start, local_end}}, _} ->
local_start = Clock.filter_replicas(local_start, Clock.replicas(remote_context))
local_end = Clock.filter_replicas(local_end, Clock.replicas(remote_context))
not Clock.dominates_or_equal?(local_start, local_end) and
Clock.dominates_or_equal?(remote_context, local_start) and
not Clock.dominates?(remote_context, local_end)
end)
end
end

View File

@@ -0,0 +1,91 @@
defmodule Phoenix.Tracker.Replica do
@moduledoc false
alias Phoenix.Tracker.Replica
@type name :: String.t()
@type vsn :: integer
@type replica_ref :: {name, vsn}
@type t :: %Replica{
name: name,
vsn: vsn,
last_heartbeat_at: pos_integer | nil,
status: :up | :down | :permdown
}
defstruct name: nil,
vsn: nil,
last_heartbeat_at: nil,
status: :up
@type op_result ::
{%{name => Replica.t()}, previous_node :: Replica.t() | nil,
updated_node :: Replica.t()}
@doc """
Returns a new Replica with a unique vsn.
"""
@spec new(name) :: Replica.t()
def new(name) do
%Replica{name: name, vsn: unique_vsn()}
end
@spec ref(Replica.t()) :: replica_ref
def ref(%Replica{name: name, vsn: vsn}), do: {name, vsn}
@spec put_heartbeat(%{name => Replica.t()}, replica_ref) :: op_result
def put_heartbeat(replicas, {name, vsn}) do
case Map.fetch(replicas, name) do
:error ->
new_replica = touch_last_heartbeat(%Replica{name: name, vsn: vsn, status: :up})
{Map.put(replicas, name, new_replica), nil, new_replica}
{:ok, %Replica{} = prev_replica} ->
updated_replica = touch_last_heartbeat(%{prev_replica | vsn: vsn, status: :up})
{Map.put(replicas, name, updated_replica), prev_replica, updated_replica}
end
end
@spec detect_down(%{name => Replica.t()}, Replica.t(), pos_integer, pos_integer) :: op_result
def detect_down(replicas, replica, temp_interval, perm_interval, now \\ now_ms()) do
downtime = now - replica.last_heartbeat_at
cond do
downtime > perm_interval ->
{Map.delete(replicas, replica.name), replica, permdown(replica)}
downtime > temp_interval ->
updated_replica = down(replica)
{Map.put(replicas, replica.name, updated_replica), replica, updated_replica}
true ->
{replicas, replica, replica}
end
end
@doc """
Fetches a replica from the map with matching name and version from the ref.
"""
@spec fetch_by_ref(%{name => Replica.t()}, replica_ref) :: {:ok, Replica.t()} | :error
def fetch_by_ref(replicas, {name, vsn}) do
case Map.fetch(replicas, name) do
{:ok, %Replica{vsn: ^vsn} = replica} -> {:ok, replica}
{:ok, %Replica{vsn: _vsn}} -> :error
:error -> :error
end
end
defp permdown(%Replica{} = replica), do: %{replica | status: :permdown}
defp down(%Replica{} = replica), do: %{replica | status: :down}
defp touch_last_heartbeat(%Replica{} = replica) do
%{replica | last_heartbeat_at: now_ms()}
end
defp now_ms, do: System.system_time(:millisecond)
defp unique_vsn do
System.system_time(:microsecond) + System.unique_integer([:positive])
end
end

View File

@@ -0,0 +1,564 @@
defmodule Phoenix.Tracker.Shard do
@moduledoc false
use GenServer
alias Phoenix.Tracker.{Clock, State, Replica, DeltaGeneration}
require Logger
@type presence :: {key :: String.t, meta :: map()}
@type topic :: String.t
@callback init(Keyword.t) :: {:ok, pid} | {:error, reason :: term}
@callback handle_diff(%{topic => {joins :: [presence], leaves :: [presence]}}, state :: term) :: {:ok, state :: term}
@callback handle_info(message :: term, state :: term) :: {:noreply, state :: term}
@optional_callbacks handle_info: 2
@type t :: %{
shard_name: String.t(),
pubsub_server: atom(),
tracker: module,
tracker_state: any,
replica: Replica.t(),
report_events_to: any,
namespaced_topic: String.t(),
log_level: boolean | atom,
replicas: map,
pending_clockset: [],
presences: State.t(),
broadcast_period: integer,
max_silent_periods: integer(),
silent_periods: integer(),
down_period: integer,
permdown_period: integer,
clock_sample_periods: integer,
deltas: [State.delta()],
max_delta_sizes: integer,
current_sample_count: integer
}
## Used by Phoenix.Tracker for dispatching to appropriate shard
@spec name_for_number(atom, non_neg_integer) :: atom
def name_for_number(prefix, n) when is_number(n) do
:"#{prefix}_shard#{n}"
end
@spec name_for_topic(atom, topic, non_neg_integer) :: atom
def name_for_topic(prefix, topic, pool_size) do
shard_number = :erlang.phash2(topic, pool_size)
name_for_number(prefix, shard_number)
end
## Client
@spec track(pid, pid, topic, term, map()) :: {:ok, ref :: binary} | {:error, reason :: term}
def track(server_pid, pid, topic, key, meta) when is_pid(pid) and is_map(meta) do
GenServer.call(server_pid, {:track, pid, topic, key, meta})
end
@spec untrack(pid, pid, topic, term) :: :ok
def untrack(server_pid, pid, topic, key) when is_pid(pid) do
GenServer.call(server_pid, {:untrack, pid, topic, key})
end
def untrack(server_pid, pid) when is_pid(pid) do
GenServer.call(server_pid, {:untrack, pid})
end
@spec update(pid, pid, topic, term, map() | (map() -> map())) :: {:ok, ref :: binary} | {:error, reason :: term}
def update(server_pid, pid, topic, key, meta) when is_pid(pid) and (is_map(meta) or is_function(meta)) do
GenServer.call(server_pid, {:update, pid, topic, key, meta})
end
@spec list(pid | atom, topic) :: [presence]
def list(server_pid, topic) do
server_pid
|> GenServer.call({:list, topic})
|> State.get_by_topic(topic)
end
@doc false
def dirty_list(shard_name, topic) do
State.tracked_values(shard_name, topic, [])
end
@spec get_by_key(pid | atom, topic, term) :: [{pid, map}]
def get_by_key(server_pid, topic, key) do
server_pid
|> GenServer.call({:list, topic})
|> State.get_by_key(topic, key)
end
@spec graceful_permdown(pid) :: :ok
def graceful_permdown(server_pid) do
GenServer.call(server_pid, :graceful_permdown)
end
## Server
def start_link(tracker, tracker_opts, pool_opts) do
number = Keyword.fetch!(pool_opts, :shard_number)
tracker_name = Keyword.fetch!(pool_opts, :name)
name = name_for_number(tracker_name, number)
shard_opts = Keyword.put(pool_opts, :name, name)
GenServer.start_link(__MODULE__,
[tracker, tracker_opts, shard_opts], name: name)
end
def init([tracker, tracker_opts, shard_opts]) do
Process.flag(:trap_exit, true)
shard_name = Keyword.fetch!(shard_opts, :name)
pubsub_server = Keyword.fetch!(shard_opts, :pubsub_server)
broadcast_period = shard_opts[:broadcast_period] || 1500
max_silent_periods = shard_opts[:max_silent_periods] || 10
down_period = shard_opts[:down_period]
|| (broadcast_period * max_silent_periods * 2)
permdown_period = shard_opts[:permdown_period] || 1_200_000
clock_sample_periods = shard_opts[:clock_sample_periods] || 2
log_level = Keyword.get(shard_opts, :log_level, false)
max_delta_sizes = shard_opts[:max_delta_sizes] || [100, 1000, 10_000]
with :ok <- validate_down_period(down_period, broadcast_period),
:ok <- validate_permdown_period(permdown_period, down_period),
{:ok, tracker_state} <- tracker.init(tracker_opts) do
node_name = Phoenix.PubSub.node_name(pubsub_server)
namespaced_topic = namespaced_topic(shard_name)
replica = Replica.new(node_name)
subscribe(pubsub_server, namespaced_topic)
send_stuttered_heartbeat(self(), broadcast_period)
{:ok, %{shard_name: shard_name,
pubsub_server: pubsub_server,
tracker: tracker,
tracker_state: tracker_state,
replica: replica,
report_events_to: shard_opts[:report_events_to],
namespaced_topic: namespaced_topic,
log_level: log_level,
replicas: %{},
pending_clockset: [],
presences: State.new(Replica.ref(replica), shard_name),
broadcast_period: broadcast_period,
max_silent_periods: max_silent_periods,
silent_periods: max_silent_periods,
down_period: down_period,
permdown_period: permdown_period,
clock_sample_periods: clock_sample_periods,
deltas: [],
max_delta_sizes: max_delta_sizes,
current_sample_count: clock_sample_periods}}
end
end
def validate_down_period(d_period, b_period) when d_period < (2 * b_period) do
{:error, "down_period must be at least twice as large as the broadcast_period"}
end
def validate_down_period(_d_period, _b_period), do: :ok
def validate_permdown_period(p_period, d_period) when p_period <= d_period do
{:error, "permdown_period must be at least larger than the down_period"}
end
def validate_permdown_period(_p_period, _d_period), do: :ok
defp send_stuttered_heartbeat(pid, interval) do
Process.send_after(pid, :heartbeat, Enum.random(0..trunc(interval * 0.25)))
end
def handle_info(:heartbeat, state) do
{:noreply, state
|> broadcast_delta_heartbeat()
|> request_transfer_from_replicas_needing_synced()
|> detect_downs()
|> schedule_next_heartbeat()}
end
def handle_info({:pub, :heartbeat, {name, vsn}, :empty, clocks}, state) do
{:noreply, state
|> put_pending_clock(clocks)
|> handle_heartbeat({name, vsn})}
end
def handle_info({:pub, :heartbeat, {name, vsn}, delta, clocks}, state) do
state = handle_heartbeat(state, {name, vsn})
{presences, joined, left} = State.merge(state.presences, delta)
{:noreply, state
|> report_diff(joined, left)
|> put_presences(presences)
|> put_pending_clock(clocks)
|> push_delta_generation(delta)}
end
def handle_info({:pub, :transfer_req, ref, {name, _vsn}, {_, clocks}}, state) do
log state, fn -> "#{state.replica.name}: transfer_req from #{inspect name}" end
delta = DeltaGeneration.extract(state.presences, state.deltas, name, clocks)
msg = {:pub, :transfer_ack, ref, Replica.ref(state.replica), delta}
direct_broadcast(state, name, msg)
{:noreply, state}
end
def handle_info({:pub, :transfer_ack, _ref, {name, _vsn}, remote_presences}, state) do
log(state, fn -> "#{state.replica.name}: transfer_ack from #{inspect name}" end)
{presences, joined, left} = State.merge(state.presences, remote_presences)
{:noreply, state
|> report_diff(joined, left)
|> push_delta_generation(remote_presences)
|> put_presences(presences)}
end
def handle_info({:pub, :graceful_permdown, {_name, _vsn} = ref}, state) do
case Replica.fetch_by_ref(state.replicas, ref) do
{:ok, replica} -> {:noreply, state |> down(replica) |> permdown(replica)}
:error -> {:noreply, state}
end
end
def handle_info({:EXIT, pid, _reason}, state) do
{:noreply, drop_presence(state, pid)}
end
def handle_info(msg, state) do
if function_exported?(state.tracker, :handle_info, 2) do
case state.tracker.handle_info(msg, state.tracker_state) do
{:noreply, new_tracker_state} ->
{:noreply, %{state | tracker_state: new_tracker_state}}
other ->
raise ArgumentError, """
expected #{state.tracker}.handle_info/2 to return {:noreply, state}, but got:
#{inspect other}
"""
end
else
{:noreply, state}
end
end
def handle_call(:values, _from, state) do
{:reply, :ets.match(state.presences.values, :"$1"), state}
end
def handle_call({:track, pid, topic, key, meta}, _from, state) do
case State.get_by_pid(state.presences, pid, topic, key) do
nil ->
{state, ref} = put_presence(state, pid, topic, key, meta)
{:reply, {:ok, ref}, state}
_ ->
{:reply, {:error, {:already_tracked, pid, topic, key}}, state}
end
end
def handle_call({:untrack, pid, topic, key}, _from, state) do
new_state = drop_presence(state, pid, topic, key)
if State.get_by_pid(new_state.presences, pid) == [] do
Process.unlink(pid)
end
{:reply, :ok, new_state}
end
def handle_call({:untrack, pid}, _from, state) do
Process.unlink(pid)
{:reply, :ok, drop_presence(state, pid)}
end
def handle_call({:update, pid, topic, key, meta_updater}, _from, state) when is_function(meta_updater) do
handle_update({pid, topic, key, meta_updater}, state)
end
def handle_call({:update, pid, topic, key, new_meta}, _from, state) do
handle_update({pid, topic, key, fn _ -> new_meta end}, state)
end
def handle_call(:graceful_permdown, _from, state) do
broadcast_from(state, self(), {:pub, :graceful_permdown, Replica.ref(state.replica)})
{:stop, :normal, :ok, state}
end
def handle_call({:list, _topic}, _from, state) do
{:reply, state.presences, state}
end
def handle_call(:replicas, _from, state) do
{:reply, state.replicas, state}
end
def handle_call(:unsubscribe, _from, state) do
Phoenix.PubSub.unsubscribe(state.pubsub_server, state.namespaced_topic)
{:reply, :ok, state}
end
def handle_call(:resubscribe, _from, state) do
subscribe(state.pubsub_server, state.namespaced_topic)
{:reply, :ok, state}
end
defp subscribe(pubsub_server, namespaced_topic) do
Phoenix.PubSub.subscribe(pubsub_server, namespaced_topic, link: true)
end
defp put_update(state, pid, topic, key, meta, %{phx_ref: prev_ref} = prev_meta) do
ref = random_ref()
meta =
meta
|> Map.put(:phx_ref, ref)
|> Map.put(:phx_ref_prev, prev_ref)
new_state =
state
|> report_diff_join(topic, key, meta, prev_meta)
|> put_presences(State.leave_join(state.presences, pid, topic, key, meta))
{new_state, ref}
end
defp put_presence(state, pid, topic, key, meta, prev_meta \\ nil) do
Process.link(pid)
ref = random_ref()
meta = Map.put(meta, :phx_ref, ref)
new_state =
state
|> report_diff_join(topic, key, meta, prev_meta)
|> put_presences(State.join(state.presences, pid, topic, key, meta))
{new_state, ref}
end
defp put_presences(state, %State{} = presences), do: %{state | presences: presences}
defp drop_presence(state, pid, topic, key) do
if leave = State.get_by_pid(state.presences, pid, topic, key) do
state
|> report_diff([], [leave])
|> put_presences(State.leave(state.presences, pid, topic, key))
else
state
end
end
defp drop_presence(state, pid) do
leaves = State.get_by_pid(state.presences, pid)
state
|> report_diff([], leaves)
|> put_presences(State.leave(state.presences, pid))
end
defp handle_heartbeat(state, {name, vsn}) do
case Replica.put_heartbeat(state.replicas, {name, vsn}) do
{replicas, nil, %Replica{status: :up} = upped} ->
up(%{state | replicas: replicas}, upped)
{replicas, %Replica{vsn: ^vsn, status: :up}, %Replica{vsn: ^vsn, status: :up}} ->
%{state | replicas: replicas}
{replicas, %Replica{vsn: ^vsn, status: :down}, %Replica{vsn: ^vsn, status: :up} = upped} ->
up(%{state | replicas: replicas}, upped)
{replicas, %Replica{vsn: old, status: :up} = downed, %Replica{vsn: ^vsn, status: :up} = upped} when old != vsn ->
%{state | replicas: replicas} |> down(downed) |> permdown(downed) |> up(upped)
{replicas, %Replica{vsn: old, status: :down} = downed, %Replica{vsn: ^vsn, status: :up} = upped} when old != vsn ->
%{state | replicas: replicas} |> permdown(downed) |> up(upped)
end
end
defp request_transfer_from_replicas_needing_synced(%{current_sample_count: 1} = state) do
needs_synced = clockset_to_sync(state)
for replica <- needs_synced, do: request_transfer(state, replica)
%{state | pending_clockset: [], current_sample_count: state.clock_sample_periods}
end
defp request_transfer_from_replicas_needing_synced(state) do
%{state | current_sample_count: state.current_sample_count - 1}
end
defp request_transfer(state, {name, _vsn}) do
log state, fn -> "#{state.replica.name}: transfer_req from #{name}" end
ref = make_ref()
msg = {:pub, :transfer_req, ref, Replica.ref(state.replica), clock(state)}
direct_broadcast(state, name, msg)
end
defp detect_downs(%{permdown_period: perm_int, down_period: temp_int} = state) do
Enum.reduce(state.replicas, state, fn {_name, replica}, acc ->
case Replica.detect_down(acc.replicas, replica, temp_int, perm_int) do
{replicas, %Replica{status: :up}, %Replica{status: :permdown} = down_rep} ->
%{acc | replicas: replicas} |> down(down_rep) |> permdown(down_rep)
{replicas, %Replica{status: :down}, %Replica{status: :permdown} = down_rep} ->
permdown(%{acc | replicas: replicas}, down_rep)
{replicas, %Replica{status: :up}, %Replica{status: :down} = down_rep} ->
down(%{acc | replicas: replicas}, down_rep)
{replicas, %Replica{status: unchanged}, %Replica{status: unchanged}} ->
%{acc | replicas: replicas}
end
end)
end
defp schedule_next_heartbeat(state) do
Process.send_after(self(), :heartbeat, state.broadcast_period)
state
end
defp clock(state), do: State.clocks(state.presences)
@spec clockset_to_sync(t) :: [State.name]
defp clockset_to_sync(state) do
my_ref = Replica.ref(state.replica)
state.pending_clockset
|> Clock.append_clock(clock(state))
|> Clock.clockset_replicas()
|> Enum.filter(fn ref -> ref != my_ref end)
end
defp put_pending_clock(state, clocks) do
%{state | pending_clockset: Clock.append_clock(state.pending_clockset, clocks)}
end
defp up(state, remote_replica) do
report_event(state, {:replica_up, remote_replica.name})
log state, fn -> "#{state.replica.name}: replica up from #{inspect remote_replica.name}" end
{presences, joined, []} = State.replica_up(state.presences, Replica.ref(remote_replica))
state
|> report_diff(joined, [])
|> put_presences(presences)
end
defp down(state, remote_replica) do
report_event(state, {:replica_down, remote_replica.name})
log state, fn -> "#{state.replica.name}: replica down from #{inspect remote_replica.name}" end
{presences, [], left} = State.replica_down(state.presences, Replica.ref(remote_replica))
state
|> report_diff([], left)
|> put_presences(presences)
end
defp permdown(state, %Replica{name: name} = remote_replica) do
report_event(state, {:replica_permdown, name})
log state, fn -> "#{state.replica.name}: permanent replica down detected #{name}" end
replica_ref = Replica.ref(remote_replica)
presences = State.remove_down_replicas(state.presences, replica_ref)
deltas = DeltaGeneration.remove_down_replicas(state.deltas, replica_ref)
case Replica.fetch_by_ref(state.replicas, replica_ref) do
{:ok, _replica} ->
replicas = Map.delete(state.replicas, name)
%{state | presences: presences, replicas: replicas, deltas: deltas}
_ ->
%{state | presences: presences, deltas: deltas}
end
end
defp report_event(%{report_events_to: nil}, _event), do: :ok
defp report_event(%{report_events_to: pid} = state, event) do
send(pid, {event, state.replica.name})
end
defp namespaced_topic(shard_name) do
"phx_presence:#{shard_name}"
end
defp broadcast_from(state, from, msg) do
Phoenix.PubSub.broadcast_from!(state.pubsub_server, from, state.namespaced_topic, msg)
end
defp direct_broadcast(state, target_node, msg) do
Phoenix.PubSub.direct_broadcast!(target_node, state.pubsub_server, state.namespaced_topic, msg)
end
defp broadcast_delta_heartbeat(%{presences: presences} = state) do
cond do
State.has_delta?(presences) ->
delta = presences.delta
new_presences = presences |> State.reset_delta() |> State.compact()
broadcast_from(state, self(), {:pub, :heartbeat, Replica.ref(state.replica), delta, clock(state)})
%{state | presences: new_presences, silent_periods: 0}
|> push_delta_generation(delta)
state.silent_periods >= state.max_silent_periods ->
broadcast_from(state, self(), {:pub, :heartbeat, Replica.ref(state.replica), :empty, clock(state)})
%{state | silent_periods: 0}
true -> update_in(state.silent_periods, &(&1 + 1))
end
end
defp report_diff(state, [], []), do: state
defp report_diff(state, joined, left) do
join_diff = Enum.reduce(joined, %{}, fn {{topic, _pid, key}, meta, _}, acc ->
Map.update(acc, topic, {[{key, meta}], []}, fn {joins, leaves} ->
{[{key, meta} | joins], leaves}
end)
end)
full_diff = Enum.reduce(left, join_diff, fn {{topic, _pid, key}, meta, _}, acc ->
Map.update(acc, topic, {[], [{key, meta}]}, fn {joins, leaves} ->
{joins, [{key, meta} | leaves]}
end)
end)
full_diff
|> state.tracker.handle_diff(state.tracker_state)
|> handle_tracker_result(state)
end
defp report_diff_join(state, topic, key, meta, nil = _prev_meta) do
%{topic => {[{key, meta}], []}}
|> state.tracker.handle_diff(state.tracker_state)
|> handle_tracker_result(state)
end
defp report_diff_join(state, topic, key, meta, prev_meta) do
%{topic => {[{key, meta}], [{key, prev_meta}]}}
|> state.tracker.handle_diff(state.tracker_state)
|> handle_tracker_result(state)
end
defp handle_tracker_result({:ok, tracker_state}, state) do
%{state | tracker_state: tracker_state}
end
defp handle_tracker_result(other, state) do
raise ArgumentError, """
expected #{state.tracker}.handle_diff/2 to return {:ok, state}, but got:
#{inspect other}
"""
end
defp handle_update({pid, topic, key, meta_updater}, state) do
case State.get_by_pid(state.presences, pid, topic, key) do
nil ->
{:reply, {:error, :nopresence}, state}
{{_topic, _pid, ^key}, prev_meta, {_replica, _}} ->
{state, ref} = put_update(state, pid, topic, key, meta_updater.(prev_meta), prev_meta)
{:reply, {:ok, ref}, state}
end
end
defp push_delta_generation(state, {%State{mode: :normal}, _}) do
%{state | deltas: []}
end
defp push_delta_generation(%{deltas: deltas} = state, %State{mode: :delta} = delta) do
new_deltas = DeltaGeneration.push(state.presences, deltas, delta, state.max_delta_sizes)
%{state | deltas: new_deltas}
end
defp random_ref() do
binary = <<
System.system_time(:nanosecond)::64,
:erlang.phash2({node(), self()})::16,
:erlang.unique_integer()::16
>>
Base.url_encode64(binary)
end
defp log(%{log_level: false}, _msg_func), do: :ok
defp log(%{log_level: level}, msg), do: Logger.log(level, msg)
end

View File

@@ -0,0 +1,20 @@
defmodule Phoenix.Tracker.ShutdownHandler do
@moduledoc false
use GenServer
def start_link(tracker) do
GenServer.start_link(__MODULE__, tracker, name: __MODULE__)
end
@impl GenServer
def init(tracker) do
Process.flag(:trap_exit, true)
{:ok, tracker}
end
@impl GenServer
def terminate(_reason, tracker) do
Phoenix.Tracker.graceful_permdown(tracker)
:ok
end
end

View File

@@ -0,0 +1,687 @@
defmodule Phoenix.Tracker.State do
@moduledoc false
alias Phoenix.Tracker.{State, Clock}
@type name :: term
@type topic :: String.t()
@type key :: term
@type meta :: map
@type ets_id :: :ets.tid()
@type clock :: pos_integer
@type tag :: {name, clock}
@type cloud :: MapSet.t()
@type clouds :: %{name => cloud}
@type context :: %{name => clock}
@type values :: ets_id | :extracted | %{tag => {pid, topic, key, meta}}
@type value :: {{topic, pid, key}, meta, tag}
@type key_meta :: {key, meta}
@type delta :: %State{mode: :delta}
@type pid_lookup :: {pid, topic, key}
@type t :: %State{
replica: name,
context: context,
clouds: clouds,
values: values,
pids: ets_id,
mode: :unset | :delta | :normal,
delta: :unset | delta,
replicas: %{name => :up | :down},
range: {context, context}
}
defstruct replica: nil,
context: %{},
clouds: %{},
values: nil,
pids: nil,
mode: :unset,
delta: :unset,
replicas: %{},
range: {%{}, %{}}
@compile {:inline, tag: 1, clock: 1, put_tag: 2, delete_tag: 2, remove_delta_tag: 2}
@doc """
Creates a new set for the replica.
## Examples
iex> Phoenix.Tracker.State.new(:replica1, :shard_name)
%Phoenix.Tracker.State{...}
"""
@spec new(name, atom) :: t
def new(replica, shard_name) do
reset_delta(%State{
replica: replica,
context: %{replica => 0},
mode: :normal,
values: :ets.new(shard_name, [:named_table, :protected, :ordered_set]),
pids: :ets.new(:pids, [:duplicate_bag]),
replicas: %{replica => :up}
})
end
@doc """
Returns the causal context for the set.
"""
@spec clocks(t) :: {name, context}
def clocks(%State{replica: rep, context: ctx} = state) do
# Exclude down replicas from clocks as they are also not included in
# deltas. Otherwise if this node knows of a down node X in permdown grace
# period, another node Y which came up after X went down will keep
# requesting full state from this node as the clock of Y will be dominated
# by the clock of this node.
{rep, Map.drop(ctx, down_replicas(state))}
end
@doc """
Adds a new element to the set.
"""
@spec join(t, pid, topic, key, meta) :: t
def join(%State{} = state, pid, topic, key, meta \\ %{}) do
add(state, pid, topic, key, meta)
end
@doc """
Updates an element via leave and join.
Atomically updates ETS local entry.
"""
@spec leave_join(t, pid, topic, key, meta) :: t
def leave_join(%State{} = state, pid, topic, key, meta) do
# Produce remove-like delta
[{{^topic, ^pid, ^key}, _meta, tag}] = :ets.lookup(state.values, {topic, pid, key})
pruned_clouds = delete_tag(state.clouds, tag)
new_delta = remove_delta_tag(state.delta, tag)
state = bump_clock(%{state | clouds: pruned_clouds, delta: new_delta})
# Update ETS entry and produce add-like delta
%State{} = state = bump_clock(state)
tag = tag(state)
true = :ets.insert(state.values, {{topic, pid, key}, meta, tag})
put_in(state.delta.values[tag], {pid, topic, key, meta})
end
@doc """
Removes an element from the set.
"""
@spec leave(t, pid, topic, key) :: t
def leave(%State{pids: pids} = state, pid, topic, key) do
pids
|> :ets.match_object({pid, topic, key})
|> case do
[{^pid, ^topic, ^key}] -> remove(state, pid, topic, key)
[] -> state
end
end
@doc """
Removes all elements from the set for the given pid.
"""
@spec leave(t, pid) :: t
def leave(%State{pids: pids} = state, pid) do
pids
|> :ets.lookup(pid)
|> Enum.reduce(state, fn {^pid, topic, key}, acc ->
remove(acc, pid, topic, key)
end)
end
@doc """
Returns a list of elements in the set belonging to an online replica.
"""
@spec online_list(t) :: [value]
def online_list(%State{values: values} = state) do
replicas = down_replicas(state)
:ets.select(values, [{{:_, :_, {:"$1", :_}}, not_in(:"$1", replicas), [:"$_"]}])
end
@doc """
Returns a list of elements for the topic who belong to an online replica.
"""
@spec get_by_topic(t, topic) :: [key_meta]
def get_by_topic(%State{values: values} = state, topic) do
tracked_values(values, topic, down_replicas(state))
end
@doc """
Returns a list of elements for the topic who belong to an online replica.
"""
@spec get_by_key(t, topic, key) :: [key_meta]
def get_by_key(%State{values: values} = state, topic, key) do
case tracked_key(values, topic, key, down_replicas(state)) do
[] -> []
[_ | _] = metas -> metas
end
end
@doc """
Performs table lookup for tracked elements in the topic.
Filters out those present on downed replicas.
"""
def tracked_values(table, topic, down_replicas) do
:ets.select(
table,
[
{{{topic, :_, :"$1"}, :"$2", {:"$3", :_}}, not_in(:"$3", down_replicas),
[{{:"$1", :"$2"}}]}
]
)
end
@doc """
Performs table lookup for tracked key in the topic.
Filters out those present on downed replicas.
"""
def tracked_key(table, topic, key, down_replicas) do
:ets.select(
table,
[
{{{topic, :"$1", key}, :"$2", {:"$3", :_}}, not_in(:"$3", down_replicas),
[{{:"$1", :"$2"}}]}
]
)
end
defp not_in(_pos, []), do: []
defp not_in(pos, replicas), do: [not: ors(pos, replicas)]
defp ors(pos, [rep]), do: {:"=:=", pos, {rep}}
defp ors(pos, [rep | rest]), do: {:or, {:"=:=", pos, {rep}}, ors(pos, rest)}
@doc """
Returns the element matching the pid, topic, and key.
"""
@spec get_by_pid(t, pid, topic, key) :: value | nil
def get_by_pid(%State{values: values}, pid, topic, key) do
case :ets.lookup(values, {topic, pid, key}) do
[] -> nil
[one] -> one
end
end
@doc """
Returns all elements for the pid.
"""
@spec get_by_pid(t, pid) :: [value]
def get_by_pid(%State{pids: pids, values: values}, pid) do
case :ets.lookup(pids, pid) do
[] ->
[]
matches ->
:ets.select(
values,
Enum.map(matches, fn {^pid, topic, key} ->
{{{topic, pid, key}, :_, :_}, [], [:"$_"]}
end)
)
end
end
@doc """
Checks if set has a non-empty delta.
"""
@spec has_delta?(t) :: boolean
def has_delta?(%State{delta: %State{clouds: clouds}}) do
Enum.find(clouds, fn {_name, cloud} -> MapSet.size(cloud) != 0 end)
end
@doc """
Resets the set's delta.
"""
@spec reset_delta(t) :: t
def reset_delta(%State{context: ctx, replica: replica} = state) do
delta_ctx = Map.take(ctx, [replica])
delta = %State{replica: replica, values: %{}, range: {delta_ctx, delta_ctx}, mode: :delta}
%{state | delta: delta}
end
@doc """
Extracts the set's elements from ets into a mergeable list.
Used when merging two sets.
"""
@spec extract(t, remote_ref :: name, context) :: t | {t, values}
def extract(
%State{mode: :delta, values: values, clouds: clouds} = state,
remote_ref,
remote_context
) do
{start_ctx, end_ctx} = state.range
known_keys = Map.keys(remote_context)
pruned_clouds = Map.take(clouds, known_keys)
pruned_start = Map.take(start_ctx, known_keys)
pruned_end = Map.take(end_ctx, known_keys)
map =
Enum.reduce(values, [], fn
{{^remote_ref, _clock}, _data}, acc ->
acc
{{replica, _clock} = tag, data}, acc ->
if Map.has_key?(remote_context, replica) do
[{tag, data} | acc]
else
acc
end
end)
|> :maps.from_list()
%{state | values: map, clouds: pruned_clouds, range: {pruned_start, pruned_end}}
end
def extract(
%State{mode: :normal, values: values, clouds: clouds} = state,
remote_ref,
remote_context
) do
known_keys = Map.keys(remote_context)
pruned_clouds = Map.take(clouds, known_keys)
pruned_context = Map.take(state.context, known_keys)
# fn {{topic, pid, key}, meta, {replica, clock}} when replica !== remote_ref ->
# {{replica, clock}, {pid, topic, key, meta}}
# end
ms = [
{
{{:"$1", :"$2", :"$3"}, :"$4", {:"$5", :"$6"}},
[{:"=/=", :"$5", {:const, remote_ref}}],
[{{{{:"$5", :"$6"}}, {{:"$2", :"$1", :"$3", :"$4"}}}}]
}
]
data =
foldl(values, [], ms, fn {{replica, _} = tag, data}, acc ->
if match?(%{^replica => _}, remote_context) do
[{tag, data} | acc]
else
acc
end
end)
{%{
state
| clouds: pruned_clouds,
context: pruned_context,
pids: nil,
values: nil,
delta: :unset
}, Map.new(data)}
end
@doc """
Merges two sets, or a delta into a set.
Returns a 3-tuple of the updated set, and the joined and left elements.
## Examples
iex> {s1, joined, left} =
Phoenix.Tracker.State.merge(s1, Phoenix.Tracker.State.extract(s2))
{%Phoenix.Tracker.State{}, [...], [...]}
"""
@spec merge(local :: t, {remote :: t, values} | delta) ::
{new_local :: t, joins :: [value], leaves :: [value]}
def merge(%State{} = local, %State{mode: :delta, values: remote_map} = remote) do
merge(local, remote, remote_map)
end
def merge(%State{} = local, {%State{} = remote, remote_map}) do
merge(local, remote, remote_map)
end
defp merge(%State{} = local, remote, remote_map) do
{added_pids, joins} = accumulate_joins(local, remote_map)
{clouds, delta, leaves, removed_pids} = observe_removes(local, remote, remote_map)
# We diff ETS deletes and inserts, this way if there is an update
# operation (leave + join) we handle it atomically via insert into
# the :ordered_set table
added_value_keys = for {value_key, _meta, _tag} <- joins, do: value_key
removed_value_keys = for {value_key, _meta, _tag} <- leaves, do: value_key
value_keys_to_remove = removed_value_keys -- added_value_keys
pids_to_remove = removed_pids -- added_pids
pids_to_add = added_pids -- removed_pids
for value_key <- value_keys_to_remove do
:ets.delete(local.values, value_key)
end
for pid <- pids_to_remove do
:ets.match_delete(local.pids, pid)
end
true = :ets.insert(local.values, joins)
true = :ets.insert(local.pids, pids_to_add)
known_remote_context = Map.take(remote.context, Map.keys(local.context))
ctx = Clock.upperbound(local.context, known_remote_context)
new_state =
%{local | clouds: clouds, delta: delta}
|> put_context(ctx)
|> compact()
{new_state, joins, leaves}
end
@spec accumulate_joins(t, values) :: joins :: {[pid_lookup], [values]}
defp accumulate_joins(local, remote_map) do
%State{context: context, clouds: clouds} = local
Enum.reduce(remote_map, {[], []}, fn {{replica, _} = tag, {pid, topic, key, meta}},
{pids, adds} ->
if not match?(%{^replica => _}, context) or in?(context, clouds, tag) do
{pids, adds}
else
{[{pid, topic, key} | pids], [{{topic, pid, key}, meta, tag} | adds]}
end
end)
end
@spec observe_removes(t, t, map) ::
{clouds, delta, leaves :: [value], removed_pids :: [pid_lookup]}
defp observe_removes(%State{values: values, delta: delta} = local, remote, remote_map) do
unioned_clouds = union_clouds(local, remote)
%State{context: remote_context, clouds: remote_clouds} = remote
init = {unioned_clouds, delta, [], []}
local_replica = local.replica
# fn {_, _, {replica, _}} = result when replica != local_replica -> result end
ms = [
{
{:_, :_, {:"$1", :_}},
[{:"/=", :"$1", {:const, local_replica}}],
[:"$_"]
}
]
foldl(values, init, ms, fn {{topic, pid, key}, _, tag} = el,
{clouds, delta, leaves, removed_pids} ->
if not match?(%{^tag => _}, remote_map) and in?(remote_context, remote_clouds, tag) do
{delete_tag(clouds, tag), remove_delta_tag(delta, tag), [el | leaves],
[{pid, topic, key} | removed_pids]}
else
{clouds, delta, leaves, removed_pids}
end
end)
end
defp put_tag(clouds, {name, _clock} = tag) do
case clouds do
%{^name => cloud} -> %{clouds | name => MapSet.put(cloud, tag)}
_ -> Map.put(clouds, name, MapSet.new([tag]))
end
end
defp delete_tag(clouds, {name, _clock} = tag) do
case clouds do
%{^name => cloud} -> %{clouds | name => MapSet.delete(cloud, tag)}
_ -> clouds
end
end
defp union_clouds(%State{mode: :delta} = local, %State{} = remote) do
Enum.reduce(remote.clouds, local.clouds, fn {name, remote_cloud}, acc ->
Map.update(acc, name, remote_cloud, &MapSet.union(&1, remote_cloud))
end)
end
defp union_clouds(%State{mode: :normal, context: local_ctx} = local, %State{} = remote) do
Enum.reduce(remote.clouds, local.clouds, fn {name, remote_cloud}, acc ->
if Map.has_key?(local_ctx, name) do
Map.update(acc, name, remote_cloud, &MapSet.union(&1, remote_cloud))
else
acc
end
end)
end
def merge_deltas(
%State{mode: :delta} = local,
%State{mode: :delta, values: remote_values} = remote
) do
%{
values: local_values,
range: {local_start, local_end},
context: local_context,
clouds: local_clouds
} = local
%{range: {remote_start, remote_end}, context: remote_context, clouds: remote_clouds} = remote
if (Clock.dominates_or_equal?(remote_end, local_start) and
Clock.dominates_or_equal?(local_end, remote_start)) or
(Clock.dominates_or_equal?(local_end, remote_start) and
Clock.dominates_or_equal?(remote_end, local_start)) do
new_start = Clock.lowerbound(local_start, remote_start)
new_end = Clock.upperbound(local_end, remote_end)
clouds = union_clouds(local, remote)
filtered_locals =
for {tag, value} <- local_values,
match?(%{^tag => _}, remote_values) or not in?(remote_context, remote_clouds, tag),
do: {tag, value}
merged_vals =
for {tag, value} <- remote_values,
not match?(%{^tag => _}, local_values) and not in?(local_context, local_clouds, tag),
do: {tag, value}
all_vals = filtered_locals ++ merged_vals
{:ok, %{local | clouds: clouds, values: Map.new(all_vals), range: {new_start, new_end}}}
else
{:error, :not_contiguous}
end
end
@doc """
Marks a replica as up in the set and returns rejoined users.
"""
@spec replica_up(t, name) :: {t, joins :: [values], leaves :: []}
def replica_up(%State{replicas: replicas, context: ctx} = state, replica) do
{%{state | context: Map.put_new(ctx, replica, 0), replicas: Map.put(replicas, replica, :up)},
replica_users(state, replica), []}
end
@doc """
Marks a replica as down in the set and returns left users.
"""
@spec replica_down(t, name) :: {t, joins :: [], leaves :: [values]}
def replica_down(%State{replicas: replicas} = state, replica) do
{%{state | replicas: Map.put(replicas, replica, :down)}, [], replica_users(state, replica)}
end
@doc """
Removes all elements for replicas that are permanently gone.
"""
@spec remove_down_replicas(t, name) :: t
def remove_down_replicas(
%State{mode: :normal, context: ctx, values: values, pids: pids} = state,
replica
) do
new_ctx = Map.delete(ctx, replica)
# fn {key, _, {^replica, _}} -> key end
ms = [{{:"$1", :_, {replica, :_}}, [], [:"$1"]}]
foldl(values, nil, ms, fn {topic, pid, key} = values_key, _ ->
:ets.delete(values, values_key)
:ets.match_delete(pids, {pid, topic, key})
nil
end)
new_clouds = Map.delete(state.clouds, replica)
new_delta = remove_down_replicas(state.delta, replica)
%{state | context: new_ctx, clouds: new_clouds, delta: new_delta}
end
def remove_down_replicas(%State{mode: :delta, range: range} = delta, replica) do
{start_ctx, end_ctx} = range
new_start = Map.delete(start_ctx, replica)
new_end = Map.delete(end_ctx, replica)
new_clouds = Map.delete(delta.clouds, replica)
new_vals =
Enum.reduce(delta.values, delta.values, fn
{{^replica, _clock} = tag, {_pid, _topic, _key, _meta}}, vals ->
Map.delete(vals, tag)
{{_replica, _clock} = _tag, {_pid, _topic, _key, _meta}}, vals ->
vals
end)
%{delta | range: {new_start, new_end}, clouds: new_clouds, values: new_vals}
end
@doc """
Returns the dize of the delta.
"""
@spec delta_size(delta) :: pos_integer
def delta_size(%State{mode: :delta, clouds: clouds, values: values}) do
Enum.reduce(clouds, map_size(values), fn {_name, cloud}, sum ->
sum + MapSet.size(cloud)
end)
end
@spec add(t, pid, topic, key, meta) :: t
defp add(%State{} = state, pid, topic, key, meta) do
state
|> bump_clock()
|> do_add(pid, topic, key, meta)
end
defp do_add(%State{} = state, pid, topic, key, meta) do
tag = tag(state)
true = :ets.insert(state.values, {{topic, pid, key}, meta, tag})
true = :ets.insert(state.pids, {pid, topic, key})
put_in(state.delta.values[tag], {pid, topic, key, meta})
end
@spec remove(t, pid, topic, key) :: t
defp remove(%State{pids: pids, values: values} = state, pid, topic, key) do
[{{^topic, ^pid, ^key}, _meta, tag}] = :ets.lookup(values, {topic, pid, key})
1 = :ets.select_delete(values, [{{{topic, pid, key}, :_, :_}, [], [true]}])
1 = :ets.select_delete(pids, [{{pid, topic, key}, [], [true]}])
pruned_clouds = delete_tag(state.clouds, tag)
new_delta = remove_delta_tag(state.delta, tag)
bump_clock(%{state | clouds: pruned_clouds, delta: new_delta})
end
@spec remove_delta_tag(delta, tag) :: delta
defp remove_delta_tag(%{mode: :delta, values: values, clouds: clouds} = delta, tag) do
%{delta | clouds: put_tag(clouds, tag), values: Map.delete(values, tag)}
end
@doc """
Compacts a sets causal history.
Called as needed and after merges.
"""
@spec compact(t) :: t
def compact(%State{context: ctx, clouds: clouds} = state) do
{new_ctx, new_clouds} =
Enum.reduce(clouds, {ctx, clouds}, fn {name, cloud}, {ctx_acc, clouds_acc} ->
{new_ctx, new_cloud} = do_compact(ctx_acc, Enum.sort(MapSet.to_list(cloud)))
{new_ctx, Map.put(clouds_acc, name, MapSet.new(new_cloud))}
end)
put_context(%{state | clouds: new_clouds}, new_ctx)
end
@spec do_compact(context, sorted_cloud_list :: list) :: {context, cloud}
defp do_compact(ctx, cloud) do
Enum.reduce(cloud, {ctx, []}, fn {replica, clock} = tag, {ctx_acc, cloud_acc} ->
case ctx_acc do
%{^replica => ctx_clock} when ctx_clock + 1 == clock ->
{%{ctx_acc | replica => clock}, cloud_acc}
%{^replica => ctx_clock} when ctx_clock >= clock ->
{ctx_acc, cloud_acc}
_ when clock == 1 ->
{Map.put(ctx_acc, replica, clock), cloud_acc}
_ ->
{ctx_acc, [tag | cloud_acc]}
end
end)
end
@compile {:inline, in?: 3, in_ctx?: 3, in_clouds?: 3}
defp in?(context, clouds, {replica, clock} = tag) do
in_ctx?(context, replica, clock) or in_clouds?(clouds, replica, tag)
end
defp in_ctx?(ctx, replica, clock) do
case ctx do
%{^replica => replica_clock} -> replica_clock >= clock
_ -> false
end
end
defp in_clouds?(clouds, replica, tag) do
case clouds do
%{^replica => cloud} -> MapSet.member?(cloud, tag)
_ -> false
end
end
@spec tag(t) :: tag
defp tag(%State{replica: rep} = state), do: {rep, clock(state)}
@spec clock(t) :: clock
defp clock(%State{replica: rep, context: ctx}), do: Map.get(ctx, rep, 0)
@spec bump_clock(t) :: t
defp bump_clock(
%State{mode: :normal, replica: rep, clouds: clouds, context: ctx, delta: delta} = state
) do
new_clock = clock(state) + 1
new_ctx = Map.put(ctx, rep, new_clock)
%{
state
| clouds: put_tag(clouds, {rep, new_clock}),
delta: %{delta | clouds: put_tag(delta.clouds, {rep, new_clock})}
}
|> put_context(new_ctx)
end
defp put_context(%State{delta: delta, replica: rep} = state, new_ctx) do
{start_clock, end_clock} = delta.range
new_end = Map.put(end_clock, rep, Map.get(new_ctx, rep, 0))
%{state | context: new_ctx, delta: %{delta | range: {start_clock, new_end}}}
end
@spec down_replicas(t) :: [name]
defp down_replicas(%State{replicas: replicas}) do
for {replica, :down} <- replicas, do: replica
end
@spec replica_users(t, name) :: [value]
defp replica_users(%State{values: values}, replica) do
:ets.match_object(values, {:_, :_, {replica, :_}})
end
@fold_batch_size 1000
defp foldl(table, initial, ms, func) do
foldl(:ets.select(table, ms, @fold_batch_size), initial, func)
end
defp foldl(:"$end_of_table", acc, _func), do: acc
defp foldl({objects, cont}, acc, func) do
foldl(:ets.select(cont), Enum.reduce(objects, acc, func), func)
end
end

View File

@@ -0,0 +1,87 @@
defmodule Phoenix.PubSub.Mixfile do
use Mix.Project
@version "2.2.0"
def project do
[
app: :phoenix_pubsub,
version: @version,
elixir: "~> 1.6",
name: "Phoenix.PubSub",
description: "Distributed PubSub and Presence platform",
homepage_url: "http://www.phoenixframework.org",
elixirc_paths: elixirc_paths(Mix.env()),
package: package(),
docs: docs(),
deps: deps()
]
end
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
def application do
[
mod: {Phoenix.PubSub.Application, []},
extra_applications: [:logger, :crypto]
]
end
defp deps do
[
{:ex_doc, ">= 0.0.0", only: :docs}
]
end
defp package do
[
maintainers: ["Chris McCord", "José Valim", "Alexander Songe", "Gary Rennie"],
licenses: ["MIT"],
links: %{github: "https://github.com/phoenixframework/phoenix_pubsub"},
files: ~w(lib test/shared CHANGELOG.md LICENSE.md mix.exs README.md)
]
end
defp docs do
[
main: "Phoenix.PubSub",
source_ref: "v#{@version}",
source_url: "https://github.com/phoenixframework/phoenix_pubsub",
before_closing_body_tag: %{
html: """
<script defer src="https://cdn.jsdelivr.net/npm/mermaid@11.6.0/dist/mermaid.min.js"></script>
<script>
let initialized = false;
window.addEventListener("exdoc:loaded", () => {
if (!initialized) {
mermaid.initialize({
startOnLoad: false,
theme: document.body.className.includes("dark") ? "dark" : "default"
});
initialized = true;
}
let id = 0;
for (const codeEl of document.querySelectorAll("pre code.mermaid")) {
const preEl = codeEl.parentElement;
const graphDefinition = codeEl.textContent;
const graphEl = document.createElement("div");
const graphId = "mermaid-graph-" + id++;
mermaid.render(graphId, graphDefinition).then(({svg, bindFunctions}) => {
graphEl.innerHTML = svg;
bindFunctions?.(graphEl);
preEl.insertAdjacentElement("afterend", graphEl);
preEl.remove();
});
}
});
</script>
""",
epub: ""
}
]
end
end

View File

@@ -0,0 +1,198 @@
defmodule Phoenix.PubSubTest do
@moduledoc """
Sets up PubSub Adapter testcases.
## Usage
To test a PubSub adapter, set the `:test_adapter` on the `:phoenix_pubsub`
configuration and require this file, ie:
# your_pubsub_adapter_test.exs
Application.put_env(:phoenix_pubsub, :test_adapter, {Phoenix.PubSub.PG2, []})
Code.require_file "../deps/phoenix_pubsub/test/shared/pubsub_test.exs", __DIR__
"""
use ExUnit.Case, async: true
alias Phoenix.PubSub
defp subscribers(config, topic) do
Registry.lookup(config.pubsub, topic)
end
defp rpc(pid, func) do
Agent.get(pid, fn :ok -> func.() end)
end
defp spawn_pid do
{:ok, pid} = Agent.start_link(fn -> :ok end)
pid
end
defmodule CustomDispatcher do
def dispatch(entries, from, message) do
for {pid, metadata} <- entries do
send(pid, {:custom, metadata, from, message})
end
:ok
end
end
setup config do
size = config[:pool_size] || 1
registry_size = config[:registry_size] || config[:registry_pool_size] || config[:pool_size] || 1
{adapter, adapter_opts} = Application.get_env(:phoenix_pubsub, :test_adapter)
adapter_opts = [adapter: adapter, name: config.test, pool_size: size, registry_size: registry_size] ++ adapter_opts
start_supervised!({Phoenix.PubSub, adapter_opts})
opts = %{
pubsub: config.test,
topic: to_string(config.test),
pool_size: size,
node: Phoenix.PubSub.node_name(config.test),
adapter_name: Module.concat(config.test, "Adapter")
}
{:ok, opts}
end
test "node_name/1 returns the node name", config do
assert is_atom(config.node) or is_binary(config.node)
end
for size <- [1, 8] do
@tag pool_size: size
test "pool #{size}: subscribe and unsubscribe", config do
pid = spawn_pid()
assert subscribers(config, config.topic) |> length == 0
assert rpc(pid, fn -> PubSub.subscribe(config.pubsub, config.topic) end)
assert subscribers(config, config.topic) == [{pid, nil}]
assert rpc(pid, fn -> PubSub.unsubscribe(config.pubsub, config.topic) end)
assert subscribers(config, config.topic) |> length == 0
end
@tag pool_size: size
test "pool #{size}: broadcast/3 and broadcast!/3 publishes message to each subscriber",
config do
PubSub.subscribe(config.pubsub, config.topic)
:ok = PubSub.broadcast(config.pubsub, config.topic, :ping)
assert_receive :ping
:ok = PubSub.broadcast!(config.pubsub, config.topic, :ping)
assert_receive :ping
end
@tag pool_size: size
test "pool #{size}: broadcast/3 does not publish message to other topic subscribers",
config do
PubSub.subscribe(config.pubsub, "unknown")
Enum.each(0..10, fn _ ->
rpc(spawn_pid(), fn -> PubSub.subscribe(config.pubsub, config.topic) end)
end)
:ok = PubSub.broadcast(config.pubsub, config.topic, :ping)
refute_received :ping
end
@tag pool_size: size
test "pool #{size}: broadcast_from/4 and broadcast_from!/4 skips sender", config do
PubSub.subscribe(config.pubsub, config.topic)
PubSub.broadcast_from(config.pubsub, self(), config.topic, :ping)
refute_received :ping
PubSub.broadcast_from!(config.pubsub, self(), config.topic, :ping)
refute_received :ping
end
@tag pool_size: size
test "pool #{size}: unsubscribe on not subscribed topic noops", config do
assert :ok = PubSub.unsubscribe(config.pubsub, config.topic)
assert subscribers(config, config.topic) == []
end
@tag pool_size: size
test "pool #{size}: direct_broadcast sends to given node", config do
PubSub.subscribe(config.pubsub, config.topic)
PubSub.direct_broadcast(config.node, config.pubsub, config.topic, :ping)
assert_receive :ping
PubSub.direct_broadcast!(config.node, config.pubsub, config.topic, :ping)
assert_receive :ping
end
@tag pool_size: size
test "pool #{size}: direct_broadcast sends to unknown node", config do
PubSub.subscribe(config.pubsub, config.topic)
PubSub.direct_broadcast(:"IDONTKNOW@127.0.0.1", config.pubsub, config.topic, :ping)
refute_received :ping
PubSub.direct_broadcast!(:"IDONTKNOW@127.0.0.1", config.pubsub, config.topic, :ping)
refute_received :ping
end
@tag pool_size: size
test "pool #{size}: local_broadcast sends to the current node", config do
PubSub.subscribe(config.pubsub, config.topic)
PubSub.local_broadcast(config.pubsub, config.topic, :ping)
assert_receive :ping
end
@tag pool_size: size
test "pool #{size}: local_broadcast_from/5 skips sender", config do
PubSub.subscribe(config.pubsub, config.topic)
PubSub.local_broadcast_from(config.pubsub, self(), config.topic, :ping)
refute_received :ping
end
@tag pool_size: size
test "pool #{size}: with custom dispatching", %{topic: topic, test: test, node: node} do
PubSub.subscribe(test, topic)
PubSub.subscribe(test, topic, metadata: :special)
PubSub.broadcast(test, topic, :broadcast, CustomDispatcher)
assert_receive {:custom, nil, :none, :broadcast}
assert_receive {:custom, :special, :none, :broadcast}
PubSub.broadcast_from(test, self(), topic, :broadcast_from, CustomDispatcher)
assert_receive {:custom, nil, pid, :broadcast_from} when pid == self()
assert_receive {:custom, :special, pid, :broadcast_from} when pid == self()
PubSub.local_broadcast(test, topic, :local, CustomDispatcher)
assert_receive {:custom, nil, :none, :local}
assert_receive {:custom, :special, :none, :local}
PubSub.local_broadcast_from(test, self(), topic, :local_from, CustomDispatcher)
assert_receive {:custom, nil, pid, :local_from} when pid == self()
assert_receive {:custom, :special, pid, :local_from} when pid == self()
PubSub.direct_broadcast(node, test, topic, :direct, CustomDispatcher)
assert_receive {:custom, nil, :none, :direct}
assert_receive {:custom, :special, :none, :direct}
end
end
@tag pool_size: 4
@tag registry_size: 2
test "PubSub pool size can be configured separately from the Registry partitions",
config do
assert {:duplicate, 2, _} = :ets.lookup_element(config.pubsub, -2, 2)
assert :persistent_term.get(config.adapter_name) ==
{config.adapter_name, :"#{config.adapter_name}_2", :"#{config.adapter_name}_3", :"#{config.adapter_name}_4"}
end
@tag pool_size: 3
test "Registry partitions are configured with the same pool size as PubSub if not specified",
config do
assert {:duplicate, 3, _} = :ets.lookup_element(config.pubsub, -2, 2)
assert :persistent_term.get(config.adapter_name) ==
{config.adapter_name, :"#{config.adapter_name}_2", :"#{config.adapter_name}_3"}
end
end