|
| 1 | +defmodule ErrorTracker.Plugins.Pruner do |
| 2 | + @moduledoc """ |
| 3 | + Periodically delete resolved errors based on their age. |
| 4 | +
|
| 5 | + Pruning allows you to keep your database size under control by removing old errors that are not |
| 6 | + needed anymore. |
| 7 | +
|
| 8 | + ## Using the pruner |
| 9 | +
|
| 10 | + To enable the pruner you must register the plugin in the ErrorTracker configuration. This will use |
| 11 | + the default options, which is to prune errors resolved after 5 minutes. |
| 12 | +
|
| 13 | + config :error_tracker, |
| 14 | + plugins: [ErrorTracker.Plugins.Pruner] |
| 15 | +
|
| 16 | + You can override the default options by passing them as an argument when registering the plugin. |
| 17 | +
|
| 18 | + config :error_tracker, |
| 19 | + plugins: [{ErrorTracker.Plugins.Pruner, max_age: :timer.minutes(30)}] |
| 20 | +
|
| 21 | + ## Options |
| 22 | +
|
| 23 | + - `:limit` - the maximum number of errors to prune on each execution. Occurrences are removed |
| 24 | + along the errors. The default is 200 to prevent timeouts and unnecesary database load. |
| 25 | +
|
| 26 | + - `:max_age` - the number of milliseconds after a resolved error may be pruned. The default is 24 |
| 27 | + hours. |
| 28 | +
|
| 29 | + - `:interval` - the interval in milliseconds between pruning runs. The default is 30 minutes. |
| 30 | +
|
| 31 | + You may find the `:timer` module functions useful to pass readable values to the `:max_age` and |
| 32 | + `:interval` options. |
| 33 | +
|
| 34 | + ## Manual pruning |
| 35 | +
|
| 36 | + In certain cases you may prefer to run the pruner manually. This can be done by calling the |
| 37 | + `prune_errors/2` function from your application code. This function supports the `:limit` and |
| 38 | + `:max_age` options as described above. |
| 39 | +
|
| 40 | + For example, you may call this function from an Oban worker so you can leverage Oban's cron |
| 41 | + capabilities and have a more granular control over when pruning is run. |
| 42 | +
|
| 43 | + defmodule MyApp.ErrorPruner do |
| 44 | + use Oban.Job |
| 45 | +
|
| 46 | + def perform(%Job{}) do |
| 47 | + ErrorTracker.Plugins.Pruner.prune_errors(limit: 10_000, max_age: :timer.minutes(60)) |
| 48 | + end |
| 49 | + end |
| 50 | + """ |
| 51 | + use GenServer |
| 52 | + |
| 53 | + import Ecto.Query |
| 54 | + |
| 55 | + alias ErrorTracker.Error |
| 56 | + alias ErrorTracker.Occurrence |
| 57 | + alias ErrorTracker.Repo |
| 58 | + |
| 59 | + @doc """ |
| 60 | + Prunes resolved errors. |
| 61 | +
|
| 62 | + You do not need to use this function if you activate the Pruner plugin. This function is exposed |
| 63 | + only for advanced use cases and Oban integration. |
| 64 | +
|
| 65 | + ## Options |
| 66 | +
|
| 67 | + - `:limit` - the maximum number of errors to prune on each execution. Occurrences are removed |
| 68 | + along the errors. The default is 200 to prevent timeouts and unnecesary database load. |
| 69 | +
|
| 70 | + - `:max_age` - the number of milliseconds after a resolved error may be pruned. The default is 24 |
| 71 | + hours. You may find the `:timer` module functions useful to pass readable values to this option. |
| 72 | + """ |
| 73 | + @spec prune_errors(keyword()) :: {:ok, list(Error.t())} |
| 74 | + def prune_errors(opts \\ []) do |
| 75 | + limit = opts[:limit] || raise ":limit option is required" |
| 76 | + max_age = opts[:max_age] || raise ":max_age option is required" |
| 77 | + time = DateTime.add(DateTime.utc_now(), -max_age, :millisecond) |
| 78 | + |
| 79 | + errors = |
| 80 | + Repo.all( |
| 81 | + from error in Error, |
| 82 | + select: [:id, :kind, :source_line, :source_function], |
| 83 | + where: error.status == :resolved, |
| 84 | + where: error.last_occurrence_at < ^time, |
| 85 | + limit: ^limit |
| 86 | + ) |
| 87 | + |
| 88 | + if Enum.any?(errors) do |
| 89 | + _pruned_occurrences_count = |
| 90 | + errors |
| 91 | + |> Ecto.assoc(:occurrences) |
| 92 | + |> prune_occurrences() |
| 93 | + |> Enum.sum() |
| 94 | + |
| 95 | + Repo.delete_all(from error in Error, where: error.id in ^Enum.map(errors, & &1.id)) |
| 96 | + end |
| 97 | + |
| 98 | + {:ok, errors} |
| 99 | + end |
| 100 | + |
| 101 | + defp prune_occurrences(occurrences_query) do |
| 102 | + Stream.unfold(occurrences_query, fn occurrences_query -> |
| 103 | + occurrences_ids = |
| 104 | + Repo.all(from occurrence in occurrences_query, select: occurrence.id, limit: 1000) |
| 105 | + |
| 106 | + case Repo.delete_all(from o in Occurrence, where: o.id in ^occurrences_ids) do |
| 107 | + {0, _} -> nil |
| 108 | + {deleted, _} -> {deleted, occurrences_query} |
| 109 | + end |
| 110 | + end) |
| 111 | + end |
| 112 | + |
| 113 | + def start_link(state \\ []) do |
| 114 | + GenServer.start_link(__MODULE__, state, name: __MODULE__) |
| 115 | + end |
| 116 | + |
| 117 | + @impl GenServer |
| 118 | + @doc false |
| 119 | + def init(state \\ []) do |
| 120 | + state = %{ |
| 121 | + limit: state[:limit] || 200, |
| 122 | + max_age: state[:max_age] || :timer.hours(24), |
| 123 | + interval: state[:interval] || :timer.minutes(30) |
| 124 | + } |
| 125 | + |
| 126 | + {:ok, schedule_prune(state)} |
| 127 | + end |
| 128 | + |
| 129 | + @impl GenServer |
| 130 | + @doc false |
| 131 | + def handle_info(:prune, state) do |
| 132 | + {:ok, _pruned} = prune_errors(state) |
| 133 | + |
| 134 | + {:noreply, schedule_prune(state)} |
| 135 | + end |
| 136 | + |
| 137 | + defp schedule_prune(state = %{interval: interval}) do |
| 138 | + Process.send_after(self(), :prune, interval) |
| 139 | + |
| 140 | + state |
| 141 | + end |
| 142 | +end |
0 commit comments