Packages

A distributed, compacting, multidimensional, telemetry-powered time series datastore

Current section

Files

Jump to
oban_met lib oban met recorder.ex
Raw

lib/oban/met/recorder.ex

defmodule Oban.Met.Recorder do
@moduledoc false
# Aggregate metrics via pubsub for querying and compaction.
use GenServer
alias __MODULE__, as: State
alias Oban.Met.{Value, Values.Gauge, Values.Sketch}
alias Oban.{Notifier, Peer}
@type series :: atom() | String.t()
@type value :: Value.t()
@type label :: String.t()
@type labels :: %{optional(String.t()) => label()}
@type ts :: integer()
@type period :: {pos_integer(), pos_integer()}
@chunk_size 500
@periods [{1, 120}, {5, 900}, {30, 2_000}, {60, 9_300}]
@default_latest_opts [filters: [], group: nil, lookback: 2]
@default_timeslice_opts [
by: 1,
filters: [],
group: nil,
lookback: 60,
operation: :sum
]
defstruct [
:compact_timer,
:conf,
:latest_table,
:name,
:series_table,
compact_periods: @periods,
handoff: :awaiting
]
@spec child_spec(keyword()) :: Supervisor.child_spec()
def child_spec(opts) do
name = Keyword.get(opts, :name, __MODULE__)
%{super(opts) | id: name}
end
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: opts[:name])
end
@spec lookup(GenServer.name(), series()) :: [term()]
def lookup(name, series) do
match = {{to_string(series), :_, :_}, :_, :_, :_}
name
|> series_table()
|> :ets.select_reverse([{match, [], [:"$_"]}])
end
@spec labels(GenServer.name(), label(), keyword()) :: [label()]
def labels(name, label, opts \\ []) when is_binary(label) do
opts = Keyword.validate!(opts, [:lookback, :since])
stime = Keyword.get(opts, :since, System.system_time(:second))
lookback = Keyword.get(opts, :lookback, 120)
match = {{:_, :_}, :"$1", :_, :"$2"}
guard = [{:andalso, {:is_map_key, label, :"$1"}, {:>=, :"$2", stime - lookback}}]
value = [{:map_get, label, :"$1"}]
name
|> latest_table()
|> :ets.select([{match, guard, value}])
|> :lists.usort()
end
@spec latest(GenServer.name(), series(), keyword()) :: %{optional(String.t()) => value()}
def latest(name, series, opts \\ []) do
opts = Keyword.validate!(opts, @default_latest_opts)
group = Keyword.fetch!(opts, :group)
lookback = Keyword.fetch!(opts, :lookback)
filters = Keyword.fetch!(opts, :filters)
stime = System.system_time(:second)
match = {{to_string(series), :_}, :"$1", :"$2", :"$3"}
guard = filters_to_guards(filters, {:>=, :"$3", stime - lookback})
body = [{{:"$1", :"$2"}}]
name
|> latest_table()
|> :ets.select([{match, [guard], body}])
|> Enum.reduce(%{}, fn {labels, value}, acc ->
Map.update(acc, labels[group] || "all", value, &Value.merge(&1, value))
end)
|> Map.new(fn {group, merged} -> {group, Value.sum(merged)} end)
end
@spec series(GenServer.name()) :: [map()]
def series(name) do
match = {{:"$1", :_}, :"$2", :"$3", :_}
name
|> latest_table()
|> :ets.select([{match, [], [:"$$"]}])
|> Enum.group_by(&hd/1)
|> Enum.map(fn {series, [[_series, _labels, %vtype{}] | _] = metrics} ->
labels =
for [_series, labels, _value] <- metrics,
key <- Map.keys(labels),
uniq: true,
do: key
%{series: series, labels: labels, type: vtype}
end)
|> Enum.sort_by(& &1.series)
end
@spec timeslice(GenServer.name(), series(), keyword()) :: [{ts(), value(), label()}]
def timeslice(name, series, opts \\ []) do
opts = Keyword.validate!(opts, [:since] ++ @default_timeslice_opts)
by = Keyword.fetch!(opts, :by)
group = Keyword.fetch!(opts, :group)
lookback = Keyword.fetch!(opts, :lookback)
operation = Keyword.fetch!(opts, :operation)
since = Keyword.get(opts, :since, System.system_time(:second))
name
|> series_table()
|> select(series, lookback, opts[:filters])
|> Enum.reduce(%{}, &merge_group(&1, &2, group))
|> Enum.reduce(%{}, &merge_chunk(&1, &2, since, by))
|> Enum.sort_by(fn {{label, chunk}, _} -> {label, -chunk} end)
|> Enum.map(fn {{label, chunk}, value} ->
value =
case operation do
:sum -> Value.sum(value)
:max -> Value.quantile(value, 1.0)
{:pct, ntile} -> Value.quantile(value, ntile)
end
{chunk, value, label}
end)
end
defp merge_group({{_, ts, _}, _, labels, value}, acc, group) do
Map.update(acc, {labels[group], ts}, value, &Value.union(&1, value))
end
defp merge_chunk({{label, ts}, value}, acc, since, by) do
chunk = div(since - ts - 1, by)
Map.update(acc, {label, chunk}, value, &Value.merge(&1, value))
end
def compact(name, periods) when is_list(periods) do
GenServer.call(name, {:compact, periods})
end
def store(name, series, value, labels, opts \\ []) do
time = Keyword.get(opts, :time, System.system_time(:second))
GenServer.call(name, {:store, {series, value, labels, time}})
end
# Callbacks
@impl GenServer
def init(opts) do
series_table =
:ets.new(:metrics_series, [
:ordered_set,
:public,
read_concurrency: true,
write_concurrency: true
])
latest_table =
:ets.new(:metrics_latest, [
:ordered_set,
:public,
read_concurrency: true,
write_concurrency: true
])
state =
opts
|> Keyword.put(:series_table, series_table)
|> Keyword.put(:latest_table, latest_table)
|> then(&struct!(State, &1))
|> schedule_compact()
Registry.register(Oban.Registry, state.name, {series_table, latest_table})
{:ok, state, {:continue, :start}}
end
@impl GenServer
def handle_continue(:start, %State{conf: conf} = state) do
payload = %{
syn: true,
module: __MODULE__,
name: inspect(conf.name),
node: conf.node
}
Notifier.notify(conf.name, :handoff, payload)
Notifier.listen(conf.name, [:gossip, :handoff, :metrics])
{:noreply, state}
end
@impl GenServer
def handle_call({:compact, periods}, _from, %State{} = state) do
inner_compact(state.series_table, state.latest_table, periods, System.system_time(:second))
{:reply, :ok, state}
end
def handle_call({:store, params}, _from, %State{} = state) do
{series, value, labels, time} = params
inner_store(state.series_table, state.latest_table, series, value, labels, time)
{:reply, :ok, state}
end
@impl GenServer
def handle_info({:notification, :handoff, %{"syn" => _} = msg}, state) do
from_name = Map.fetch!(msg, "name")
from_node = Map.fetch!(msg, "node")
own_name = inspect(state.conf.name)
own_node = state.conf.node
if {from_name, from_node} != {own_name, own_node} and Peer.leader?(state.conf) do
%{conf: conf, series_table: series_table} = state
target_ident = from_name <> "." <> to_string(from_node)
Task.start_link(fn ->
data =
series_table
|> :ets.tab2list()
|> :erlang.term_to_binary([:compressed])
|> Base.encode64()
payload = %{
ack: true,
module: __MODULE__,
ident: target_ident,
data: data,
node: conf.node,
name: inspect(conf.name)
}
Notifier.notify(conf, :handoff, payload)
end)
end
{:noreply, state}
end
def handle_info({:notification, :handoff, %{"ack" => _, "data" => data}}, %State{} = state) do
if state.handoff == :awaiting and not Peer.leader?(state.conf) do
%{series_table: series_table, latest_table: latest_table} = state
Task.start_link(fn ->
data
|> Base.decode64!()
|> :erlang.binary_to_term()
|> then(&:ets.insert(series_table, &1))
rebuild_latest(series_table, latest_table)
end)
end
{:noreply, %{state | handoff: :complete}}
end
def handle_info({:notification, :metrics, %{"metrics" => _} = payload}, state) do
%{"metrics" => metrics, "node" => node, "time" => time} = payload
for %{"series" => series, "value" => value} = metric <- metrics do
labels =
metric
|> Map.drop(~w(series value))
|> Map.put("node", node)
inner_store(state.series_table, state.latest_table, series, from_map(value), labels, time)
end
{:noreply, state}
end
def handle_info({:notification, _channel, _payload}, state) do
{:noreply, state}
end
def handle_info(:compact, %State{} = state) do
%{compact_periods: periods, series_table: series_table, latest_table: latest_table} = state
# Window shifted back 2s so concurrent writes (at time=now) can't race the delete.
Task.start(fn ->
inner_compact(series_table, latest_table, periods, System.system_time(:second) - 2)
end)
{:noreply, schedule_compact(state)}
end
defp from_map(%{"size" => _} = value), do: Sketch.from_map(value)
defp from_map(value), do: Gauge.from_map(value)
# Table
defp latest_table(name), do: name |> tables() |> elem(1)
defp series_table(name), do: name |> tables() |> elem(0)
defp tables(name) do
case Registry.lookup(Oban.Registry, name) do
[{_pid, {_series, _latest} = pair}] ->
pair
_ ->
raise RuntimeError, "no table registered for #{inspect(name)}"
end
end
defp inner_compact(series_table, latest_table, periods, now) do
delete_outdated_series(series_table, periods, now)
delete_outdated_latest(latest_table, periods, now)
Enum.reduce(periods, now, fn {step, duration}, ts ->
since = ts - duration
match = {{:_, :"$1", :_}, :"$2", :_, :_}
guard = [{:andalso, {:>=, :"$2", since}, {:"=<", :"$1", ts}}]
objects = :ets.select(series_table, [{match, guard, [:"$_"]}])
_delete = :ets.select_delete(series_table, [{match, guard, [true]}])
objects
|> Enum.group_by(fn {{ser, max, lab}, _, _, _} -> {ser, lab, div(ts - max - 1, step)} end)
|> Enum.map(fn {_chunk, metrics} -> compact_object(metrics) end)
|> then(&:ets.insert(series_table, &1))
since
end)
end
defp compact_object([{{series, _, lab_key}, _, labels, _} | _] = metrics) do
{min_ts, max_ts} =
metrics
|> Enum.flat_map(fn {{_, max_ts, _}, min_ts, _, _} -> [max_ts, min_ts] end)
|> Enum.min_max()
value =
metrics
|> Enum.map(&elem(&1, 3))
|> Enum.reduce(&Value.merge/2)
{{series, max_ts, lab_key}, min_ts, labels, value}
end
defp delete_outdated_series(table, periods, now) do
maximum = Enum.reduce(periods, 0, fn {_, duration}, acc -> duration + acc end)
since = now - maximum
match = {{:_, :"$1", :_}, :_, :_, :_}
guard = [{:<, :"$1", since}]
:ets.select_delete(table, [{match, guard, [true]}])
end
defp inner_store(series_table, latest_table, series, value, labels, time) do
series = to_string(series)
hash = :erlang.phash2(labels)
key = {series, time, hash}
merged =
case :ets.lookup(series_table, key) do
[{_key, _time, _labels, old_value}] -> Value.union(old_value, value)
_ -> value
end
:ets.insert(series_table, {key, time, labels, merged})
case :ets.lookup(latest_table, {series, hash}) do
[{_, _, _, prev_time}] when prev_time > time ->
:ok
[{_, _, prev_value, ^time}] ->
:ets.insert(latest_table, {{series, hash}, labels, Value.union(prev_value, value), time})
_ ->
:ets.insert(latest_table, {{series, hash}, labels, value, time})
end
end
defp delete_outdated_latest(latest_table, periods, now) do
maximum = Enum.reduce(periods, 0, fn {_, duration}, acc -> duration + acc end)
since = now - maximum
match = {{:_, :_}, :_, :_, :"$1"}
guard = [{:<, :"$1", since}]
:ets.select_delete(latest_table, [{match, guard, [true]}])
end
defp rebuild_latest(series_table, latest_table) do
fun = fn {{series, max_ts, hash}, _min_ts, labels, value}, acc ->
Map.update(acc, {series, hash}, {labels, value, max_ts}, fn
{_, _, prev_ts} = entry when prev_ts >= max_ts -> entry
_ -> {labels, value, max_ts}
end)
end
fun
|> :ets.foldl(%{}, series_table)
|> Enum.each(fn {key, {labels, value, max_ts}} ->
case :ets.lookup(latest_table, key) do
[{_, _, _, prev_ts}] when prev_ts >= max_ts -> :ok
_ -> :ets.insert(latest_table, {key, labels, value, max_ts})
end
end)
end
# Scheduling
defp schedule_compact(state) do
time = Time.utc_now()
interval =
time
|> Time.add(60)
|> Map.put(:second, 0)
|> Time.diff(time)
|> Integer.mod(86_400)
|> System.convert_time_unit(:second, :millisecond)
timer = Process.send_after(self(), :compact, interval)
%{state | compact_timer: timer}
end
# Fetching & Filtering
defp select(table, series, since, filters) do
cutoff = System.system_time(:second) - since
match = {{to_string(series), :_, :_}, :_, :"$1", :_}
conditions =
case filters do
[_ | _] -> [filters_to_guards(filters, true)]
_ -> []
end
case :ets.select_reverse(table, [{match, conditions, [:"$_"]}], @chunk_size) do
{rows, cont} -> collect_until(rows, cont, cutoff, [])
:"$end_of_table" -> []
end
end
defp collect_until(rows, cont, cutoff, acc) do
case take_recent(rows, cutoff, acc) do
{:done, acc} ->
acc
{:continue, acc} ->
case :ets.select(cont) do
{more, more_cont} -> collect_until(more, more_cont, cutoff, acc)
:"$end_of_table" -> acc
end
end
end
defp take_recent([], _cutoff, acc), do: {:continue, acc}
defp take_recent([{{_, max_ts, _}, _, _, _} = row | rest], cutoff, acc) when max_ts >= cutoff do
take_recent(rest, cutoff, [row | acc])
end
defp take_recent(_, _cutoff, acc), do: {:done, acc}
defp filters_to_guards(nil, base), do: base
defp filters_to_guards(filters, base) do
Enum.reduce(filters, base, fn {field, values}, and_acc ->
and_guard =
values
|> List.wrap()
|> Enum.map(fn value -> {:==, {:map_get, to_string(field), :"$1"}, value} end)
|> Enum.reduce(fn or_guard, or_acc -> {:orelse, or_guard, or_acc} end)
{:andalso, and_guard, and_acc}
end)
end
end