Current section

Files

Jump to
phoenix_sync lib phoenix sync shape.ex
Raw

lib/phoenix/sync/shape.ex

defmodule Phoenix.Sync.Shape do
@moduledoc """
Materialize a shape stream into memory and subscribe to changes.
Add a `#{inspect(__MODULE__)}` to your supervision tree to subscribe to a
sync stream and maintain a synchronized local copy of the data in-memory.
defmodule MyApp.Application do
use Application
def start(_type, _args) do
children = [
{Phoenix.Sync.Shape, [MyApp.Todo, name: MyApp.TodoShape]}
]
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
Supervisor.start_link(children, opts)
end
end
Or use `start_link/1` or `start_link/2` to start a shape process manually:
{:ok, pid} = #{inspect(__MODULE__)}.start_link(MyApp.Todo)
{:ok, pid} = #{inspect(__MODULE__)}.start_link(MyApp.Todo, replica: :full)
This allows you to subscribe to the shape and receive notifications and
also retrieve the current dataset.
`to_list/2` will return the current state of the shape as a list:
#{inspect(__MODULE__)}.to_list(MyApp.TodoShape)
[
{~s|"public"."todos"/"1"|, %MyApp.Todo{id: 1}},
{~s|"public"."todos"/"2"|, %MyApp.Todo{id: 2}},
# ...
]
`subscribe/2` registers the current process to receive change events (and
`unsubscribe/1` allows you to stop receiving them):
# use the registered `name` of the shape to receive change events
# within a GenServer or other process
def init(_args) do
ref = #{inspect(__MODULE__)}.subscribe(MyApp.TodoShape)
{:ok, ref}
end
# handle the shape events...
def handle_info({:sync, ref, {operation, {key, _value}}, ref)
when operation in [:insert, :update, :delete] do
IO.inspect([{operation, key}])
{:noreply, state}
end
def handle_info({:sync, ref, control}, ref)
when control in [:up_to_date, :must_refetch] do
IO.inspect(control: control)
{:noreply, state}
end
### Keys
The sync stream gives every value in the source table a unique key which is
defined as the full table name (with namespace) and the primary key value,
e.g. `~s|"public"."todos"/"1"|`.
If a table has a composite primary key, the key will include all primary key
values. e.g. `~s|"public"."todos"/"1"/"123"|`.
"""
@doc false
use GenServer
alias Phoenix.Sync.PredefinedShape
alias Electric.Client.Message
alias Phoenix.Sync.Shape
@type shape() :: GenServer.server()
@type key() :: String.t()
@type value() :: term()
@type tag() :: term()
@type operation() :: :insert | :update | :delete
@type control() :: :up_to_date | :must_refetch
@type payload() :: {operation(), {key(), value()}} | control()
@type event() :: {tag(), reference(), payload()}
@type subscription_msg_type() :: operation() | control()
@type subscribe_opts() :: [{:only, [subscription_msg_type()]} | {:tag, term()}]
@type enum_opts() :: [{:keys, boolean()}]
@type stream_options() :: [
unquote(NimbleOptions.option_typespec(Phoenix.Sync.PredefinedShape.schema()))
| {:name, GenServer.name()}
]
@type shape_options() :: [
Phoenix.Sync.queryable()
| stream_options()
]
@doc """
Start a new shape process that will receive the sync stream events and
maintain an in-memory copy of the dataset in sync with Postgres.
## Options
Shapes are defined exactly as for `Phoenix.Sync.Client.stream/2`:
# using an `Ecto.Schema` module
{:ok, pid} = #{inspect(__MODULE__)}.start_link(MyApp.Todo)
# or a full `Ecto.Query`
{:ok, pid} = #{inspect(__MODULE__)}.start_link(
from(t in MyApp.Todo, where: t.completed == true)
)
# we can pass extra sync options
{:ok, pid} = #{inspect(__MODULE__)}.start_link(
from(t in MyApp.Todo, where: t.completed == true),
replica: :full
)
but also accept a `:name` much like other `GenServer` processes.
{:ok, pid} = #{inspect(__MODULE__)}.start_link(MyApp.Todo, name: TodosShape)
To start a Shape within a supervision tree, you pass the options as the child
spec as if they were arguments:
children = [
{#{inspect(__MODULE__)}, [MyApp.Todo, name: TodoShape]}
]
## Options
#{Electric.Client.ShapeDefinition.schema_definition() |> NimbleOptions.new!() |> NimbleOptions.docs()}
"""
@spec start_link(shape_options()) :: GenServer.on_start()
def start_link(args) do
with {:ok, stream_args, shape_opts} <- validate_args(args) do
GenServer.start_link(
__MODULE__,
{stream_args, shape_opts},
Keyword.take(shape_opts, [:name])
)
end
end
@doc """
See `start_link/1`.
"""
@spec start_link(String.t() | Phoenix.Sync.queryable(), stream_options()) ::
GenServer.on_start()
def start_link(queryable, opts) do
start_link([queryable | opts])
end
@doc """
Subscribe the current process to the given shape and receive notifications
about new changes on the stream.
# create a shape that holds all the completed todos
{:ok, shape} = Phoenix.Sync.Shape.start_link(from(t in Todo, where: t.completed == true))
# subscribe this process to the shape events
ref = Phoenix.Sync.Shape.subscribe(shape)
receive do
{:sync, ^ref, {:insert, {_key, todo}} ->
# a todo has been marked as completed
{:sync, ^ref, {:delete, {_key, todo}} ->
# a todo has been deleted or moved from completed to not completed
{:sync, ^ref, {:update, {_key, todo}} ->
# the todo's title has been changed
{:sync, ^ref, :up_to_date} ->
# the local shape is fully synchronized with the remote server
{:sync, ^ref, :must_refetch} ->
# the local data has been invalidated and needs to be refetched
end
In the example above, changes in the `completed` state of a todo will move
the affected item into or out of the shape because it's built using a filter
that only allows completed todos. These moves into or out of the shape are
mapped to `:insert` and `:delete` events, respectively.
The message format is `{tag, reference, payload}`
- `tag` defaults to `:sync` but can be customized using the `:tag` option (see below).
- `reference` is the unique reference for the subscription returned by `subscribe/2`.
- `type` is the operation type, which can be one of `:insert`, `:update`,
`:delete`, `:up_to_date`, or `:must_refetch`.
- `:up_to_date` indicates that the shape is fully synchronized.
- `:must_refetch` indicates that the shape needs to be refetched due to a
schema change, table truncation or some other server-side change.
- `payload` depends on the operation type:
- For `:insert`, `:update` and `:delete`, it is a tuple `{operation, {key, value}}` where `key` is a unique
identifier for the value and `value` is the actual data.
- For `:up_to_date` and `:must_refetch`, it is just the operation.
## Options
- `only` - Limit events to only a subset of the available message types (see
above). If not provided, all message types will be sent. If you want to
receive all `:insert`, `:update` and `:delete` events, you can use `only:
:changes`.
- `tag` - change the tag of the message sent to subscriber. Defaults to `:sync`.
## Examples
# only receive notification when the shape is fully synchronized
ref = Phoenix.Sync.Shape.subscribe(query, only: [:up_do_date])
# only receive notification of deletes
ref = Phoenix.Sync.Shape.subscribe(query, only: [:delete])
# only receive notification when the shape has been invalidated and needs
# to be refetched. Give the message a custom tag to make it easier to
# identify the originating shape.
ref = Phoenix.Sync.Shape.subscribe(Todo, only: [:must_refetch], tag: {:sync, :todo})
"""
@spec subscribe(shape(), subscribe_opts()) :: reference()
def subscribe(shape, opts \\ []) do
GenServer.call(shape, {:subscribe, opts})
end
@doc """
Unsubscribe the current process from the given shape.
"""
@spec unsubscribe(shape()) :: :ok
def unsubscribe(shape) do
GenServer.call(shape, :unsubscribe)
end
@doc """
Get a list of the current subscribers to the given shape.
"""
@spec subscribers(shape()) :: [pid()]
def subscribers(shape) do
GenServer.call(shape, :subscribers)
end
@doc """
Get all values in the shape.
Returns a list of `{key, value}` tuples, where `key` is a unique identifier
for the value.
## Options
- `keys` (default: `true`) - if set to false, returns only the values without keys.
"""
@spec to_list(shape(), opts :: enum_opts()) :: [{key(), value()}] | [value()]
def to_list(shape, opts \\ []) do
shape
|> whereis!()
|> :ets.match_object(:_)
|> map_all(Keyword.get(opts, :keys, true))
end
defp map_all(rows, true), do: rows
defp map_all(rows, false), do: Enum.map(rows, &elem(&1, 1))
@doc """
Return a lazy stream of the current values in the shape.
{:ok, _pid} = Phoenix.Sync.Shape.start_link(MyApp.Todo, name: :todos)
stream = Phoenix.Sync.Shape.stream(:todos)
Enum.into(stream, %{})
%{
~s|"public"."todos"/"1"| => %MyApp.Todo{id: 1},
~s|"public"."todos"/"2"| => %MyApp.Todo{id: 2},
# ...
}
## Options
- `keys` (default: `true`) - if set to `false`, returns only the values without keys.
- `batch_size` (default: `100`) - the number of rows to fetch from the
backing ETS table in each iteration. Larger values are more efficient but will use
more memory.
"""
@spec stream(shape(), opts :: enum_opts()) :: Enumerable.t({key(), value()} | value())
def stream(shape, opts \\ []) do
table = whereis!(shape)
match_spec =
if Keyword.get(opts, :keys, true), do: :"$1", else: {:_, :"$1"}
batch_size = Keyword.get(opts, :batch_size, 100)
Stream.resource(
fn -> nil end,
fn
nil ->
case :ets.match(table, match_spec, batch_size) do
{match, continuation} -> {List.flatten(match), continuation}
:"$end_of_table" -> {:halt, nil}
end
:"$end_of_table" ->
{:halt, nil}
continuation ->
case :ets.match(continuation) do
{match, continuation} -> {List.flatten(match), continuation}
:"$end_of_table" -> {:halt, nil}
end
end,
fn _ -> :ok end
)
end
@doc """
Get all data in the shape as a map.
The keys of the map will be the sync stream keys.
{:ok, _pid} = Phoenix.Sync.Shape.start_link(MyApp.Todo, name: :todos)
Phoenix.Sync.Shape.to_map(:todos)
%{
~s|"public"."todos"/"1"| => %MyApp.Todo{id: 1},
~s|"public"."todos"/"2"| => %MyApp.Todo{id: 2},
# ...
}
"""
@spec to_map(shape()) :: map()
def to_map(shape) do
to_map(shape, & &1)
end
@doc """
Get all data in the shape as a map, transforming the keys and values.
{:ok, _pid} = Phoenix.Sync.Shape.start_link(MyApp.Todo, name: :todos)
Phoenix.Sync.Shape.to_map(:todos, fn {_key, %MyApp.Todo{id: id, title: title} ->
{id, title}
end)
%{
1 => "my first todo",
2 => "my second todo",
2 => "my third todo",
}
"""
@spec to_map(shape(), ({key(), value()} -> {Map.key(), Map.value()})) :: map()
def to_map(shape, transform) do
shape
|> to_list()
|> Map.new(transform)
end
@doc """
A wrapper around `Enum.find/3` that searches for a value in the shape.
The `matcher` function is only passed the shape value, not the key.
"""
@spec find(shape(), Enum.default(), (value() -> any())) :: value() | Enum.default()
def find(shape, default \\ nil, matcher) do
shape
|> stream(keys: false)
|> Enum.find(default, matcher)
end
defp whereis!(shape) do
case Shape.Registry.whereis(shape) do
{:ok, table} -> table
:error -> raise ArgumentError, "Shape #{inspect(shape)} is not registered"
end
end
@impl GenServer
def init({stream_args, _shape_opts}) do
{:ok, %{stream_pid: nil, table: nil, subscriptions: %{}},
{:continue, {:start_shape, stream_args}}}
end
@impl GenServer
def handle_continue({:start_shape, stream_args}, state) do
{:ok, table_name} = Shape.Registry.register(self())
table =
:ets.new(table_name, [:named_table, :ordered_set, :protected, read_concurrency: true])
pid = self()
stream =
try do
apply(Phoenix.Sync.Client, :stream, stream_args)
rescue
Phoenix.Sync.Sandbox.Error ->
# TODO: some way of lazily subscribing to the stream once a shared-mode sandbox
# is available.
# Emit a single up-to-date message to indicate that the shape is ready
# followed by an infinite but empty stream.
Stream.concat(
[[%Message.ControlMessage{control: :up_to_date}]],
Stream.repeatedly(fn -> [] end)
)
|> Stream.flat_map(& &1)
end
{:ok, pid} =
Task.start_link(fn ->
stream |> Stream.each(&send(pid, {:sync_message, &1})) |> Stream.run()
end)
{:noreply, %{state | stream_pid: pid, table: table}}
end
@impl GenServer
def handle_call(:subscribers, _from, state) do
{:reply, Map.keys(state.subscriptions), state}
end
def handle_call({:subscribe, opts}, {pid, _}, state) do
ref = Process.monitor(pid)
filter = build_subscription_filter(opts)
tag = Keyword.get(opts, :tag, :sync)
state = Map.update!(state, :subscriptions, &Map.put(&1, pid, {filter, tag, ref}))
{:reply, ref, state}
end
def handle_call(:unsubscribe, {pid, _}, state) do
subscriptions = Map.delete(state.subscriptions, pid)
{:reply, :ok, %{state | subscriptions: subscriptions}}
end
@impl GenServer
def handle_info({:sync_message, msg}, state) do
state = state |> handle_sync_message(msg) |> notify_subscribers(msg)
{:noreply, state}
end
def handle_info({:DOWN, _ref, :process, pid, _reason}, state) do
subscriptions = Map.delete(state.subscriptions, pid)
{:noreply, %{state | subscriptions: subscriptions}}
end
defp notify_subscribers(state, msg) do
event = msg_event(msg)
Enum.each(state.subscriptions, fn {pid, {filter, tag, ref}} ->
if filter.(msg), do: send(pid, {tag, ref, event})
end)
state
end
defp msg_event(%Message.ChangeMessage{key: key, headers: %{operation: operation}} = msg) do
{operation, {key, msg.value}}
end
defp msg_event(%Message.ControlMessage{control: control}) do
control
end
defp handle_sync_message(state, %Message.ChangeMessage{headers: %{operation: operation}} = msg) do
case operation do
:delete ->
:ets.delete(state.table, msg.key)
op when op in [:insert, :update] ->
:ets.insert(state.table, {msg.key, msg.value})
end
state
end
defp handle_sync_message(state, %Message.ControlMessage{control: :up_to_date}) do
state
end
defp handle_sync_message(state, %Message.ControlMessage{control: :must_refetch}) do
:ets.delete_all_objects(state.table)
state
end
defp build_subscription_filter(opts) do
case Keyword.fetch(opts, :only) do
{:ok, only} ->
types = List.wrap(only)
types =
types
|> Enum.flat_map(fn
:changes -> [:insert, :update, :delete]
type -> [type]
end)
|> Enum.uniq()
fn msg -> Enum.any?(types, &message_matches_type(&1, msg)) end
:error ->
fn _ -> true end
end
end
defp message_matches_type(operation, %Message.ChangeMessage{headers: %{operation: operation}}),
do: true
defp message_matches_type(control, %Message.ControlMessage{control: control}), do: true
defp message_matches_type(_, _), do: false
# shape may be of form [SchemaModule, where: "..."] which is fine syntactically but breaks Keyword functions
defp validate_args(args) when is_list(args) do
case Enum.split_with(args, &is_tuple/1) do
{opts, []} ->
{stream_opts, shape_opts} = extract_shape_opts(opts)
{:ok, [shape_opts], stream_opts}
{opts, [queryable]} ->
if PredefinedShape.is_queryable?(queryable) do
{stream_opts, shape_opts} = extract_shape_opts(opts)
{:ok, [queryable, shape_opts], stream_opts}
else
{:error, "#{queryable} is not a valid Ecto query"}
end
{_opts, [_s1, _s2 | _] = queryables} ->
{:error, "Multiple queryables provided: #{inspect(queryables)}"}
end
end
defp validate_args(queryable) do
if PredefinedShape.is_queryable?(queryable) do
{:ok, [queryable, []], []}
else
{:error, "#{queryable} is not a valid Ecto query"}
end
end
defp extract_shape_opts(args) when is_list(args) do
Keyword.split(args, [:name])
end
end