Packages
x3m_system
0.6.0
0.9.1
0.9.0
0.8.6
0.8.5
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
retired
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
retired
0.7.7
0.7.6
retired
0.7.5
0.7.4
retired
0.7.3
retired
0.7.2
0.7.1
0.7.0
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
retired
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.1.1
0.1.0
Building blocks for distributed and/or CQRS/ES systems
Retired package: Renamed Scheduler behaviour in 0.6.1
Current section
Files
Jump to
Current section
Files
lib/scheduller.ex
defmodule X3m.System.Scheduller do
@moduledoc """
This behaviour should be used to schedulle `X3m.System.Message` delivery
at some point in time in the future. Implementation module should persist alarms so when process
is respawned they can be reloaded into memory.
Not all schedulled alarms are kept in memory. They are loaded in bulks
each `in_memory_interval/0` milliseconds for next `2 * in_memory_interval/0` milliseconds.
If message with it's `X3m.System.Message.id` is already in memory (and schedulled for delivery)
it is ignored.
When message is processed, `service_responded/2` callback is invoked. It should return either
`:ok` or `{:retry, in_milliseconds, X3m.System.Message}` if returned message should be redelivered
in specified number of milliseconds.
"""
@doc """
This callback is invoked when `X3m.System.Message` should be saved as a alarm.
Time when it should be dispatched is set in it's `assigns.dispatch_at` as `DateTime`.
3rd parameter (state) is the one that was set when Scheduller's `start_link/1` was
called.
"""
@callback save_alarm(
X3m.System.Message.t(),
aggregate_id :: String.t(),
state :: any()
) :: :ok
@doc """
Load alarms callback is invoked on Schedullers init with `load_from` as `nil`,
and after that it is invoked each `in_memory_interval/0` with `load_from`
set to previous `load_until` value and new `load_until` will be
`load_from = 2 * in_memory_interval/0`.
"""
@callback load_alarms(
load_from :: nil | DateTime.t(),
load_until :: DateTime.t(),
state :: any()
) ::
{:ok, [X3m.System.Message.t()]}
| {:error, term()}
@doc """
This callback is invoked when schedulled message is processed.
It should return either `:ok` (and remove from persitance) so message delivery is not retried or
ammount of milliseconds in which delivery will be retried with potentially
modifed `X3m.System.Message`. It's `assigns` can used to track number of retries for example.
"""
@callback service_responded(X3m.System.Message.t(), state :: any()) ::
:ok
| {:retry, in_ms :: non_neg_integer(), X3m.System.Message.t()}
@doc """
This is optional callback that should return in which interval (in milliseconds)
alarms should be loaded.
"""
@callback in_memory_interval() :: milliseconds :: pos_integer()
@doc """
This optional callback defines timeout for response (in milliseconds). By default
response is being waited for 5_000 milliseconds
"""
@callback dispatch_timeout(X3m.System.Message.t()) :: milliseconds :: pos_integer()
@optional_callbacks in_memory_interval: 0, dispatch_timeout: 1
defmodule State do
@type t() :: %__MODULE__{
client_state: any(),
loaded_until: nil | DateTime.t(),
schedulled_alarms: %{String.t() => X3m.System.Message.t()}
}
@enforce_keys ~w(client_state loaded_until schedulled_alarms)a
defstruct @enforce_keys
end
defmacro __using__(_opts) do
quote do
@moduledoc """
This module should be used to schedulle `X3m.System.Message` delivery
at some point in time in the future. Alarms are persisted so when process
is respawned they can be reloaded into memory.
Not all schedulled alarms are kept in memory. They are loaded in bulks
each `in_memory_interval/0` milliseconds for next `2 * in_memory_interval/0` milliseconds.
If message with it's `X3m.System.Message.id` is already in memory (and schedulled for delivery)
it is ignored.
When message is processed, `service_responded/2` callback is invoked. It should return either
`:ok` or `{:retry, in_milliseconds, X3m.System.Message}` if returned message should be redelivered
in specified number of milliseconds.
"""
use GenServer
alias X3m.System.{Scheduller, Dispatcher, Message}
alias X3m.System.Scheduller.State
@behaviour Scheduller
@name __MODULE__
@doc """
Spawns Scheduller with given `state`. That one is provided in all
callbacks as last parameter and can be used to provide Repo or any other detail needed.
When new Scheduller is spawned it calls `load_alarms/3` callback with
`load_from` set to `nil`.
"""
@spec start_link(state :: any()) :: GenServer.on_start()
def start_link(state \\ %{}),
do: GenServer.start_link(__MODULE__, state, name: @name)
@doc """
Schedules dispatch of `msg` `in` given milliseconds or `at` given `DateTime`.
It assigns `:dispatch_at` to the `msg` and that's the real `DateTime` when dispatch
should occur.
opts can be either:
- `in` - in milliseconds
- `at` - as `DateTime`
"""
@spec dispatch(Message.t(), String.t(), opts :: Keyword.t()) :: :ok
def dispatch(%Message{} = msg, aggregate_id, in: dispatch_in_ms) do
dispatch_at = DateTime.add(_now(), dispatch_in_ms, :millisecond)
GenServer.call(
@name,
{:schedulle_dispatch, msg, aggregate_id, dispatch_at, dispatch_in_ms}
)
end
def dispatch(%Message{} = msg, aggregate_id, at: %DateTime{} = dispatch_at) do
dispatch_in_ms = DateTime.diff(dispatch_at, _now(), :millisecond)
GenServer.call(
@name,
{:schedulle_dispatch, msg, aggregate_id, dispatch_at, dispatch_in_ms}
)
end
@spec _now() :: DateTime.t()
defp _now(),
do: DateTime.now!("Etc/UTC", Tzdata.TimeZoneDatabase)
@impl GenServer
@doc false
def init(client_state) do
send(self(), :load_alarms)
{:ok, %State{client_state: client_state, loaded_until: nil, schedulled_alarms: %{}}}
end
@impl GenServer
@doc false
def handle_call(
{:schedulle_dispatch, %Message{} = msg, aggregate_id, dispatch_at, dispatch_in_ms},
_from,
%State{} = state
) do
msg = Message.assign(msg, :dispatch_at, dispatch_at)
:ok = save_alarm(msg, aggregate_id, state.client_state)
schedulled_alarms =
cond do
dispatch_in_ms < 0 ->
msg = Message.assign(msg, :late?, true)
send(self(), {:dispatch, msg})
Map.put(state.schedulled_alarms, msg.id, msg)
DateTime.compare(state.loaded_until, dispatch_at) == :gt ->
Process.send_after(self(), {:dispatch, msg}, dispatch_in_ms)
Map.put(state.schedulled_alarms, msg.id, msg)
true ->
state.schedulled_alarms
end
{:reply, :ok, %State{state | schedulled_alarms: schedulled_alarms}}
end
@impl GenServer
@doc false
def handle_info(:load_alarms, %State{} = state) do
load_until =
(state.loaded_until || DateTime.utc_now())
|> DateTime.add(in_memory_interval() * 2, :millisecond)
{:ok, alarms} = load_alarms(state.loaded_until, load_until, state.client_state)
schedulled_alarms =
alarms
|> Enum.reduce(%{}, fn %Message{} = msg, acc ->
dispatch_in_ms = DateTime.diff(msg.assigns.dispatch_at, _now(), :millisecond)
cond do
dispatch_in_ms < 0 ->
msg = Message.assign(msg, :late?, true)
send(self(), {:dispatch, msg})
Map.put_new(acc, msg.id, msg)
DateTime.compare(load_until, msg.assigns.dispatch_at) == :gt ->
Process.send_after(@name, {:dispatch, msg}, dispatch_in_ms)
Map.put_new(acc, msg.id, msg)
true ->
:ok
acc.schedulled_alarms
end
end)
Process.send_after(self(), :load_alarms, in_memory_interval())
{:noreply, %State{state | loaded_until: load_until, schedulled_alarms: schedulled_alarms}}
end
def handle_info({:dispatch, msg}, %State{} = state) do
spawn_link(fn ->
timeout = dispatch_timeout(msg)
msg = %{msg | reply_to: self()}
msg
|> Dispatcher.dispatch(timeout: timeout)
|> service_responded(state.client_state)
|> case do
:ok ->
{_, schedulled_alarms} = Map.pop(state.schedulled_alarms, msg.id)
{:noreply, %State{state | schedulled_alarms: schedulled_alarms}}
{:retry, in_ms, %Message{} = msg} ->
Process.send_after(@name, {:dispatch, msg}, in_ms)
schedulled_alarms = Map.put(state.schedulled_alarms, msg.id, msg)
{:noreply, %State{state | schedulled_alarms: schedulled_alarms}}
end
end)
{:noreply, state}
end
@doc false
@spec in_memory_interval() :: milliseconds :: pos_integer()
def in_memory_interval,
do: 6 * 60 * 60 * 1_000
@doc false
@spec dispatch_timeout(Message.t()) :: milliseconds :: pos_integer()
def dispatch_timeout(%Message{}),
do: 5_000
defoverridable in_memory_interval: 0, dispatch_timeout: 1
end
end
end