| defmodule Plausible.Ingestion.Counters.Buffer do |
| @moduledoc """ |
| A buffer aggregating counters for internal metrics, within 10 seconds time buckets. |
| |
| See `Plausible.Ingestion.Counters` for integration. |
| |
| Flushing is by default possible only once the 10s bucket is complete |
| (its window has moved). This is to avoid race conditions |
| when clearing up the buffer on dequeue - because there is no atomic "get and delete", |
| and items are buffered concurrently, there is a gap between get and delete |
| in which items written may disappear otherwise. |
| |
| `aggregate_bucket_fn` and `flush_boundary_fn` control that semantics and |
| are configurable only for test purposes. |
| """ |
|
|
| defstruct [:buffer_name, :aggregate_bucket_fn, :flush_boundary_fn] |
|
|
| alias Plausible.Ingestion.Counters.Record |
|
|
| @type t() :: %__MODULE__{} |
| @type unix_timestamp() :: pos_integer() |
| @type bucket_fn_opt() :: |
| {:aggregate_bucket_fn, (NaiveDateTime.t() -> unix_timestamp())} |
| | {:flush_boundary_fn, (DateTime.t() -> unix_timestamp())} |
|
|
| @ets_opts [ |
| :public, |
| :ordered_set, |
| :named_table, |
| write_concurrency: true |
| ] |
|
|
| @spec new(atom(), [bucket_fn_opt()]) :: t() |
| def new(buffer_name, opts \\ []) do |
| ^buffer_name = :ets.new(buffer_name, @ets_opts) |
|
|
| aggregate_bucket_fn = Keyword.get(opts, :aggregate_bucket_fn, &bucket_10s/1) |
| flush_boundary_fn = Keyword.get(opts, :flush_boundary_fn, &previous_10s/1) |
|
|
| %__MODULE__{ |
| buffer_name: buffer_name, |
| aggregate_bucket_fn: aggregate_bucket_fn, |
| flush_boundary_fn: flush_boundary_fn |
| } |
| end |
|
|
| @spec aggregate( |
| t(), |
| binary(), |
| binary(), |
| timestamp :: NaiveDateTime.t(), |
| tracker_script_version :: integer() |
| ) :: t() |
| def aggregate( |
| %__MODULE__{buffer_name: buffer_name, aggregate_bucket_fn: bucket_fn} = buffer, |
| metric, |
| domain, |
| timestamp, |
| tracker_script_version |
| ) do |
| bucket = |
| bucket_fn.(timestamp) |
|
|
| :ets.update_counter( |
| buffer_name, |
| {bucket, metric, domain, tracker_script_version}, |
| {2, 1}, |
| {{bucket, metric, domain, tracker_script_version}, 0} |
| ) |
|
|
| buffer |
| end |
|
|
| @spec flush(t(), now :: DateTime.t()) :: [Record.t()] |
| def flush( |
| %__MODULE__{buffer_name: buffer_name, flush_boundary_fn: flush_boundary_fn}, |
| now \\ DateTime.utc_now() |
| ) do |
| boundary = flush_boundary_fn.(now) |
|
|
| match = {{:"$1", :"$2", :"$3", :"$4"}, :"$5"} |
| guard = {:"=<", :"$1", boundary} |
| select = {{:"$1", :"$2", :"$3", :"$4", :"$5"}} |
|
|
| match_specs_read = [{match, [guard], [select]}] |
| match_specs_delete = [{match, [guard], [true]}] |
|
|
| case :ets.select(buffer_name, match_specs_read) do |
| [] -> |
| [] |
|
|
| data -> |
| :ets.select_delete(buffer_name, match_specs_delete) |
| data |
| end |
| end |
|
|
| @spec bucket_10s(NaiveDateTime.t()) :: unix_timestamp() |
| def bucket_10s(datetime) do |
| datetime |
| |> DateTime.from_naive!("Etc/UTC") |
| |> DateTime.truncate(:second) |
| |> Map.replace(:second, div(datetime.second, 10) * 10) |
| |> DateTime.to_unix() |
| end |
|
|
| @spec previous_10s(DateTime.t()) :: unix_timestamp() |
| def previous_10s(datetime) do |
| datetime |
| |> DateTime.add(-10, :second) |
| |> DateTime.to_unix() |
| end |
| end |
|
|