Current section

Files

Jump to
batch_please lib batch_please.ex
Raw

lib/batch_please.ex

defmodule BatchPlease do
@moduledoc ~S"""
BatchPlease is a tool for collecting batches of items, and doing something
with each batch when it reaches a certain size or age.
It is built on top of GenServer, implemented as a behaviour,
and invoked through `use BatchPlease`.
It is useful to build specialized batch collectors on top of BatchPlease,
in order to abstract more details from the end user. Examples of this
approach include `BatchPlease.MemoryBatcher` (which stores items in memory
when batching) and `BatchPlease.FileBatcher` (which encodes items to
string format and accumulates them in an on-disk file until ready for
processing).
Simple/trivial usage example:
defmodule Summer do
use BatchPlease, max_batch_size: 3
def batch_init(_opts) do
{:ok, %{sum: 0}}
end
def batch_add_item(batch, item) do
{:ok, %{batch | sum: batch.sum + item}}
end
def batch_process(batch) do
IO.puts("This batch added up to #{batch.sum}")
:ok
end
end
{:ok, pid} = GenServer.start_link(Summer)
BatchPlease.add_item(pid, 1)
BatchPlease.add_item(pid, 2)
BatchPlease.add_item(pid, 3) # prints "This batch added up to 6"
BatchPlease.add_item(pid, 4)
BatchPlease.add_item(pid, 5)
BatchPlease.add_item(pid, 6) # prints "This batch added up to 15"
"""
import BatchPlease.DynamicResolvers
#### `use BatchPlease`
@doc false
defmacro __using__(opts) do
quote do
use GenServer
def init(args), do: BatchPlease.init(args ++ unquote(opts), __MODULE__)
def handle_call(msg, from, state), do: BatchPlease.handle_call(msg, from, state)
def terminate(reason, state), do: BatchPlease.terminate(reason, state)
@behaviour BatchPlease
end
end
#### Internal types
@typedoc ~S"""
A GenServer performing as a batch server.
"""
@type batch_server :: pid
@typedoc ~S"""
A map representing the internal state of a batch server. This map
contains a `batch` key, representing the state of the current batch.
"""
@type state :: %{
opts: opts,
module: atom,
batch: batch,
last_item: item | nil,
config: state_config,
overrides: state_overrides,
counts: state_counts,
times: state_times,
}
@type state_config :: %{
lazy_flush: boolean | nil,
max_batch_size: non_neg_integer | nil,
max_time_since_last_flush: non_neg_integer | nil,
max_time_since_first_item: non_neg_integer | nil,
}
@type state_overrides :: %{
batch_init: ((opts) -> batch_return) | nil,
batch_add_item: ((batch, item) -> batch_return) | nil,
batch_pre_process: ((batch) -> batch_return) | nil,
batch_process: ((batch) -> ok_or_error) | nil,
batch_post_process: ((batch) -> ok_or_error) | nil,
batch_terminate: ((batch) -> ok_or_error) | nil,
should_flush: ((state) -> boolean) | nil,
}
@type state_counts :: %{
batch_items: non_neg_integer,
total_items: non_neg_integer,
flushes: non_neg_integer,
}
@type state_times :: %{
first_item_of_batch: integer | nil,
last_flush: integer | nil,
}
@typep state_return :: {:ok, state} | {:error, String.t}
@typep reply_return :: {ok_or_error, state}
@typedoc ~S"""
A map representing the state of the current batch.
Contents of this map are implementation-specific.
"""
@type batch :: %{}
@typedoc ~S"""
Represents the return value of functions which generate a new batch state
(`batch_init/1` and `batch_add_item/2`).
"""
@type batch_return :: {:ok, batch} | {:error, String.t}
@typedoc ~S"""
Return value of functions which may fail, but do not return a new batch
state (`batch_process/1` and `batch_terminate/1`).
"""
@type ok_or_error :: :ok | {:error, String.t}
@typedoc ~S"""
Any item that can be added to a batch.
"""
@type item :: any
@typedoc ~S"""
Configuration parameters for a batch server.
"""
@type opts :: [option]
@type option ::
{:eager_flush, boolean} |
{:max_batch_size, non_neg_integer | nil} |
{:max_time_since_last_flush, non_neg_integer | nil} |
{:max_time_since_first_item, non_neg_integer | nil}
#### Behaviour stuff
@doc ~S"""
Creates a new batch state, given the configuration options in `opts`.
This function is called not just once, but every time a new batch
is created (i.e., at startup and after every flush).
Defaults to creating an empty map, returning `{:ok, %{}}`.
Returns the updated batch state or an error message.
"""
@callback batch_init(opts) :: batch_return
@doc ~S"""
Adds an item to the batch represented in `batch`.
Returns the updated batch state or an error message.
"""
@callback batch_add_item(batch, item) :: batch_return
@doc ~S"""
Performs some pre-processing on the batch, before it is passed to
`batch_process/1`. Returns the updated batch state or an error message.
This is an optional callback.
"""
@callback batch_pre_process(batch) :: batch_return
@doc ~S"""
Processes the batch, whatever that entails.
This function may operate synchronously or asynchronously,
according to developer preference. If it operates synchronously,
calls to `BatchPlease.flush/1` will block until finished.
Returns `:ok` on success, or `{:error, message}` otherwise.
"""
@callback batch_process(batch) :: ok_or_error
@doc ~S"""
Performs some post-processing on the batch, after `batch_process/1`
has completed successfully. Does not return an updated batch, because
this operation is immediately followed by `batch_init/1` to create a new
batch.
Returns `:ok` on success, or `{:error, message}` otherwise.
This is an optional callback.
"""
@callback batch_post_process(batch) :: ok_or_error
@doc ~S"""
Cleans up batch state before the batcher process is terminated.
Defaults to no-op. This is not guaranteed to be called at
termination time -- for more information, see:
https://hexdocs.pm/elixir/GenServer.html#c:terminate/2
Returns `:ok` on success, or `{:error, message}` otherwise.
This is an optional callback.
"""
@callback batch_terminate(batch) :: ok_or_error
@doc ~S"""
Given the current module state, returns whether the current batch
should be processed now. Precedes the handling of `max_batch_size`
(but does not replace it).
This is an optional callback.
"""
@callback should_flush(state) :: boolean
@optional_callbacks [
batch_terminate: 1,
batch_pre_process: 1,
batch_post_process: 1,
should_flush: 1,
]
#### Public functions
@doc ~S"""
Adds an item to a batch.
Returns `:ok` on success, or `{:error, message}` otherwise.
"""
@spec add_item(batch_server, item) :: :ok | {:error, String.t}
def add_item(batch_server, item) do
GenServer.call(batch_server, {:add_item, item})
end
@doc ~S"""
Forces the processing and flushing of a batch.
Returns `:ok` on success, or `{:error, message}` otherwise.
"""
@spec flush(batch_server) :: :ok | {:error, String.t}
def flush(batch_server) do
GenServer.call(batch_server, :flush)
end
@doc false
def get_internal_state(batch_server) do
GenServer.call(batch_server, :get_internal_state)
end
#### GenServer implementation
@doc false
@spec init(Keyword.t, atom) :: batch_return
def init(opts, module) do
state = %{
opts: opts, ## invocation opts
module: module, ## module containing implementation (if any)
batch: nil, ## batch state
last_item: nil, ## last item added
config: %{
lazy_flush: opts[:lazy_flush],
max_batch_size: opts[:max_batch_size],
max_time_since_last_flush: opts[:max_time_since_last_flush],
max_time_since_first_item: opts[:max_time_since_first_item],
},
overrides: %{
batch_init: opts[:batch_init],
batch_add_item: opts[:batch_add_item],
batch_pre_process: opts[:batch_pre_process],
batch_process: opts[:batch_process],
batch_post_process: opts[:batch_post_process],
batch_terminate: opts[:batch_terminate],
should_flush: opts[:should_flush],
},
counts: %{
flushes: 0,
batch_items: 0,
total_items: 0,
},
times: %{
last_flush: mono_now(),
first_item_of_batch: nil,
},
}
{:ok, batch} = do_batch_init(state, opts)
{:ok, %{state | batch: batch}}
end
@doc false
def handle_call({:add_item, item}, _from, state) do
{reply, state} = handle_add_item(state, item)
{:reply, reply, state}
end
def handle_call(:flush, _from, state) do
{reply, state} = handle_flush(state)
{:reply, reply, state}
end
def handle_call(:get_internal_state, _from, state) do
{:reply, state, state}
end
@doc false
def terminate(_reason, state) do
do_batch_terminate(state, state.batch)
end
#### internal impl
## Adds an item to the state, flushing as necessary.
@spec handle_add_item(state, item) :: reply_return
defp handle_add_item(state, item) do
with {:ok, state} <- autoflush_state(state),
{:ok, state} <- add_item_to_state(state, item),
{:ok, state} <- eager_flush_state(state)
do
{:ok, state}
else
{:error, msg} -> {{:error, msg}, state}
end
end
@spec add_item_to_state(state, item) :: state_return
defp add_item_to_state(state, item) do
with {:ok, batch} <- do_batch_add_item(state, state.batch, item)
do
{:ok, %{state |
batch: batch,
counts: %{state.counts |
batch_items: state.counts.batch_items + 1,
total_items: state.counts.total_items + 1,
},
times: %{state.times |
first_item_of_batch: state.times.first_item_of_batch || mono_now(),
},
last_item: item,
}}
end
end
@spec autoflush_state(state) :: state_return
defp autoflush_state(state) do
should_flush = do_should_flush(state) ||
should_flush_on_size?(state) ||
should_flush_on_age?(state)
if should_flush do
handle_flush(state)
else
{:ok, state}
end
end
@spec should_flush_on_size?(state) :: boolean
defp should_flush_on_size?(state) do
case state.config.max_batch_size do
nil -> false
size -> state.counts.batch_items >= size
end
end
@spec should_flush_on_age?(state) :: boolean
defp should_flush_on_age?(state) do
mtslf = state.config.max_time_since_last_flush
lf = state.times.last_flush
mtsfi = state.config.max_time_since_first_item
fi = state.times.first_item_of_batch
cond do
mtslf && lf && (mono_now() >= mtslf + lf) ->
true
mtsfi && fi && (mono_now() >= mtsfi + fi) ->
true
:else ->
false
end
end
@spec eager_flush_state(state) :: state_return
defp eager_flush_state(state) do
if state.config.lazy_flush, do: {:ok, state}, else: autoflush_state(state)
end
@spec handle_flush(state) :: reply_return
defp handle_flush(state) do
case process(state, state.batch) do
:ok ->
{:ok, new_batch} = do_batch_init(state, state.opts)
{:ok, %{state |
batch: new_batch,
counts: %{state.counts |
flushes: state.counts.flushes + 1,
batch_items: 0,
},
times: %{state.times |
last_flush: mono_now(),
first_item_of_batch: nil,
},
}}
{:error, msg} ->
{{:error, msg}, state}
end
end
## Performs pre, regular, and post processing
@spec process(state, batch) :: ok_or_error
defp process(state, batch) do
with {:ok, batch} <- do_batch_pre_process(state, batch),
:ok <- do_batch_process(state, batch)
do
do_batch_post_process(state, batch)
end
end
defp mono_now, do: :erlang.monotonic_time(:milli_seconds)
end