From b46f60e1057a8c9e86457a58eca0ff0e4db6ff43 Mon Sep 17 00:00:00 2001 From: Isaac Sanders Date: Tue, 13 Oct 2020 17:30:36 -0500 Subject: [PATCH 01/13] Uses logger metadata (#462) * Uses logger metadata in execution broadcaster * Uses logger metadata in job broadcaster * Removes unused require * Removes unused require * Uses logger metadata in clock broadcaster * Uses logger metadata in executor * Update execution_broadcaster.ex * Formats the code * Uses the correct name * Fixes tests * fix: Improves testing resilience Co-authored-by: Isaac Sanders --- config/config.exs | 1 + lib/quantum/clock_broadcaster.ex | 3 ++- lib/quantum/execution_broadcaster.ex | 14 +++++--------- lib/quantum/executor.ex | 19 ++++++++----------- lib/quantum/job_broadcaster.ex | 24 ++++++++++++------------ lib/quantum/supervisor.ex | 2 -- lib/quantum/task_registry.ex | 2 -- test/quantum/executor_test.exs | 10 +++++++--- test/quantum/job_broadcaster_test.exs | 2 +- 9 files changed, 36 insertions(+), 41 deletions(-) diff --git a/config/config.exs b/config/config.exs index ae722aea..75b858d5 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,2 +1,3 @@ use Mix.Config +config :logger, :console, metadata: :all config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase diff --git a/lib/quantum/clock_broadcaster.ex b/lib/quantum/clock_broadcaster.ex index 0d2631f7..228ff2e9 100644 --- a/lib/quantum/clock_broadcaster.ex +++ b/lib/quantum/clock_broadcaster.ex @@ -118,6 +118,7 @@ defmodule Quantum.ClockBroadcaster do defp log_catched_up(%State{debug_logging: true}), do: Logger.debug(fn -> - "[#{inspect(Node.self())}][#{__MODULE__}] Clock Producer catched up with past times and is now running in normal time" + {"Clock Producer catched up with past times and is now running in normal time", + node: Node.self()} end) end diff --git a/lib/quantum/execution_broadcaster.ex b/lib/quantum/execution_broadcaster.ex index 5a0ea10b..a7b8337d 100644 --- a/lib/quantum/execution_broadcaster.ex +++ b/lib/quantum/execution_broadcaster.ex @@ -101,9 +101,7 @@ defmodule Quantum.ExecutionBroadcaster do ) do debug_logging && Logger.debug(fn -> - "[#{inspect(Node.self())}][#{__MODULE__}] Scheduling job for single reboot execution: #{ - inspect(name) - }" + {"Scheduling job for single reboot execution", node: Node.self(), name: name} end) {[%ExecuteEvent{job: job}], %{state | uninitialized_jobs: [job | uninitialized_jobs]}} @@ -115,7 +113,7 @@ defmodule Quantum.ExecutionBroadcaster do ) do debug_logging && Logger.debug(fn -> - "[#{inspect(Node.self())}][#{__MODULE__}] Adding job #{inspect(name)}" + {"Adding job", node: Node.self(), name: name} end) {[], %{state | uninitialized_jobs: [job | uninitialized_jobs]}} @@ -127,7 +125,7 @@ defmodule Quantum.ExecutionBroadcaster do ) do debug_logging && Logger.debug(fn -> - "[#{inspect(Node.self())}][#{__MODULE__}] Running job #{inspect(name)} once" + {"Running job once", node: Node.self(), name: name} end) {[%ExecuteEvent{job: job}], state} @@ -143,7 +141,7 @@ defmodule Quantum.ExecutionBroadcaster do ) do debug_logging && Logger.debug(fn -> - "[#{inspect(Node.self())}][#{__MODULE__}] Removing job #{inspect(name)}" + {"Removing job", node: Node.self(), name: name} end) uninitialized_jobs = Enum.reject(uninitialized_jobs, &(&1.name == name)) @@ -218,9 +216,7 @@ defmodule Quantum.ExecutionBroadcaster do for %Job{name: job_name} = job <- jobs do debug_logging && Logger.debug(fn -> - "[#{inspect(Node.self())}][#{__MODULE__}] Scheduling job for execution #{ - inspect(job_name) - }" + {"Scheduling job for execution", node: Node.self(), name: job_name} end) %ExecuteEvent{job: job} diff --git a/lib/quantum/executor.ex b/lib/quantum/executor.ex index 59bdd9e0..531b370b 100644 --- a/lib/quantum/executor.ex +++ b/lib/quantum/executor.ex @@ -51,7 +51,7 @@ defmodule Quantum.Executor do ) do debug_logging && Logger.debug(fn -> - "[#{inspect(Node.self())}][#{__MODULE__}] Start execution of job #{inspect(job_name)}" + {"Start execution of job", node: Node.self(), name: job_name} end) case TaskRegistry.mark_running(task_registry, job_name, node) do @@ -84,15 +84,13 @@ defmodule Quantum.Executor do ) do debug_logging && Logger.debug(fn -> - "[#{inspect(Node.self())}][#{__MODULE__}] Task for job #{inspect(job_name)} started on node #{ - node - }" + {"Task for job started on node", node: Node.self(), name: job_name, started_on: node} end) Task.Supervisor.async_nolink({task_supervisor, node}, fn -> debug_logging && Logger.debug(fn -> - "[#{inspect(Node.self())}][#{__MODULE__}] Execute started for job #{inspect(job_name)}" + {"Execute started for job", node: Node.self(), name: job_name} end) # Note: we are intentionally mimicking the ":telemetry.span" here to keep current functionality @@ -110,9 +108,10 @@ defmodule Quantum.Executor do type, value -> debug_logging && Logger.debug(fn -> - "[#{inspect(Node.self())}][#{__MODULE__}] Execution ended for job #{ - inspect(job_name) - }, which failed due to: #{Exception.format(type, value, __STACKTRACE__)}" + { + "Execution failed for job", + node: Node.self(), name: job_name, type: type, value: value + } end) duration = :erlang.monotonic_time() - start_monotonic_time @@ -128,9 +127,7 @@ defmodule Quantum.Executor do result -> debug_logging && Logger.debug(fn -> - "[#{inspect(Node.self())}][#{__MODULE__}] Execution ended for job #{ - inspect(job_name) - }, which yielded result: #{inspect(result)}" + {"Execution ended for job", node: Node.self(), name: job_name, result: result} end) duration = :erlang.monotonic_time() - start_monotonic_time diff --git a/lib/quantum/job_broadcaster.ex b/lib/quantum/job_broadcaster.ex index 03a8812f..35ed6f98 100644 --- a/lib/quantum/job_broadcaster.ex +++ b/lib/quantum/job_broadcaster.ex @@ -49,7 +49,7 @@ defmodule Quantum.JobBroadcaster do :not_applicable -> debug_logging && Logger.debug(fn -> - "[#{inspect(Node.self())}][#{__MODULE__}] Loading Initial Jobs from Config" + {"Loading Initial Jobs from Config", node: Node.self()} end) jobs @@ -57,7 +57,7 @@ defmodule Quantum.JobBroadcaster do storage_jobs when is_list(storage_jobs) -> debug_logging && Logger.debug(fn -> - "[#{inspect(Node.self())}][#{__MODULE__}] Loading Initial Jobs from Storage, skipping config" + {"Loading Initial Jobs from Storage, skipping config", node: Node.self()} end) for %Job{state: :active} = job <- storage_jobs do @@ -104,7 +104,7 @@ defmodule Quantum.JobBroadcaster do %{^job_name => %Job{state: :active} = old_job} -> debug_logging && Logger.debug(fn -> - "[#{inspect(Node.self())}][#{__MODULE__}] Replacing job #{inspect(job_name)}" + {"Replacing job", node: Node.self(), name: job_name} end) # Send event to telemetry incase the end user wants to monitor events @@ -122,7 +122,7 @@ defmodule Quantum.JobBroadcaster do %{^job_name => %Job{state: :inactive}} -> debug_logging && Logger.debug(fn -> - "[#{inspect(Node.self())}][#{__MODULE__}] Replacing job #{inspect(job_name)}" + {"Replacing job", node: Node.self(), name: job_name} end) # Send event to telemetry incase the end user wants to monitor events @@ -139,7 +139,7 @@ defmodule Quantum.JobBroadcaster do _ -> debug_logging && Logger.debug(fn -> - "[#{inspect(Node.self())}][#{__MODULE__}] Adding job #{inspect(job_name)}" + {"Adding job", node: Node.self(), name: job_name} end) # Send event to telemetry incase the end user wants to monitor events @@ -167,7 +167,7 @@ defmodule Quantum.JobBroadcaster do %{^job_name => %Job{state: :active} = old_job} -> debug_logging && Logger.debug(fn -> - "[#{inspect(Node.self())}][#{__MODULE__}] Replacing job #{inspect(job_name)}" + {"Replacing job", node: Node.self(), name: job_name} end) # Send event to telemetry incase the end user wants to monitor events @@ -184,7 +184,7 @@ defmodule Quantum.JobBroadcaster do %{^job_name => %Job{state: :inactive}} -> debug_logging && Logger.debug(fn -> - "[#{inspect(Node.self())}][#{__MODULE__}] Replacing job #{inspect(job_name)}" + {"Replacing job", node: Node.self(), name: job_name} end) # Send event to telemetry incase the end user wants to monitor events @@ -201,7 +201,7 @@ defmodule Quantum.JobBroadcaster do _ -> debug_logging && Logger.debug(fn -> - "[#{inspect(Node.self())}][#{__MODULE__}] Adding job #{inspect(job_name)}" + {"Adding job", node: Node.self(), name: job_name} end) # Send event to telemetry incase the end user wants to monitor events @@ -227,7 +227,7 @@ defmodule Quantum.JobBroadcaster do ) do debug_logging && Logger.debug(fn -> - "[#{inspect(Node.self())}][#{__MODULE__}] Deleting job #{inspect(name)}" + {"Deleting job", node: Node.self(), name: name} end) case Map.fetch(jobs, name) do @@ -269,7 +269,7 @@ defmodule Quantum.JobBroadcaster do ) do debug_logging && Logger.debug(fn -> - "[#{inspect(Node.self())}][#{__MODULE__}] Change job state #{inspect(name)}" + {"Change job state", node: Node.self(), name: name} end) case Map.fetch(jobs, name) do @@ -309,7 +309,7 @@ defmodule Quantum.JobBroadcaster do ) do debug_logging && Logger.debug(fn -> - "[#{inspect(Node.self())}][#{__MODULE__}] Running job #{inspect(name)} once" + {"Running job once", node: Node.self(), name: name} end) case Map.fetch(jobs, name) do @@ -332,7 +332,7 @@ defmodule Quantum.JobBroadcaster do ) do debug_logging && Logger.debug(fn -> - "[#{inspect(Node.self())}][#{__MODULE__}] Deleting all jobs" + {"Deleting all jobs", node: Node.self()} end) for {_name, %Job{} = job} <- jobs do diff --git a/lib/quantum/supervisor.ex b/lib/quantum/supervisor.ex index 0468d2f4..eb2a315a 100644 --- a/lib/quantum/supervisor.ex +++ b/lib/quantum/supervisor.ex @@ -3,8 +3,6 @@ defmodule Quantum.Supervisor do use Supervisor - require Logger - # Starts the quantum supervisor. @spec start_link(GenServer.server(), Keyword.t()) :: GenServer.on_start() def start_link(quantum, opts) do diff --git a/lib/quantum/task_registry.ex b/lib/quantum/task_registry.ex index 6826d41b..e9b1124c 100644 --- a/lib/quantum/task_registry.ex +++ b/lib/quantum/task_registry.ex @@ -3,8 +3,6 @@ defmodule Quantum.TaskRegistry do # Registry to check if a task is already running on a node. - require Logger - alias __MODULE__.StartOpts alias Quantum.Job diff --git a/test/quantum/executor_test.exs b/test/quantum/executor_test.exs index 283d8f64..c0560bd4 100644 --- a/test/quantum/executor_test.exs +++ b/test/quantum/executor_test.exs @@ -359,7 +359,8 @@ defmodule Quantum.ExecutorTest do assert :ok == wait_for_termination(task) end) - assert logs =~ ~r/\(RuntimeError\) failed/ + assert logs =~ ~r/type=error/ + assert logs =~ ~r/Execution failed for job/ assert_receive %{test_id: ^test_id, type: :start} assert_receive %{ @@ -408,7 +409,8 @@ defmodule Quantum.ExecutorTest do assert :ok == wait_for_termination(task) end) - assert logs =~ ~r/\(exit\) :failure/ + assert logs =~ ~r/type=exit/ + assert logs =~ ~r/value=failure/ assert_receive %{test_id: ^test_id, type: :start} assert_receive %{ @@ -459,7 +461,9 @@ defmodule Quantum.ExecutorTest do assert :ok == wait_for_termination(task) end) - assert logs =~ "(throw) #{inspect(ref)}" + '#Ref' ++ rest = :erlang.ref_to_list(ref) + assert logs =~ "type=throw" + assert logs =~ "value=#{rest}" assert_receive %{test_id: ^test_id, type: :start} assert_receive %{ diff --git a/test/quantum/job_broadcaster_test.exs b/test/quantum/job_broadcaster_test.exs index f8f408c6..54a2bc35 100644 --- a/test/quantum/job_broadcaster_test.exs +++ b/test/quantum/job_broadcaster_test.exs @@ -182,7 +182,7 @@ defmodule Quantum.JobBroadcasterTest do assert_receive {:received, {:add, ^active_job}} assert_receive {:add_job, ^active_job, _} - end) =~ "Adding job #Reference" + end) =~ "Adding job" assert_receive %{test_id: ^test_id} end From 3ac7984f4bfca2b47fb9d36e70e56fcaa302dbd6 Mon Sep 17 00:00:00 2001 From: pnezis Date: Fri, 16 Oct 2020 20:02:13 +0300 Subject: [PATCH 02/13] Support setting job `state` in scheduler's config (#463) * Support setting job `state` in scheduler's config * Add normalizer unit test --- lib/quantum/normalizer.ex | 4 ++++ pages/configuration.md | 3 ++- test/quantum/normalizer_test.exs | 16 ++++++++++++++++ test/quantum_startup_test.exs | 4 +++- 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/lib/quantum/normalizer.ex b/lib/quantum/normalizer.ex index ceb8814d..4afc18e1 100644 --- a/lib/quantum/normalizer.ex +++ b/lib/quantum/normalizer.ex @@ -85,6 +85,10 @@ defmodule Quantum.Normalizer do Job.set_timezone(job, normalize_timezone(timezone)) end + defp normalize_job_option({:state, state}, job) do + Job.set_state(job, state) + end + defp normalize_job_option(_, job), do: job @spec normalize_task(config_task) :: Job.task() | no_return diff --git a/pages/configuration.md b/pages/configuration.md index 3e683c57..5f5ab38e 100644 --- a/pages/configuration.md +++ b/pages/configuration.md @@ -15,7 +15,7 @@ config :your_app, YourApp.Scheduler, # Runs on 18, 20, 22, 0, 2, 4, 6: {"0 18-6/2 * * *", fn -> :mnesia.backup('/var/backup/mnesia') end}, # Runs every midnight: - {"@daily", {Backup, :backup, []}} + {"@daily", {Backup, :backup, []}, state: :inactive} ] ``` @@ -72,6 +72,7 @@ Possible options: - `task` function to be performed, ex: `{Heartbeat, :send, []}` or `fn -> :something end` - `run_strategy` strategy on how to run tasks inside of cluster, default: `%Quantum.RunStrategy.Random{nodes: :cluster}` - `overlap` set to false to prevent next job from being executed if previous job is still running, default: `true` +- `state` set to `:inactive` to deactivate a job or `:active` to activate it It is possible to control the behavior of jobs at runtime. diff --git a/test/quantum/normalizer_test.exs b/test/quantum/normalizer_test.exs index d9c2f6dc..d3313048 100644 --- a/test/quantum/normalizer_test.exs +++ b/test/quantum/normalizer_test.exs @@ -74,6 +74,22 @@ defmodule Quantum.NormalizerTest do assert normalize(Scheduler.new_job(), job) == expected_job end + test "normalizer of state" do + job = { + :newsletter, + [ + state: :inactive + ] + } + + expected_job = + Scheduler.new_job() + |> Job.set_name(:newsletter) + |> Job.set_state(:inactive) + + assert normalize(Scheduler.new_job(), job) == expected_job + end + test "expression tuple not extended" do job = { :newsletter, diff --git a/test/quantum_startup_test.exs b/test/quantum_startup_test.exs index 2b6ca2da..1431b783 100644 --- a/test/quantum_startup_test.exs +++ b/test/quantum_startup_test.exs @@ -19,6 +19,7 @@ defmodule QuantumStartupTest do test_jobs = [ {:test_job, [schedule: ~e[1 * * * *], task: fn -> :ok end]}, {:test_job, [schedule: ~e[2 * * * *], task: fn -> :ok end]}, + {:inactive_job, [schedule: ~e[* * * * *], task: fn -> :ok end, state: :inactive]}, {"3 * * * *", fn -> :ok end}, {"4 * * * *", fn -> :ok end} ] @@ -28,8 +29,9 @@ defmodule QuantumStartupTest do capture_log(fn -> {:ok, _pid} = start_supervised(Scheduler) - assert Enum.count(QuantumStartupTest.Scheduler.jobs()) == 3 + assert Enum.count(QuantumStartupTest.Scheduler.jobs()) == 4 assert QuantumStartupTest.Scheduler.find_job(:test_job).schedule == ~e[1 * * * *] + assert QuantumStartupTest.Scheduler.find_job(:inactive_job).state == :inactive :ok = stop_supervised(Scheduler) end) From 02e519fe0710b7c64a34762e3d3034d3ffb61aef Mon Sep 17 00:00:00 2001 From: Michael Bianco Date: Tue, 3 Nov 2020 10:06:18 -0700 Subject: [PATCH 03/13] Log exception with crash_reason (#464) * Log exception with crash reason * Formatting * Testing for error log with exception banner --- lib/quantum/executor.ex | 17 +++++++++++++++++ test/quantum/executor_test.exs | 1 + 2 files changed, 18 insertions(+) diff --git a/lib/quantum/executor.ex b/lib/quantum/executor.ex index 531b370b..65f42527 100644 --- a/lib/quantum/executor.ex +++ b/lib/quantum/executor.ex @@ -114,6 +114,8 @@ defmodule Quantum.Executor do } end) + log_exception(type, value, __STACKTRACE__) + duration = :erlang.monotonic_time() - start_monotonic_time :telemetry.execute([:quantum, :job, :exception], %{duration: duration}, %{ @@ -153,4 +155,19 @@ defmodule Quantum.Executor do defp execute_task(fun) when is_function(fun, 0) do fun.() end + + def log_exception(kind, reason, stacktrace) do + reason = Exception.normalize(kind, reason, stacktrace) + + crash_reason = + case kind do + :throw -> {{:nocatch, reason}, stacktrace} + _ -> {reason, stacktrace} + end + + Logger.error( + Exception.format(kind, reason, stacktrace), + crash_reason: crash_reason + ) + end end diff --git a/test/quantum/executor_test.exs b/test/quantum/executor_test.exs index c0560bd4..86627153 100644 --- a/test/quantum/executor_test.exs +++ b/test/quantum/executor_test.exs @@ -411,6 +411,7 @@ defmodule Quantum.ExecutorTest do assert logs =~ ~r/type=exit/ assert logs =~ ~r/value=failure/ + assert logs =~ "[error] ** (exit) :failure" assert_receive %{test_id: ^test_id, type: :start} assert_receive %{ From 5c3e4a2b826c1f62256ca734506af6633d44b17c Mon Sep 17 00:00:00 2001 From: pnezis Date: Mon, 30 Nov 2020 21:36:53 +0200 Subject: [PATCH 04/13] Optional `update_job` callback in `Quantum.Storage` (#467) If the callback is overriden then it will be called, otherwise the previous behaviour (delete and then add the job) is used. --- lib/quantum/job_broadcaster.ex | 51 ++++++++++----------------- lib/quantum/storage.ex | 10 ++++++ test/quantum/job_broadcaster_test.exs | 43 +++++++++++++++++++++- test/support/test_storage.ex | 10 ++++++ 4 files changed, 81 insertions(+), 33 deletions(-) diff --git a/lib/quantum/job_broadcaster.ex b/lib/quantum/job_broadcaster.ex index 35ed6f98..608bd76c 100644 --- a/lib/quantum/job_broadcaster.ex +++ b/lib/quantum/job_broadcaster.ex @@ -107,14 +107,7 @@ defmodule Quantum.JobBroadcaster do {"Replacing job", node: Node.self(), name: job_name} end) - # Send event to telemetry incase the end user wants to monitor events - :telemetry.execute([:quantum, :job, :update], %{}, %{ - job: job, - scheduler: state.scheduler - }) - - :ok = storage.delete_job(storage_pid, job_name) - :ok = storage.add_job(storage_pid, job) + :ok = update_job(storage, storage_pid, job, state.scheduler) {:noreply, [{:delete, old_job}, {:add, job}], %{state | jobs: Map.put(jobs, job_name, job)}} @@ -125,14 +118,7 @@ defmodule Quantum.JobBroadcaster do {"Replacing job", node: Node.self(), name: job_name} end) - # Send event to telemetry incase the end user wants to monitor events - :telemetry.execute([:quantum, :job, :update], %{}, %{ - job: job, - scheduler: state.scheduler - }) - - :ok = storage.delete_job(storage_pid, job_name) - :ok = storage.add_job(storage_pid, job) + :ok = update_job(storage, storage_pid, job, state.scheduler) {:noreply, [{:add, job}], %{state | jobs: Map.put(jobs, job_name, job)}} @@ -170,14 +156,7 @@ defmodule Quantum.JobBroadcaster do {"Replacing job", node: Node.self(), name: job_name} end) - # Send event to telemetry incase the end user wants to monitor events - :telemetry.execute([:quantum, :job, :update], %{}, %{ - job: job, - scheduler: state.scheduler - }) - - :ok = storage.delete_job(storage_pid, job_name) - :ok = storage.add_job(storage_pid, job) + :ok = update_job(storage, storage_pid, job, state.scheduler) {:noreply, [{:delete, old_job}], %{state | jobs: Map.put(jobs, job_name, job)}} @@ -187,14 +166,7 @@ defmodule Quantum.JobBroadcaster do {"Replacing job", node: Node.self(), name: job_name} end) - # Send event to telemetry incase the end user wants to monitor events - :telemetry.execute([:quantum, :job, :update], %{}, %{ - job: job, - scheduler: state.scheduler - }) - - :ok = storage.delete_job(storage_pid, job_name) - :ok = storage.add_job(storage_pid, job) + :ok = update_job(storage, storage_pid, job, state.scheduler) {:noreply, [], %{state | jobs: Map.put(jobs, job_name, job)}} @@ -361,4 +333,19 @@ defmodule Quantum.JobBroadcaster do def handle_info(_message, state) do {:noreply, [], state} end + + defp update_job(storage, storage_pid, %Job{name: job_name} = job, scheduler) do + # Send event to telemetry incase the end user wants to monitor events + :telemetry.execute([:quantum, :job, :update], %{}, %{ + job: job, + scheduler: scheduler + }) + + if function_exported?(storage, :update_job, 2) do + :ok = storage.update_job(storage_pid, job) + else + :ok = storage.delete_job(storage_pid, job_name) + :ok = storage.add_job(storage_pid, job) + end + end end diff --git a/lib/quantum/storage.ex b/lib/quantum/storage.ex index a55dac5f..3604efcc 100644 --- a/lib/quantum/storage.ex +++ b/lib/quantum/storage.ex @@ -76,4 +76,14 @@ defmodule Quantum.Storage do Purge all date from storage and go back to initial state. """ @callback purge(storage_pid :: storage_pid) :: :ok + + @doc """ + Updates existing job in storage. + + This callback is optional. If not implemented then the `c:delete_job/2` + and then the `c:add_job/2` callbacks will be called instead. + """ + @callback update_job(storage_pid :: storage_pid, job :: Job.t()) :: :ok + + @optional_callbacks update_job: 2 end diff --git a/test/quantum/job_broadcaster_test.exs b/test/quantum/job_broadcaster_test.exs index 54a2bc35..37818dcc 100644 --- a/test/quantum/job_broadcaster_test.exs +++ b/test/quantum/job_broadcaster_test.exs @@ -6,6 +6,7 @@ defmodule Quantum.JobBroadcasterTest do alias Quantum.{Job, JobBroadcaster, JobBroadcaster.StartOpts} alias Quantum.Storage.Test, as: TestStorage + alias Quantum.Storage.TestWithUpdate, as: TestStorageWithUpdate alias Quantum.TestConsumer import ExUnit.CaptureLog @@ -87,6 +88,15 @@ defmodule Quantum.JobBroadcasterTest do [] end + storage = + case tags[:storage] do + :with_update -> + TestStorageWithUpdate + + _ -> + TestStorage + end + broadcaster = if tags[:manual_dispatch] do nil @@ -98,7 +108,7 @@ defmodule Quantum.JobBroadcasterTest do %StartOpts{ name: __MODULE__, jobs: init_jobs, - storage: TestStorage, + storage: storage, scheduler: TestScheduler, debug_logging: true }} @@ -262,7 +272,38 @@ defmodule Quantum.JobBroadcasterTest do TestScheduler.add_job(broadcaster, job_2) assert_receive {:received, {:delete, ^job_1}} + assert_receive {:delete_job, ^test_name, _} + assert_receive {:received, {:add, ^job_2}} + assert_receive {:add_job, ^job_2, _} + end + + @tag listen_storage: true, storage: :with_update + test "storage with update does not add and delete job", %{ + broadcaster: broadcaster, + test: test_name + } do + job_1 = + TestScheduler.new_job() + |> Quantum.Job.set_name(test_name) + |> Quantum.Job.set_schedule(~e[*/5 * * * * *]e) + + TestScheduler.add_job(broadcaster, job_1) + + assert_receive {:received, {:add, ^job_1}} + assert_receive {:add_job, ^job_1, _} + + job_2 = + TestScheduler.new_job() + |> Quantum.Job.set_name(test_name) + |> Quantum.Job.set_schedule(~e[*/10 * * * * *]e) + + TestScheduler.add_job(broadcaster, job_2) + + assert_receive {:received, {:delete, ^job_1}} + refute_receive {:delete_job, ^test_name, _} assert_receive {:received, {:add, ^job_2}} + refute_receive {:add_job, ^job_2, _} + assert_receive {:update_job, ^job_2, _} end @tag listen_storage: true diff --git a/test/support/test_storage.ex b/test/support/test_storage.ex index a4c6934f..6535991c 100644 --- a/test/support/test_storage.ex +++ b/test/support/test_storage.ex @@ -114,3 +114,13 @@ defmodule Quantum.Storage.Test do end end end + +defmodule Quantum.Storage.TestWithUpdate do + @moduledoc """ + Test implementation of a `Quantum.Storage` that overrides `c:update_job/2`. + """ + use Quantum.Storage.Test + + @impl Quantum.Storage + def update_job(_storage_pid, job), do: send_and_wait(:update_job, job) +end From eca5ee0c17b2f974c7e9bffb36620a7024c76d03 Mon Sep 17 00:00:00 2001 From: pnezis Date: Tue, 1 Dec 2020 01:13:47 +0200 Subject: [PATCH 05/13] Fix tests and resolve a bug in ExecutionBroadcaster (#468) * Fix test issues - Include `:crash_reason` in logger metadata - Remove `:crash_reason` from `log_exception` - Change tests pattern matching * Return `state` in case of invalid timezone In case of invalid timezone no state was returned and as a result the GenServer was crashing due to a `FunctionClauseError` * Do not set crash_reason if elixir < 1.10 --- config/config.exs | 2 +- lib/quantum/execution_broadcaster.ex | 2 ++ lib/quantum/executor.ex | 23 ++++++++++++++--------- test/quantum/executor_test.exs | 8 +++----- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/config/config.exs b/config/config.exs index 75b858d5..895be551 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,3 +1,3 @@ use Mix.Config -config :logger, :console, metadata: :all +config :logger, :console, metadata: [:all, :crash_reason] config :elixir, :time_zone_database, Tzdata.TimeZoneDatabase diff --git a/lib/quantum/execution_broadcaster.ex b/lib/quantum/execution_broadcaster.ex index a7b8337d..b64b0789 100644 --- a/lib/quantum/execution_broadcaster.ex +++ b/lib/quantum/execution_broadcaster.ex @@ -263,6 +263,8 @@ defmodule Quantum.ExecutionBroadcaster do job: job, error: e ) + + state end defp get_next_execution_time( diff --git a/lib/quantum/executor.ex b/lib/quantum/executor.ex index 65f42527..f73c3096 100644 --- a/lib/quantum/executor.ex +++ b/lib/quantum/executor.ex @@ -159,15 +159,20 @@ defmodule Quantum.Executor do def log_exception(kind, reason, stacktrace) do reason = Exception.normalize(kind, reason, stacktrace) - crash_reason = - case kind do - :throw -> {{:nocatch, reason}, stacktrace} - _ -> {reason, stacktrace} - end + # TODO: Remove in a future version and make elixir 1.10 minimum requirement + if Version.match?(System.version(), "< 1.10.0") do + Logger.error(Exception.format(kind, reason, stacktrace)) + else + crash_reason = + case kind do + :throw -> {{:nocatch, reason}, stacktrace} + _ -> {reason, stacktrace} + end - Logger.error( - Exception.format(kind, reason, stacktrace), - crash_reason: crash_reason - ) + Logger.error( + Exception.format(kind, reason, stacktrace), + crash_reason: crash_reason + ) + end end end diff --git a/test/quantum/executor_test.exs b/test/quantum/executor_test.exs index 86627153..b72b3098 100644 --- a/test/quantum/executor_test.exs +++ b/test/quantum/executor_test.exs @@ -359,7 +359,7 @@ defmodule Quantum.ExecutorTest do assert :ok == wait_for_termination(task) end) - assert logs =~ ~r/type=error/ + assert logs =~ ~r/[error]/ assert logs =~ ~r/Execution failed for job/ assert_receive %{test_id: ^test_id, type: :start} @@ -409,8 +409,6 @@ defmodule Quantum.ExecutorTest do assert :ok == wait_for_termination(task) end) - assert logs =~ ~r/type=exit/ - assert logs =~ ~r/value=failure/ assert logs =~ "[error] ** (exit) :failure" assert_receive %{test_id: ^test_id, type: :start} @@ -463,8 +461,8 @@ defmodule Quantum.ExecutorTest do end) '#Ref' ++ rest = :erlang.ref_to_list(ref) - assert logs =~ "type=throw" - assert logs =~ "value=#{rest}" + assert logs =~ "[error] ** (throw)" + assert logs =~ "#{rest}" assert_receive %{test_id: ^test_id, type: :start} assert_receive %{ From 24997fb649d778f654c1adad0006f7ad529a1184 Mon Sep 17 00:00:00 2001 From: Tyler Pachal Date: Thu, 25 Feb 2021 05:45:21 -0400 Subject: [PATCH 06/13] Solution: Remove Supervisor.Spec references (#475) * solution-readme-supervisor-spec * Remove reference to Supervisor.Spec from pages --- README.md | 2 -- pages/supervision-tree.md | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index 16707821..32b6a676 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,6 @@ defmodule Acme.Application do use Application def start(_type, _args) do - import Supervisor.Spec, warn: false - children = [ # This is the new line Acme.Scheduler diff --git a/pages/supervision-tree.md b/pages/supervision-tree.md index 0f8e0ba6..0316fd33 100644 --- a/pages/supervision-tree.md +++ b/pages/supervision-tree.md @@ -12,4 +12,4 @@ ## Error Handling -The OTP Supervision Tree is initiated by the user of the library. Therefore the error handling can be implemented via normal OTP means. See `Supervisor.Spec` for more information. +The OTP Supervision Tree is initiated by the user of the library. Therefore the error handling can be implemented via normal OTP means. See `Supervisor` module for more information. From d8752427f1df5886c59573999155b0ce30051925 Mon Sep 17 00:00:00 2001 From: Kian Meng Ang Date: Sun, 28 Feb 2021 05:47:35 +0800 Subject: [PATCH 07/13] Misc doc changes (#476) List of changes: * Fix image not showing in HTML page * Use common source url * Fix invalid SPDX license id * Update license section * Set and use latest ex_doc * Fix markdown in changelog * Badges and more badges! * Fix typos * Update gitignore * Update formatter config * Remove extra spaces * Add logo --- .formatter.exs | 3 +- .github/workflows/elixir.yml | 12 ++-- .gitignore | 35 ++++++++-- CHANGELOG.md | 2 +- README.md | 69 +++++++++++-------- assets/quantum-elixir-logo.svg | 12 ++++ lib/quantum/run_strategy/all.ex | 2 +- lib/quantum/storage.ex | 2 +- mix.exs | 21 +++--- pages/configuration.md | 3 - pages/crontab-format.md | 4 +- .../{runtime.md => runtime-configuration.md} | 0 pages/supervision-tree.md | 6 +- pages/telemetry.md | 8 +-- 14 files changed, 113 insertions(+), 66 deletions(-) create mode 100644 assets/quantum-elixir-logo.svg rename pages/{runtime.md => runtime-configuration.md} (100%) diff --git a/.formatter.exs b/.formatter.exs index b11432d8..d2cda26e 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,3 +1,4 @@ +# Used by "mix format" [ - inputs: [".formatter.exs", "mix.exs", "{config,lib,test}/**/*.{ex,exs}"] + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] ] diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index b9db3118..138e2e5c 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -36,7 +36,7 @@ jobs: with: name: deps_lock path: mix.lock - + compile_dev: name: Compile Dev Environment @@ -76,7 +76,7 @@ jobs: with: name: compile_dev path: _build/dev/ - + compile_docs: name: Compile Docs Environment @@ -116,7 +116,7 @@ jobs: with: name: compile_docs path: _build/docs/ - + compile_test: name: Compile Test Environment (OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}}) @@ -162,7 +162,7 @@ jobs: with: name: compile_test-${{matrix.otp}}-${{matrix.elixir}} path: _build/test/ - + compile_prod: name: Compile Prod Environment @@ -205,7 +205,7 @@ jobs: format: name: Check Formatting - + runs-on: ubuntu-latest needs: ['deps'] @@ -397,4 +397,4 @@ jobs: - uses: actions/upload-artifact@v1 with: name: docs - path: doc \ No newline at end of file + path: doc diff --git a/.gitignore b/.gitignore index f5762b88..08946195 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,31 @@ -/_build -/deps -/doc -/docs +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where third-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. erl_crash.dump -mix.lock + +# Also ignore archive artifacts (built via "mix archive.build"). *.ez -/cover + +# Ignore package tarball (built via "mix hex.build"). +quantum-*.tar + +# Temporary files for e.g. tests. +/tmp/ + +# Misc. +mix.lock /priv/plts/*.plt -/priv/plts/*.plt.hash \ No newline at end of file +/priv/plts/*.plt.hash diff --git a/CHANGELOG.md b/CHANGELOG.md index fb0ba0d5..0b1e8a5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,7 +68,7 @@ Diff for [3.0.0-rc.2] ## 3.0.0-rc.1 - 2020-02-26 -## Changed +### Changed - A lot of function that were not for public use have been undocumented. Those are now considered internal and may break at any point in time. - `Quantum.Scheduler` has been renamed to `Quantum` diff --git a/README.md b/README.md index 32b6a676..11072a6c 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,29 @@ # Quantum -[Cron](https://en.wikipedia.org/wiki/Cron)-like job scheduler for [Elixir](http://elixir-lang.org/). - -[![Financial Contributors on Open Collective](https://opencollective.com/quantum/all/badge.svg?label=financial+contributors)](https://opencollective.com/quantum) [![Hex.pm Version](http://img.shields.io/hexpm/v/quantum.svg)](https://hex.pm/packages/quantum) -[![Hex docs](http://img.shields.io/badge/hex.pm-docs-green.svg?style=flat)](https://hexdocs.pm/quantum) -![.github/workflows/elixir.yml](https://github.com/quantum-elixir/quantum-core/workflows/.github/workflows/elixir.yml/badge.svg) +[![Financial Contributors on Open Collective](https://opencollective.com/quantum/all/badge.svg?label=financial+contributors)](https://opencollective.com/quantum) +[![CI](https://github.com/quantum-elixir/quantum-core/workflows/.github/workflows/elixir.yml/badge.svg)](https://github.com/quantum-elixir/quantum-core/actions/workflows/elixir.yml) [![Coverage Status](https://coveralls.io/repos/quantum-elixir/quantum-core/badge.svg?branch=master)](https://coveralls.io/r/quantum-elixir/quantum-core?branch=master) -[![Hex.pm](https://img.shields.io/hexpm/dt/quantum.svg)](https://hex.pm/packages/quantum) +[![Module Version](https://img.shields.io/hexpm/v/quantum.svg)](https://hex.pm/packages/quantum) +[![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/quantum/) +[![Total Download](https://img.shields.io/hexpm/dt/quantum.svg)](https://hex.pm/packages/quantum) +[![License](https://img.shields.io/hexpm/l/quantum.svg)](https://github.com/quantum-elixir/quantum-core/blob/master/LICENSE) +[![Last Updated](https://img.shields.io/github/last-commit/quantum-elixir/quantum-core.svg)](https://github.com/quantum-elixir/quantum-core/commits/master) > **This README follows master, which may not be the currently published version**. Here are the [docs for the latest published version of Quantum](https://hexdocs.pm/quantum/readme.html). +[Cron](https://en.wikipedia.org/wiki/Cron)-like job scheduler for [Elixir](http://elixir-lang.org/). + ## Setup -To use Quantum in your project, edit the `mix.exs` file and add Quantum to +To use Quantum in your project, edit the `mix.exs` file and add `Quantum` to **1. the list of dependencies:** ```elixir defp deps do - [{:quantum, "~> 3.0"}] + [ + {:quantum, "~> 3.0"} + ] end ``` @@ -90,10 +95,9 @@ More details on the usage can be found in the [Documentation](https://hexdocs.pm This project uses the [Collective Code Construction Contract (C4)](http://rfc.zeromq.org/spec:42/C4/) for all code changes. -> "Everyone, without distinction or discrimination, SHALL have an equal right to become a Contributor under the -terms of this contract." +> "Everyone, without distinction or discrimination, SHALL have an equal right to become a Contributor under the terms of this contract." -### tl;dr +### TL;DR 1. Check for [open issues](https://github.com/quantum-elixir/quantum-core/issues) or [open a new issue](https://github.com/quantum-elixir/quantum-core/issues/new) to start a discussion around [a problem](https://www.youtube.com/watch?v=_QF9sFJGJuc). 2. Issues SHALL be named as "Problem: _description of the problem_". @@ -106,7 +110,8 @@ terms of this contract." ### Code Contributors This project exists thanks to all the people who contribute. - + +[![Contributors](https://opencollective.com/quantum/contributors.svg?width=890&button=false)](https://github.com/quantum-elixir/quantum-core/graphs/contributors) ### Financial Contributors @@ -114,23 +119,33 @@ Become a financial contributor and help us sustain our community. [[Contribute]( #### Individuals - +[![Individuals](https://opencollective.com/quantum/individuals.svg?width=890)](https://opencollective.com/quantum) #### Organizations Support this project with your organization. Your logo will show up here with a link to your website. [[Contribute](https://opencollective.com/quantum/contribute)] - - - - - - - - - - - -## License - -[Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0) +[![Organization0](https://opencollective.com/quantum/organization/0/avatar.svg)](https://opencollective.com/quantum/organization/0/website) +[![Organization1](https://opencollective.com/quantum/organization/1/avatar.svg)](https://opencollective.com/quantum/organization/1/website) +[![Organization2](https://opencollective.com/quantum/organization/2/avatar.svg)](https://opencollective.com/quantum/organization/2/website) +[![Organization3](https://opencollective.com/quantum/organization/3/avatar.svg)](https://opencollective.com/quantum/organization/3/website) +[![Organization4](https://opencollective.com/quantum/organization/4/avatar.svg)](https://opencollective.com/quantum/organization/4/website) +[![Organization5](https://opencollective.com/quantum/organization/5/avatar.svg)](https://opencollective.com/quantum/organization/5/website) +[![Organization6](https://opencollective.com/quantum/organization/6/avatar.svg)](https://opencollective.com/quantum/organization/6/website) +[![Organization7](https://opencollective.com/quantum/organization/7/avatar.svg)](https://opencollective.com/quantum/organization/7/website) +[![Organization8](https://opencollective.com/quantum/organization/8/avatar.svg)](https://opencollective.com/quantum/organization/8/website) +[![Organization9](https://opencollective.com/quantum/organization/9/avatar.svg)](https://opencollective.com/quantum/organization/9/website) + +## Copyright and License + +Copyright (c) 2015 Constantin Rack + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at [http://www.apache.org/licenses/LICENSE-2.0](http://www.apache.org/licenses/LICENSE-2.0) + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/assets/quantum-elixir-logo.svg b/assets/quantum-elixir-logo.svg new file mode 100644 index 00000000..8e659297 --- /dev/null +++ b/assets/quantum-elixir-logo.svg @@ -0,0 +1,12 @@ + + + + quantum-elixir logo + Logo of quantum-elixir, a cron-like job scheduler for elixir + + + + + + + diff --git a/lib/quantum/run_strategy/all.ex b/lib/quantum/run_strategy/all.ex index 3de4edef..f0be096e 100644 --- a/lib/quantum/run_strategy/all.ex +++ b/lib/quantum/run_strategy/all.ex @@ -1,6 +1,6 @@ defmodule Quantum.RunStrategy.All do @moduledoc """ - Run job on all node of the node list + Run job on all node of the node list. If the node list is `:cluster`, all nodes of the cluster will be used. diff --git a/lib/quantum/storage.ex b/lib/quantum/storage.ex index 3604efcc..f88e9af9 100644 --- a/lib/quantum/storage.ex +++ b/lib/quantum/storage.ex @@ -1,6 +1,6 @@ defmodule Quantum.Storage do @moduledoc """ - Bahaviour to be implemented by all Storage Adapters. + Behaviour to be implemented by all Storage Adapters. The calls to the storage are blocking, make sure they're fast to not block the job execution. """ diff --git a/mix.exs b/mix.exs index c5e431b7..f00f033f 100644 --- a/mix.exs +++ b/mix.exs @@ -3,6 +3,7 @@ defmodule Quantum.Mixfile do use Mix.Project + @source_url "https://github.com/quantum-elixir/quantum-core" @version "3.3.0" def project do @@ -54,10 +55,10 @@ defmodule Quantum.Mixfile do "Jonatan Männchen" ], exclude_patterns: [~r[priv/plts]], - licenses: ["Apache License 2.0"], + licenses: ["Apache-2.0"], links: %{ - "Changelog" => "https://github.com/quantum-elixir/quantum-core/blob/master/CHANGELOG.md", - "GitHub" => "https://github.com/quantum-elixir/quantum-core" + "Changelog" => "#{@source_url}/blob/master/CHANGELOG.md", + "GitHub" => @source_url } } end @@ -66,13 +67,14 @@ defmodule Quantum.Mixfile do [ main: "readme", source_ref: "v#{@version}", - source_url: "https://github.com/quantum-elixir/quantum-core", + source_url: @source_url, + logo: "assets/quantum-elixir-logo.svg", extras: [ - "README.md", "CHANGELOG.md", + "README.md", "pages/supervision-tree.md", "pages/configuration.md", - "pages/runtime.md", + "pages/runtime-configuration.md", "pages/crontab-format.md", "pages/run-strategies.md", "pages/telemetry.md" @@ -97,13 +99,12 @@ defmodule Quantum.Mixfile do [ {:crontab, "~> 1.1"}, {:gen_stage, "~> 0.14 or ~> 1.0"}, + {:telemetry, "~> 0.4"}, {:tzdata, "~> 1.0", only: [:dev, :test]}, - {:earmark, "~> 1.0", only: [:dev, :docs], runtime: false}, - {:ex_doc, "~> 0.19", only: [:dev, :docs], runtime: false}, + {:ex_doc, ">= 0.0.0", only: [:dev, :docs], runtime: false}, {:excoveralls, "~> 0.5", only: [:test], runtime: false}, {:dialyxir, "~> 1.0-rc", only: [:dev], runtime: false}, - {:credo, "~> 1.0", only: [:dev], runtime: false}, - {:telemetry, "~> 0.4"} + {:credo, "~> 1.0", only: [:dev], runtime: false} ] end end diff --git a/pages/configuration.md b/pages/configuration.md index 5f5ab38e..26e1162a 100644 --- a/pages/configuration.md +++ b/pages/configuration.md @@ -155,6 +155,3 @@ Timezones can also be configured on a per-job basis. This overrides the default timezone: "America/New_York" } ``` - -## Telemetry Support - diff --git a/pages/crontab-format.md b/pages/crontab-format.md index 4bc16da5..54893a62 100644 --- a/pages/crontab-format.md +++ b/pages/crontab-format.md @@ -11,7 +11,7 @@ | month | 1-12 (or names) | | day of week | 0-6 (0 is Sunday, or use abbreviated names) | -The `second` field can only be used in extended cron expressions. +The `second` field can only be used in extended Cron expressions. Names can also be used for the `month` and `day of week` fields. Use the first three letters of the particular day or month (case does not matter). @@ -43,5 +43,5 @@ Instead of the first five fields, one of these special strings may be used: All Cron Expressions are parsed and evaluated by [crontab](https://hex.pm/packages/crontab). -Issues with parsing a cron expression can be reported here: +Issues with parsing a Cron expression can be reported here: [crontab GitHub issues](https://github.com/jshmrtn/crontab/issues) diff --git a/pages/runtime.md b/pages/runtime-configuration.md similarity index 100% rename from pages/runtime.md rename to pages/runtime-configuration.md diff --git a/pages/supervision-tree.md b/pages/supervision-tree.md index 0316fd33..40be512c 100644 --- a/pages/supervision-tree.md +++ b/pages/supervision-tree.md @@ -6,9 +6,9 @@ * `YourApp.Scheduler.JobBroadcaster` (`Quantum.JobBroadcaster`) - The `GenStage` that keeps track of all jobs. * `YourApp.Scheduler.ExecutionBroadcaster` (`Quantum.ExecutionBroadcaster`) - The `GenStage` that notifies execution of jobs. * `YourApp.Scheduler.ExecutorSupervisor` (`Quantum.ExecutorSupervisor`) - The `ConsumerSupervisor` that spawns an Executor for every execution. - - `no_name` (`YourApp.Scheduler.Executor`) - The `Task` that calls the `YourApp.Scheduler.Task.Supervisor` with the execution of the cron (per Node). - * `YourApp.Scheduler.Task.Supervisor` (`Task.Supervisor`) - The `Task.Supervisor` where all cron jobs run in. - - `Task` - The place where the defined cron job action gets called. + - `no_name` (`YourApp.Scheduler.Executor`) - The `Task` that calls the `YourApp.Scheduler.Task.Supervisor` with the execution of the Cron (per Node). + * `YourApp.Scheduler.Task.Supervisor` (`Task.Supervisor`) - The `Task.Supervisor` where all Cron jobs run in. + - `Task` - The place where the defined Cron job action gets called. ## Error Handling diff --git a/pages/telemetry.md b/pages/telemetry.md index 818b4b3c..40ebcc76 100644 --- a/pages/telemetry.md +++ b/pages/telemetry.md @@ -1,6 +1,6 @@ # Telemetry -Sice version [`3.2.0`](https://github.com/quantum-elixir/quantum-core/releases/tag/v3.2.0) `quantum` supports [`:telemetry`](https://hexdocs.pm/telemetry) metrics. +Since version [`3.2.0`](https://github.com/quantum-elixir/quantum-core/releases/tag/v3.2.0) `quantum` supports [`:telemetry`](https://hexdocs.pm/telemetry) metrics.