Packages
ash_graphql
1.7.13
1.10.0
1.9.4
1.9.3
1.9.2
1.9.1
1.9.0
1.8.5
1.8.4
1.8.3
1.8.2
1.8.1
1.8.0
1.7.17
1.7.16
1.7.15
1.7.14
1.7.13
1.7.12
1.7.11
1.7.10
1.7.9
1.7.8
1.7.7
1.7.6
1.7.5
1.7.4
1.7.3
1.7.2
1.7.1
1.7.0
1.6.0
1.5.1
1.5.0
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.4
1.3.3
1.3.2
1.3.1
1.3.0
1.2.1
1.2.0
1.1.1
1.1.0
1.0.1
1.0.0
1.0.0-rc.5
1.0.0-rc.4
retired
1.0.0-rc.3
1.0.0-rc.2
1.0.0-rc.1
1.0.0-rc.0
0.28.1
0.28.0
0.27.1
0.27.0
0.26.9
0.26.8
0.26.7
0.26.6
0.26.5
0.26.4
0.26.3
0.26.2
0.26.0
0.25.13
0.25.12
0.25.10
0.25.9
0.25.8
0.25.7
0.25.6
0.25.5
0.25.4
0.25.3
0.25.2
0.25.1
0.25.0
0.24.1
0.24.0
0.23.3
0.23.2
0.23.1
0.23.0
0.22.13
0.22.12
0.22.11
0.22.10
0.22.9
0.22.8
0.22.7
0.22.6
0.22.4
0.22.3
0.22.2
0.22.1
0.22.0
0.21.0
retired
0.20.5
0.20.4
0.20.3
0.20.2
0.20.1
0.20.0-rc.3
0.20.0-rc.2
0.20.0-rc.1
0.20.0-rc.0
0.19.0
0.18.0-rc0
0.17.5
0.17.5-rc0
0.17.4
0.17.2
0.17.1
0.17.0
0.16.28
0.16.27
0.16.26
0.16.25
0.16.24
0.16.23
0.16.22
0.16.21
0.16.20
0.16.18-rc5
0.16.18-rc4
0.16.18-rc3
0.16.18-rc2
0.16.18-rc1
0.16.18-rc0
0.16.17
0.16.16
0.16.15
0.16.14
0.16.13
0.16.12
0.16.11
0.16.10
0.16.9
0.16.8
0.16.7
0.16.6
0.16.5
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.10
0.15.9
0.15.8
0.15.7
0.15.6
0.15.5
0.15.4
0.15.3
0.15.2
0.15.1
0.15.0
0.14.1
0.14.0
0.13.1
0.13.0
0.12.5
0.12.4
0.12.3
0.12.1
0.12.0
0.10.0
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.0
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.3
0.6.2
0.6.1
0.6.0
0.5.0
0.4.0
0.3.2
0.3.1
0.3.0
0.2.1
0.2.0
0.1.3
0.1.2
The extension for building GraphQL APIs with Ash
Current section
Files
Jump to
Current section
Files
lib/subscription/batcher.ex
defmodule AshGraphql.Subscription.Batcher do
@moduledoc """
If started as a GenServer, this module will batch notifications and send them in bulk.
Otherwise, it will send them immediately.
"""
use GenServer
alias Absinthe.Pipeline.BatchResolver
require Logger
@compile {:inline, simulate_slowness: 0}
defstruct batches: %{},
in_progress_batches: %{},
total_count: 0,
async_limit: 100,
send_immediately_threshold: 50,
subscription_batch_interval: 1000
defmodule Batch do
@moduledoc false
defstruct notifications: [],
count: 0,
pubsub: nil,
key_strategy: nil,
doc: nil,
timer: nil
def add(batch, item) do
%{batch | notifications: [item | batch.notifications], count: batch.count + 1}
end
end
defmodule Notification do
@moduledoc false
defstruct [:action_type, :data]
end
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: opts[:name] || __MODULE__)
end
def drain do
GenServer.call(__MODULE__, :drain, :infinity)
end
def publish(topic, notification, pubsub, key_strategy, doc) do
case GenServer.call(
__MODULE__,
{:publish, topic, notification, pubsub, key_strategy, doc},
:infinity
) do
:handled ->
:ok
:backpressure_sync ->
do_send(topic, [notification], pubsub, key_strategy, doc)
end
catch
:exit, {:noproc, _} ->
do_send(topic, [notification], pubsub, key_strategy, doc)
end
@doc """
Config options
`async_limit` (default 100):
if there are more than `async_limit` notifications, we will start to backpressure
`send_immediately_threshold` (default 50):
if there are less then `send_immediately_threshold` notifications, we will send them immediately
`subscription_batch_interval` (default 1000):
the interval in milliseconds the batcher waits for new notifications before sending them
"""
def init(config) do
{:ok,
%__MODULE__{
async_limit: config[:async_limit] || 100,
send_immediately_threshold: config[:send_immediately_threshold] || 50,
subscription_batch_interval: config[:subscription_batch_interval] || 1000
}}
end
def handle_call(:drain, _from, state) do
{:reply, :done, send_all_batches(state, false)}
end
def handle_call(:dump_state, _from, state) do
{:reply, state, state}
end
def handle_call({:publish, topic, notification, pubsub, key_strategy, doc}, _from, state) do
if state.total_count >= state.async_limit do
{:reply, :backpressure_sync, state}
else
{:reply, :handled, state,
{:continue, {:publish, topic, notification, pubsub, key_strategy, doc}}}
end
end
def handle_continue({:publish, topic, notification, pubsub, key_strategy, doc}, state) do
state = put_notification(state, topic, pubsub, key_strategy, doc, notification)
# if we have less than async threshold, we can process it eagerly
if state.total_count < state.send_immediately_threshold do
# so we eagerly process current_calls
state = eagerly_build_batches(state, state.send_immediately_threshold - state.total_count)
# and if we still have less than the async threshold
if state.total_count < state.send_immediately_threshold do
# then we send all of our batches
{:noreply, send_all_batches(state, true)}
else
# otherwise we wait on the regularly scheduled push
{:noreply, ensure_timer(state, topic)}
end
else
# otherwise we wait on the regularly scheduled push
{:noreply, ensure_timer(state, topic)}
end
end
def handle_info({task, {:sent, _topic, _res}}, state) do
batch = state.in_progress_batches[task]
state =
%{
state
| total_count: state.total_count - batch.count,
in_progress_batches: Map.delete(state.in_progress_batches, task)
}
case batch do
%{timer: timer} when not is_nil(timer) ->
Process.cancel_timer(timer)
:ok
_ ->
:ok
end
{:noreply, state}
end
def handle_info({:DOWN, _, _, _, :normal}, state) do
{:noreply, state}
end
def handle_info({:send_batch, topic}, state) do
batch = state.batches[topic]
# only run one task per topic at a time
if batch do
task =
Task.async(fn ->
{:sent, topic,
do_send(topic, batch.notifications, batch.pubsub, batch.key_strategy, batch.doc)}
end)
{:noreply,
%{
state
| batches: Map.delete(state.batches, topic),
in_progress_batches: Map.put(state.in_progress_batches, task.ref, batch)
}}
else
{:noreply, state}
end
end
defp eagerly_build_batches(state, 0), do: state
defp eagerly_build_batches(state, count) do
receive do
{:"$gen_call", {:publish, topic, notification, pubsub, key_strategy, doc}, from} ->
GenServer.reply(from, :handled)
state
|> put_notification(topic, pubsub, key_strategy, doc, notification)
|> eagerly_build_batches(count - 1)
after
0 ->
state
end
end
if Application.compile_env(:ash_graphql, :simulate_subscription_slowness?, false) do
defp simulate_slowness do
:timer.sleep(Application.get_env(:ash_graphql, :simulate_subscription_processing_time, 0))
end
else
defp simulate_slowness do
:ok
end
end
defp send_all_batches(state, async?) do
state.batches
|> Enum.reduce(state, fn {topic, batch}, state ->
if batch.timer do
Process.cancel_timer(batch.timer)
end
if async? do
task =
Task.async(fn ->
{:sent, topic,
do_send(topic, batch.notifications, batch.pubsub, batch.key_strategy, batch.doc)}
end)
%{
state
| batches: Map.delete(state.batches, topic),
in_progress_batches: Map.put(state.in_progress_batches, task.ref, batch)
}
else
do_send(topic, batch.notifications, batch.pubsub, batch.key_strategy, batch.doc)
%{
state
| batches: Map.delete(state.batches, topic),
total_count: state.total_count - batch.count
}
end
end)
end
defp do_send(topic, notifications, pubsub, key_strategy, doc) do
# This is a temporary and very hacky way of doing this
# we pass in the notifications as a list as the root data
# The resolver then returns the *first* one, and puts a list
# of notifications in the process dictionary. Those will be
# passed in *again* to the resolution step, to be returned
# as-is. Its gross, and I hate it, but it is better than forcing
# individual resolution :)
simulate_slowness()
pipeline =
Absinthe.Subscription.Local.pipeline(doc, notifications)
first_results =
case Absinthe.Pipeline.run(doc.source, pipeline) do
{:ok, %{result: data}, _} ->
if should_send?(data) do
[List.wrap(data)]
else
[]
end
{:error, {_method, error}, _phase} ->
raise Ash.Error.to_error_class(error)
{:error, error, _phase} ->
raise Ash.Error.to_error_class(error)
end
result =
case List.wrap(Process.get(:batch_resolved)) do
[] ->
first_results
batch ->
batch =
Enum.map(batch, fn item ->
pipeline =
Absinthe.Subscription.Local.pipeline(doc, {:pre_resolved, item})
{:ok, %{result: data}, _} = Absinthe.Pipeline.run(doc.source, pipeline)
data
end)
[batch] ++ first_results
end
Logger.debug("""
Absinthe Subscription Publication
Field Topic: #{inspect(key_strategy)}
Subscription id: #{inspect(topic)}
Notification Count: #{Enum.count(notifications)}
""")
for batch <- result, record <- batch, not is_nil(record) do
:ok = pubsub.publish_subscription(topic, record)
end
rescue
e ->
BatchResolver.pipeline_error(e, __STACKTRACE__)
after
Process.delete(:batch_resolved)
end
defp put_notification(state, topic, pubsub, key_strategy, doc, notification) do
state.batches
|> Map.put_new_lazy(topic, fn ->
%Batch{key_strategy: key_strategy, doc: doc, pubsub: pubsub}
end)
|> Map.update!(topic, &Batch.add(&1, notification))
|> then(&%{state | batches: &1, total_count: state.total_count + 1})
end
defp ensure_timer(
%{batches: batches, subscription_batch_interval: subscription_batch_interval} = state,
topic
) do
if not is_nil(batches[topic].timer) and Process.read_timer(batches[topic].timer) do
state
else
timer =
Process.send_after(
self(),
{:send_batch, topic},
subscription_batch_interval
)
put_in(state.batches[topic].timer, timer)
end
end
defp should_send?(%{errors: errors}) do
# if the user is not allowed to see the data or the query didn't
# return any data we do not send the error to the client
# because it would just expose unnecessary information
# and the user can not really do anything usefull with it
not (errors
|> List.wrap()
|> Enum.any?(fn error -> Map.get(error, :code) in ["forbidden", "not_found", nil] end))
end
defp should_send?(_), do: true
end