diff --git a/CHANGELOG.md b/CHANGELOG.md index fb81ad0..9efd1c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## v0.3.1 [2023-01-05] + +* Improved documentation. +* Minor code cleanup. +* Added CI workflow. + ## v0.3.0 [2022-06-27] ### Enhancements diff --git a/README.md b/README.md index 68cfcc4..acd815a 100644 --- a/README.md +++ b/README.md @@ -7,35 +7,37 @@ Asynchronous dependency health checks library. ## Features -* User defined dependencies checks with flexible settings. -* Dependencies checks functions are executed asynchronously. -* Built-in `Plug` module providing customizable response for a http health-check endpoint. -* Support for multiple independent `Existence` instances and associated health-checks - endpoints (example use case: separate Kubernetes readiness and liveness http probes). -* Checks states are stored and accessed using a dedicated ETS tables per `Existence` instance, - which means practically unlimited requests per second processing capacity. +* User defined dependencies health-checks with flexible settings, including configurable + health-check callback function timeout, startup delay and initial check state. +* `Existence.Plug` module providing customizable response for a http health-check endpoint. +* Support for multiple `Existence` instances and associated health-checks endpoints + (example use case: separate Kubernetes readiness and liveness http probes). +* Dependencies health-checks states are cached and accessed using a dedicated ETS table per + `Existence` instance, which means practically unlimited requests per second processing capacity + without putting unnecessary load on a health-checked dependency. ## Installation -Add `Existence` library to your application dependencies: +Add `Existence` library to your application `mix.exs` file: ```elixir +# mix.exs def deps do [ - {:existence, "~> 0.3.0"} + {:existence, "~> 0.3.1"} ] end ``` ## Usage -Define dependencies checks functions MFA's and start `Existence` child with your application -supervisor +Define dependencies checks callback functions MFA's and start `Existence` child with your application +supervisor: ```elixir +# lib/my_app/application.ex defmodule MyApp.Application do use Application def start(_type, _args) do health_checks = [ - check_1: %{mfa: {MyApp.Checks, :check_1, []}}, - check_2: %{mfa: {MyApp.Checks, :check_2, []}} + check_postgres: %{mfa: {MyApp.Checks, :check_postgres, []}} ] children = [{Existence, checks: health_checks}] @@ -46,35 +48,46 @@ defmodule MyApp.Application do end ``` -Declare your dependencies checks functions: +Declare your dependencies checks callback functions, in our example case PostgreSQL health-check: ```elixir +# lib/my_app/checks.ex defmodule MyApp.Checks do - def check_1(), do: :ok - def check_2(), do: :ok + def check_postgres() do + "SELECT 1;" + |> MyApp.Repo.query() + |> case do + {:ok, %Postgrex.Result{num_rows: 1, rows: [[1]]}} -> :ok + _ -> :error + end + end end ``` -Dependencies checks functions above are for illustrative purposes only, please refer to the -`Existence` module documentation for more realistic dependencies checks examples. - -Configure your Phoenix router to respond to the `/healthcheck` endpoint requests using for example -`Plug.Router.forward/2`: -```elixir -defmodule MyAppWeb.Router do - use MyAppWeb, :router - - forward("/healthcheck", Existence.Plug) -end -``` +Dependency health-check function `check_postgres/0` will be spawned asynchronously by default every +30 seconds, checking if PostgreSQL instance associated with `MyApp.Repo` is healthy. +`check_postgres/0` results will be cached in the ETS table used further to provide responses to user +requests. If `/healthcheck` endpoint requests are issued thousands of times per second, they do not +hit and overload PostgreSQL, instead cached results from ETS are returned. -Get current overall health-check state: +Current overall health-check state can be examined with `get_state/1`: ```elixir iex> Existence.get_state() :ok ``` -List individual dependencies checks current states: +Dependencies health-checks states can be examined with `get_checks/1`: ```elixir iex> Existence.get_checks() -[check_1: :ok, check_2: :ok] +[check_postgres: :ok] ``` + +Library provides Plug module generating responses to the `/healthcheck` endpoint requests. +Example `Existence.Plug` usage with `Plug.Router.forward/2`: +```elixir +defmodule MyAppWeb.Router do + use MyAppWeb, :router + + forward("/healthcheck", Existence.Plug) +end +``` + diff --git a/lib/existence.ex b/lib/existence.ex index 35c4705..eafb8ed 100644 --- a/lib/existence.ex +++ b/lib/existence.ex @@ -1,6 +1,6 @@ defmodule Existence do @moduledoc """ - Health-checks start and state access module. + Health-checks management and access module. Module provides functions for accessing an overall health-check state and individual dependencies checks results. @@ -16,28 +16,29 @@ defmodule Existence do Overall health-check unhealthy state is represented by an `:error` atom. User defined dependencies checks functions are spawned as monitored isolated processes. - If user dependency check function raises, throws an error, timeouts or fails in any way it - doesn't have a negative impact on other processes, including user application. + If user dependency check function raises, throws an error, timeouts or fails in any other way it + doesn't have a negative impact on other processes and it is gracefully handled by the library. Current dependencies checks functions results and current overall health-check state are stored in an ETS table. Whenever user executes any of available state getters, request is made against ETS table which has `:read_concurrency` set to `true`. - In practice it means that library can handle huge numbers of requests per second - without blocking any other processes. + In practice it means that library can handle unlimited numbers of requests per second + without blocking any other processes, adding latency to the response or overloading dependency + with synchronous requests. - Module provides few functions to access checks states: + Module provides following functions to access checks states: * `get_state/1` and `get_state!/1` to get overall health-check state, * `get_checks/1` and `get_checks!/1` to get dependencies checks states. Functions with bangs are negligibly cheaper computationally because they don't check if ETS table - storing Existence state exists and they will raise if such table doesn't exist. + storing given Existence instance state exists and they will raise if such table doesn't exist. ## Usage - After defining dependencies checks parameters, `Existence` can be started using + After defining dependencies checks options, `Existence` can be started using your application supervisor: ```elixir - #lib/my_app/application.ex + # lib/my_app/application.ex def start(_type, _args) do health_checks = [ # minimal dependency check configuration: @@ -67,7 +68,7 @@ defmodule Existence do Initial overall health-check state can be changed with a `:state` key. In a code example above initial overall health-check state is set to a healthy state with: `state: :ok`. - `Existence` supports starting multiple instances by using common Elixir child identifiers: + Multiple `Existence` instances can be started by using common Elixir child identifiers `:id` and `:name`, for example: ```elixir children = [ @@ -77,19 +78,18 @@ defmodule Existence do ``` ## Configuration - `Existence` startup options: - * `:id` - any term used to identify the child specification internally. Please refer to the - `Supervisor` "Child specification" documentation section for details on child `:id` key. - Default: `Existence`. - * `:name` - name used to start `Existence` `:gen_statem` process locally. If defined - as an `atom()` `:gen_statem.start_link/3` is used to start `Existence` process - without registration. - If defined as a `{:local, atom()}` tuple, `:gen_statem.start_link/4` is invoked and process is - registered locally with a given name. - Key value is used to select `Existence` instance when running any of the state getters, - for example: `get_state(CustomName)`. Default: `Existence`. + `Existence` options: + * `:id` - term used to identify the child specification internally. Please refer to the + `Supervisor` "Child specification" documentation section for details on child `:id` key. + Default: `Existence`. + * `:name` - name used to start `Existence` process locally. If defined as an `atom()` + `:gen_statem.start_link/3` is used to start `Existence` process without registration. + If defined as a `{:local, atom()}` tuple, `:gen_statem.start_link/4` is invoked and process is + registered locally with a given name. + Key value is used to select `Existence` instance when running any of the state getters, + for example: `get_state(CustomName)`. Default: `Existence`. * `:checks` - keyword list with user defined dependencies checks parameters, see description - below for details. Default: `[]`. + below for details. Default: `[]`. * `:state` - initial overall `Existence` instance health-check state. Default: `:error`. * `:on_state_change` - MFA tuple pointing at user function which will be synchronously applied on the overall health-check state change. @@ -98,29 +98,31 @@ defmodule Existence do Default: `nil`. Dependencies checks are defined using a keyword list with configuration parameters defined - as a maps. + as a maps, see code example above. Dependencies checks configuration options: * `:mfa` - `{module, function, arguments}` tuple specifying user defined function to spawn when - executing given dependency check. Please refer to `Kernel.apply/3` documentation for - the MFA pattern explanation. Required. + executing given dependency check. Please refer to `Kernel.spawn_monitor/3` documentation for + the MFA pattern explanation. Function will be spawned with arguments given in the `:mfa` key. + Required. * `:initial_delay` - amount of time in milliseconds to wait before spawning a dependency check - for the first time. Can be used to wait for a dependency process to properly initialize before - executing dependency check function first time when application is started. Default: `100`. + for the first time. Can be used to wait for a dependency process to properly initialize before + executing dependency check function first time when application is started. Default: `100`. * `:interval` - time interval in milliseconds specifying how frequently given check should be - executed. Default: `30_000`. + executed and dependency checked. Default: `30_000`. * `:state` - initial dependency check state when starting `Existence`. Default: `:error`. - * `:timeout` - after spawning dependency check function library will wait `:timeout` amount of - milliseconds for the dependency check function to complete. - If dependency check function will do not complete within a given timeout, dependency check - function process will be killed, and dependency check state will assume a `:killed` atom value. - Default: `5_000`. + * `:timeout` - after spawning dependency check function we will wait `:timeout` amount of + milliseconds for the dependency check function to complete. + If dependency check function will do not complete within a given timeout, dependency check + function process will be killed, and dependency check state will assume a `:killed` value. + Default: `5_000`. ## Dependencies checks - User defined dependencies checks functions must return an `:ok` atom for the healthy state. + User defined dependencies checks functions must return an `:ok` atom within given `:timeout` + interval to acquire a healthy state. Any other values returned by dependencies checks functions are considered as an unhealthy state. - Example checks for two popular dependencies, PostgreSQL and Redis: + Example health-checks callback functions for two popular dependencies, PostgreSQL and Redis: ```elixir #lib/checks.ex def check_postgres() do @@ -169,21 +171,18 @@ defmodule Existence do ] @doc """ - Get dependencies checks states. + Get dependencies checks states for `name` instance. - Function gets current dependencies checks states for an `Existence` instance started with - a given `name`. - If `name` is not provided, checks states for instance with default `:name` (`Existence`) - are returned. + Function gets current dependencies checks states for an instance started with a given `name`, + by default `Existence`. Dependencies checks functions results are returned as a keyword list. If no checks were defined function will return an empty list. + Function returns `:undefined` if `name` instance doesn't exist. Dependency check function result equal to an `:ok` atom means healthy state, any other term is associated with an unhealthy state. - Function returns `:undefined` if `name` instance doesn't exist. - ##### Example: ```elixir iex> Existence.get_checks() @@ -207,10 +206,9 @@ defmodule Existence do end @doc """ - Same as `get_checks/1` but raises on error. + Same as `get_checks/1` but raises if `name` instance doesn't exist. - Function will raise with an `ArgumentError` exception if `Existence` instance `name` - doesn't exist. + Function will raise with an `ArgumentError` exception if instance `name` doesn't exist. ##### Example: ```elixir @@ -231,19 +229,16 @@ defmodule Existence do end @doc """ - Get an overall health-check state. + Get an overall health-check state for `name` instance. - Function gets current overall health-check state for an `Existence` instance started with - a given `name`. - If `name` is not provided, overall health-check state for an instance with default `:name` - (`Existence`) is returned. - - Function returns an `:ok` atom when overall health-check state is healthy and an `:error` atom - when state is unhealthy. - Overall health-check state is healthy only when all dependencies health checks are healthy. + Function gets current overall health-check state for an instance started with a given `name`, + by default `Existence`. + Function returns an `:ok` when overall health-check state is healthy and an `:error` when state + is unhealthy. Function returns `:undefined` if `name` instance doesn't exist. + Overall health-check state is healthy only when all dependencies health checks are healthy. ##### Example: ```elixir @@ -268,10 +263,9 @@ defmodule Existence do end @doc """ - Same as `get_state/1` but raises on error. + Same as `get_state/1` but raises if `name` instance doesn't exist. - Function will raise with an `ArgumentError` exception if `Existence` instance `name` - doesn't exist. + Function will raise with an `ArgumentError` exception if instance `name` doesn't exist. ##### Example: ```elixir @@ -315,7 +309,6 @@ defmodule Existence do @impl true def init(args) do - Process.flag(:trap_exit, true) ets_tab = Keyword.fetch!(args, :ets_name) :ets.new(ets_tab, [ @@ -370,7 +363,8 @@ defmodule Existence do end def unhealthy(:info, {:DOWN, ref, :process, pid, :normal}, data) do - find_check(pid, ref, data) + pid + |> find_check(ref, data) |> maybe_respawn_check() :keep_state_and_data @@ -404,7 +398,8 @@ defmodule Existence do end def healthy(:info, {:DOWN, ref, :process, pid, :normal}, data) do - find_check(pid, ref, data) + pid + |> find_check(ref, data) |> maybe_respawn_check() :keep_state_and_data diff --git a/lib/plug.ex b/lib/plug.ex index e75c312..16e08f1 100644 --- a/lib/plug.ex +++ b/lib/plug.ex @@ -3,7 +3,7 @@ defmodule Existence.Plug do Plug responding with a health-check state. Plug sends a `text/plain` response depending on the `Existence` overall health-check state - returned by an `Existence.get_state/1` function. + returned by an `Existence.get_state/1` or `Existence.get_state!/1` function. ## Configuration Plug is configured with a keyword list. @@ -19,7 +19,7 @@ defmodule Existence.Plug do get an overall health-check state. If set to false, not raising `Existence.get_state/1` function will be used. Default: `false`. - * `:name` - `Existence` name same as a name used when starting instance with supervision tree. + * `:name` - instance name defined when starting `Existence` child with supervision tree. Default: `Existence`. ## Usage diff --git a/mix.exs b/mix.exs index 355f5b7..57b34d0 100644 --- a/mix.exs +++ b/mix.exs @@ -2,7 +2,7 @@ defmodule Existence.MixProject do use Mix.Project @source_url "https://github.com/Recruitee/existence" - @version "0.3.0" + @version "0.3.1" def project do [ @@ -25,7 +25,7 @@ defmodule Existence.MixProject do defp deps do [ {:plug, "~> 1.10"}, - {:ex_doc, "~> 0.28.0", only: :dev, runtime: false} + {:ex_doc, "~> 0.29.0", only: :dev, runtime: false} ] end diff --git a/mix.lock b/mix.lock index 4c228cf..0dec99f 100644 --- a/mix.lock +++ b/mix.lock @@ -1,12 +1,12 @@ %{ - "earmark_parser": {:hex, :earmark_parser, "1.4.26", "f4291134583f373c7d8755566122908eb9662df4c4b63caa66a0eabe06569b0a", [:mix], [], "hexpm", "48d460899f8a0c52c5470676611c01f64f3337bad0b26ddab43648428d94aabc"}, - "ex_doc": {:hex, :ex_doc, "0.28.4", "001a0ea6beac2f810f1abc3dbf4b123e9593eaa5f00dd13ded024eae7c523298", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "bf85d003dd34911d89c8ddb8bda1a958af3471a274a4c2150a9c01c78ac3f8ed"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.29", "149d50dcb3a93d9f3d6f3ecf18c918fb5a2d3c001b5d3305c926cddfbd33355b", [:mix], [], "hexpm", "4902af1b3eb139016aed210888748db8070b8125c2342ce3dcae4f38dcc63503"}, + "ex_doc": {:hex, :ex_doc, "0.29.1", "b1c652fa5f92ee9cf15c75271168027f92039b3877094290a75abcaac82a9f77", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "b7745fa6374a36daf484e2a2012274950e084815b936b1319aeebcf7809574f6"}, "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, - "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"}, + "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"}, "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, - "plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"}, - "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, - "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, + "plug": {:hex, :plug, "1.14.0", "ba4f558468f69cbd9f6b356d25443d0b796fbdc887e03fa89001384a9cac638f", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bf020432c7d4feb7b3af16a0c2701455cbbbb95e5b6866132cb09eb0c29adc14"}, + "plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"}, + "telemetry": {:hex, :telemetry, "1.2.0", "a8ce551485a9a3dac8d523542de130eafd12e40bbf76cf0ecd2528f24e812a44", [:rebar3], [], "hexpm", "1427e73667b9a2002cf1f26694c422d5c905df889023903c4518921d53e3e883"}, }