├── .formatter.exs ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── coveralls.json ├── lib ├── eternal.ex └── eternal │ ├── priv.ex │ ├── server.ex │ ├── supervisor.ex │ └── table.ex ├── mix.exs └── test ├── eternal ├── priv_test.exs └── table_test.exs ├── eternal_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | # set of input files and directories to apply formatting to 3 | inputs: ["{mix,.credo,.formatter}.exs", "{benchmarks,lib,test}/**/*.{ex,exs}"], 4 | 5 | # match the Credo linter 6 | line_length: 80 7 | ] 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | name: Elixir ${{ matrix.elixir }} 12 | runs-on: ubuntu-latest 13 | container: 14 | image: elixir:${{ matrix.elixir }} 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | elixir: 19 | - '1.16' 20 | - '1.15' 21 | - '1.14' 22 | - '1.13' 23 | - '1.12' 24 | - '1.11' 25 | - '1.10' 26 | - '1.9' 27 | - '1.8' 28 | - '1.7' 29 | - '1.6' 30 | 31 | steps: 32 | - uses: actions/checkout@v3 33 | 34 | - name: Setup Environment 35 | run: | 36 | epmd -daemon 37 | mix local.hex --force 38 | mix local.rebar --force 39 | mix deps.get 40 | 41 | - name: Run Tests 42 | run: mix test --trace 43 | 44 | Coverage: 45 | name: Coverage Reporting 46 | runs-on: ubuntu-latest 47 | container: 48 | image: elixir:1.16 49 | env: 50 | MIX_ENV: cover 51 | steps: 52 | - uses: actions/checkout@v3 53 | 54 | - name: Setup Environment 55 | run: | 56 | epmd -daemon 57 | mix local.hex --force 58 | mix local.rebar --force 59 | mix deps.get 60 | 61 | - name: Generate Coverage 62 | run: mix coveralls.github 63 | env: 64 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /_build 2 | /cover 3 | /deps 4 | /doc 5 | .elixir_ls 6 | erl_crash.dump 7 | mix.lock 8 | *.ez 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Isaac Whitfield 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Eternal 2 | [![Build Status](https://img.shields.io/github/actions/workflow/status/whitfin/eternal/ci.yml?branch=main)](https://github.com/whitfin/eternal/actions) [![Coverage Status](https://img.shields.io/coveralls/whitfin/eternal.svg)](https://coveralls.io/github/whitfin/eternal) [![Hex.pm Version](https://img.shields.io/hexpm/v/eternal.svg)](https://hex.pm/packages/eternal) [![Documentation](https://img.shields.io/badge/docs-latest-blue.svg)](https://hexdocs.pm/eternal/) 3 | 4 | Eternal is a simple way to monitor an ETS table to ensure that it never dies. It works by using bouncing GenServers to ensure that both an owner and heir are always available, via the use of scheduled monitoring and message passing. The idea is similar to that of the Immortal library, but taking it further to ensure a more bulletproof solution - and removing the need to have a single process dedicated to owning your ETS table. 5 | 6 | ## Installation 7 | 8 | Eternal is available on [Hex](https://hex.pm/). You can install the package by adding `eternal` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [{:eternal, "~> 1.2"}] 13 | end 14 | ``` 15 | 16 | Then running `mix deps.get` will pull the package from the registry. 17 | 18 | ## Usage 19 | 20 | ### Manual Startup 21 | 22 | The API of Eternal is quite small in order to reduce the risk of potential crashes (as that would cause you to lose your ETS tables). You'll probably just want to use `Eternal.start_link/3` which behaves quite similarly to `:ets.new/2`. 23 | 24 | The first two arguments are identical to `:ets.new/2`, and the latter is just a Keyword List of options to configure Eternal. It should be noted that the table will always have the `:public` (for table access) and `:named_table` (for table naming) arguments passed in, whether specified or not. Both the second and third arguments are optional. 25 | 26 | ```elixir 27 | iex> Eternal.start_link(:table1, [ :set, { :read_concurrency, true }]) 28 | { :ok, #PID<0.402.0> } 29 | iex> Eternal.start_link(:table2, [ :set, { :read_concurrency, true }], [ quiet: true ]) 30 | { :ok, #PID<0.406.0> } 31 | ``` 32 | 33 | For further usage examples, please see the [documentation](https://hexdocs.pm/eternal/). 34 | 35 | ### Application Supervision 36 | 37 | I'd highly recommend setting up an Application and letting Eternal start up inside the Supervision tree this way - just make sure that your strategy is `:one_for_one`, otherwise a crash in a different child in the tree would restart your ETS table. 38 | 39 | ```elixir 40 | defmodule MyApplication do 41 | use Application 42 | 43 | @impl true 44 | def start(_type, _args) do 45 | children = [ 46 | %{ 47 | id: Eternal, 48 | start: {Eternal, :start_link, [:table, [:compressed], [quiet: true]]} 49 | } 50 | ] 51 | 52 | # See https://hexdocs.pm/elixir/Supervisor.html 53 | # for other strategies and supported options 54 | opts = [strategy: :one_for_one] 55 | Supervisor.start_link(children, opts) 56 | end 57 | end 58 | ``` 59 | 60 | If you need a strategy other than `:one_for_one` (which is rare), you can simply hoist Eternal to a tree above your main application tree. This is a little more complicated, but ensures your tables are safe. You can do this using something like the following (you can see how Eternal is distanced from your app logic which may cause a restart): 61 | 62 | ```elixir 63 | defmodule MyApplication do 64 | # define application 65 | use Application 66 | 67 | # See http://elixir-lang.org/docs/stable/elixir/Application.html 68 | # for more information on OTP Applications 69 | def start(_type, _args) do 70 | import Supervisor.Spec, warn: false 71 | 72 | # Note how we create our main application tree separately to our Eternal 73 | # tree, thus making Eternal resistant to crashes around your application. 74 | children = [ 75 | supervisor(Eternal, [:table, [ :compressed ], [ quiet: true ]]), 76 | supervisor(Supervisor, [MyApplication.OneForAllSupervisor, [ ]]) 77 | ] 78 | 79 | # See http://elixir-lang.org/docs/stable/elixir/Supervisor.html 80 | # for other strategies and supported options 81 | opts = [ strategy: :one_for_one ] 82 | Supervisor.start_link(children, opts) 83 | end 84 | end 85 | 86 | defmodule MyApplication.OneForAllSupervisor do 87 | use Supervisor 88 | 89 | def init([]) do 90 | children = [ worker(MyModuleWhichMightCrash, []) ] 91 | supervise(children, strategy: :one_for_all) 92 | end 93 | end 94 | ``` 95 | 96 | ## Contributions 97 | 98 | If you feel something can be improved, or have any questions about certain behaviours or pieces of implementation, please feel free to file an issue. Proposed changes should be taken to issues before any PRs to avoid wasting time on code which might not be merged upstream. 99 | 100 | ## Credits 101 | 102 | Thanks to the following for the inspiration for this project: 103 | 104 | - [Daniel Berkompas](https://github.com/danielberkompas/immortal) 105 | - [Steve Vinoski](http://steve.vinoski.net/blog/2011/03/23/dont-lose-your-ets-tables/) 106 | -------------------------------------------------------------------------------- /coveralls.json: -------------------------------------------------------------------------------- 1 | { 2 | "default_stop_words": [ 3 | "defmodule", 4 | "defrecord", 5 | "defimpl", 6 | "defexception", 7 | "defprotocol", 8 | "defstruct", 9 | "defdelegate", 10 | "def.+(.+\\\\.+).+do", 11 | "^\\s+use\\s+" 12 | ], 13 | "custom_stop_words": [ 14 | "with" 15 | ], 16 | "coverage_options": { 17 | "treat_no_relevant_lines_as_covered": true 18 | }, 19 | "skip_files": [ ] 20 | } 21 | -------------------------------------------------------------------------------- /lib/eternal.ex: -------------------------------------------------------------------------------- 1 | defmodule Eternal do 2 | @moduledoc """ 3 | This module implements bindings around what should be an eternal ETS table, 4 | or at least until you decide to terminate it. It works by using "bouncing" 5 | GenServers which come up as needed to provide an heir for the ETS table. It 6 | operates as follows: 7 | 8 | 1. An ETS table is created with the provided name and options. 9 | 2. Two GenServers are started, an `owner` and an `heir`. The ETS table is gifted 10 | to the `owner`, and has the `heir` set as the heir. 11 | 3. If the `owner` crashes, the `heir` becomes the owner, and a new GenServer 12 | is started and assigned the role of `heir`. 13 | 4. If an `heir` dies, we attempt to start a new GenServer and notify the `owner` 14 | so that they may change the assigned `heir`. 15 | 16 | This means that there should always be an `heir` to your table, which should 17 | ensure that you don't lose anything inside ETS. 18 | """ 19 | 20 | # import guards 21 | import Eternal.Table 22 | import Eternal.Priv 23 | 24 | # alias while we're at it 25 | alias Eternal.Priv 26 | alias Eternal.Table 27 | alias Eternal.Supervisor, as: Sup 28 | 29 | # Return values of `start_link` functions 30 | @type on_start :: 31 | {:ok, pid} 32 | | :ignore 33 | | {:error, {:already_started, pid} | {:shutdown, term} | term} 34 | 35 | @doc """ 36 | Creates a new ETS table using the provided `ets_opts`. 37 | 38 | These options are passed through as-is, with the exception of prepending the 39 | `:public` and `:named_table` options. Seeing as you can't execute inside the 40 | GenServers, your table will have to be public to be interacted with. 41 | 42 | ## Options 43 | 44 | You may provide a third parameter containing Eternal options: 45 | 46 | - `:name` - override the default naming scheme and use a custom name for this 47 | table. Remember to use this name when calling `stop/1`. 48 | - `:quiet` - by default, Eternal logs debug messages. Setting this to true will 49 | disable this logging. 50 | 51 | ## Examples 52 | 53 | iex> Eternal.start_link(:table1) 54 | { :ok, _pid1 } 55 | 56 | iex> Eternal.start_link(:table2, [ :compressed ]) 57 | { :ok, _pid2 } 58 | 59 | iex> Eternal.start_link(:table3, [ ], [ quiet: true ]) 60 | { :ok, _pid3 } 61 | 62 | """ 63 | @spec start_link(name :: atom, ets_opts :: Keyword.t(), opts :: Keyword.t()) :: 64 | on_start 65 | def start_link(name, ets_opts \\ [], opts \\ []) 66 | when is_opts(name, ets_opts, opts) do 67 | with {:ok, pid, _table} <- create(name, [:named_table] ++ ets_opts, opts) do 68 | {:ok, pid} 69 | end 70 | end 71 | 72 | @doc """ 73 | Functionally equivalent to `start_link/3`, except that the link to the starting 74 | process is removed after the table is started. 75 | 76 | ## Examples 77 | 78 | iex> Eternal.start(:table1) 79 | { :ok, _pid1 } 80 | 81 | iex> Eternal.start(:table2, [ :compressed ]) 82 | { :ok, _pid2 } 83 | 84 | iex> Eternal.start(:table3, [ ], [ quiet: true ]) 85 | { :ok, _pid3 } 86 | 87 | """ 88 | @spec start(name :: atom, ets_opts :: Keyword.t(), opts :: Keyword.t()) :: 89 | on_start 90 | def start(name, ets_opts \\ [], opts \\ []) 91 | when is_opts(name, ets_opts, opts) do 92 | with {:ok, pid} = v <- start_link(name, ets_opts, opts) do 93 | :erlang.unlink(pid) && v 94 | end 95 | end 96 | 97 | @doc """ 98 | Returns the heir of a given ETS table. 99 | 100 | ## Examples 101 | 102 | iex> Eternal.heir(:my_table) 103 | #PID<0.134.0> 104 | 105 | """ 106 | @spec heir(table :: Table.t()) :: pid | :undefined 107 | def heir(table) when is_table(table), 108 | do: :ets.info(table, :heir) 109 | 110 | @doc """ 111 | Returns the owner of a given ETS table. 112 | 113 | ## Examples 114 | 115 | iex> Eternal.owner(:my_table) 116 | #PID<0.132.0> 117 | 118 | """ 119 | @spec owner(table :: Table.t()) :: pid | :undefined 120 | def owner(table) when is_table(table), 121 | do: :ets.info(table, :owner) 122 | 123 | @doc """ 124 | Terminates both servers in charge of a given ETS table. 125 | 126 | Note: this will terminate your ETS table. 127 | 128 | ## Examples 129 | 130 | iex> Eternal.stop(:my_table) 131 | :ok 132 | 133 | """ 134 | @spec stop(table :: Table.t()) :: :ok 135 | def stop(table) when is_table(table) do 136 | name = Table.to_name(table) 137 | proc = GenServer.whereis(name) 138 | 139 | if proc && Process.alive?(proc) do 140 | Supervisor.stop(proc) 141 | end 142 | 143 | :ok 144 | end 145 | 146 | # Creates a table supervisor with the provided options and nominates the children 147 | # as owner/heir of the ETS table immediately afterwards. We do this by fetching 148 | # the children of the supervisor and using the process id to nominate. 149 | defp create(name, ets_opts, opts) do 150 | with {:ok, pid, table} = res <- Sup.start_link(name, ets_opts, opts) do 151 | [proc1, proc2] = Supervisor.which_children(pid) 152 | 153 | {_id1, pid1, :worker, [__MODULE__.Server]} = proc1 154 | {_id2, pid2, :worker, [__MODULE__.Server]} = proc2 155 | 156 | Priv.heir(table, pid2) 157 | Priv.gift(table, pid1) 158 | 159 | res 160 | end 161 | end 162 | end 163 | -------------------------------------------------------------------------------- /lib/eternal/priv.ex: -------------------------------------------------------------------------------- 1 | defmodule Eternal.Priv do 2 | @moduledoc false 3 | # This module contains code private to the Eternal project, basically just 4 | # providing utility functions and macros. Nothing too interesting to see here 5 | # beyond shorthands for common blocks. 6 | 7 | # we need is_table/1 8 | import Eternal.Table 9 | 10 | # we also need logging 11 | require Logger 12 | 13 | @doc """ 14 | Provides a safe execution environment for ETS actions. 15 | 16 | If any errors occur inside ETS, we simply return a false value. It should be 17 | noted that the table is passed through purely as sugar so we can use inline 18 | anonymous functions. 19 | """ 20 | @spec ets_try(table :: Table.t(), fun :: function) :: any | false 21 | def ets_try(table, fun) when is_table(table) and is_function(fun, 1) do 22 | fun.(table) 23 | rescue 24 | _ -> false 25 | end 26 | 27 | @doc """ 28 | Gifts away an ETS table to another process. 29 | 30 | This must be called from within the owning process. 31 | """ 32 | @spec gift(table :: Table.t(), pid :: pid) :: any | false 33 | def gift(table, pid) when is_table(table) and is_pid(pid), 34 | do: ets_try(table, &:ets.give_away(&1, pid, :gift)) 35 | 36 | @doc """ 37 | Sets the Heir of an ETS table to a given process. 38 | 39 | This must be called from within the owning process. 40 | """ 41 | @spec heir(table :: Table.t(), pid :: pid) :: any | false 42 | def heir(table, pid) when is_table(table) and is_pid(pid), 43 | do: ets_try(table, &:ets.setopts(&1, {:heir, pid, :heir})) 44 | 45 | @doc """ 46 | Logs a message inside a noisy environment. 47 | 48 | If the options contains a truthy quiet flag, no logging occurs. 49 | """ 50 | @spec log(msg :: any, opts :: Keyword.t()) :: :ok 51 | def log(msg, opts) when is_list(opts) do 52 | noisy(opts, fn -> 53 | Logger.debug("[eternal] #{msg}") 54 | end) 55 | end 56 | 57 | @doc """ 58 | Executes a function only in a noisy environment. 59 | 60 | Noisy environments are determined by the opts having a falsy quiet flag. 61 | """ 62 | @spec noisy(opts :: Keyword.t(), fun :: function) :: :ok 63 | def noisy(opts, fun) when is_list(opts) and is_function(fun, 0) do 64 | !Keyword.get(opts, :quiet) && fun.() 65 | :ok 66 | end 67 | 68 | @doc """ 69 | Converts a PID to a Binary using `inspect/1`. 70 | """ 71 | @spec spid(pid :: pid) :: spid :: binary 72 | def spid(pid), 73 | do: inspect(pid) 74 | 75 | @doc """ 76 | Determines if a list of arguments are correctly formed. 77 | """ 78 | defmacro is_opts(name, ets_opts, opts) do 79 | quote do 80 | is_atom(unquote(name)) and 81 | is_list(unquote(ets_opts)) and 82 | is_list(unquote(opts)) 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/eternal/server.ex: -------------------------------------------------------------------------------- 1 | defmodule Eternal.Server do 2 | @moduledoc false 3 | # This module remains internal to Eternal and should not be manually created 4 | # by anyone external so it shall remain undocumented for now. 5 | # 6 | # Basically, this module implements an extremely base GenServer which listens 7 | # on two messages - the first is that of ETS to trigger a log that the table 8 | # owner has changed, and the other is a recognisation that a new heir has been 9 | # attached and needs assigning to ETS (as only an owner can set the heir). 10 | 11 | # use default server behaviour 12 | use GenServer 13 | 14 | # add a Priv alias 15 | alias Eternal.Priv 16 | 17 | @doc """ 18 | Simply performs a base validation and passes through the arguments to the server. 19 | """ 20 | def start_link({_table, _opts, _base} = args), 21 | do: GenServer.start_link(__MODULE__, args) 22 | 23 | @doc """ 24 | Initialization phase of an Eternal server. 25 | 26 | If the server process is intended to be a new heir, we message the owner in order 27 | to let it know we need adding as an heir. We don't do this is there is no owner 28 | or the owner is the base process creating the Eternal table, as this would result 29 | in no heir being assigned. 30 | """ 31 | def init({table, opts, base}) do 32 | owner = Eternal.owner(table) 33 | 34 | unless owner in [:undefined, base] do 35 | send(owner, {:"ETS-HEIR-UP", table, self()}) 36 | end 37 | 38 | {:ok, {table, opts}} 39 | end 40 | 41 | # Handles the transfer of an ETS table from an owner to an heir, with the server 42 | # receiving this message being the heir. We log the change before starting a new 43 | # heir and triggering a monitor to occur against this server (the new owner). 44 | def handle_info({:"ETS-TRANSFER", table, from, reason}, {table, opts} = state) do 45 | Priv.log( 46 | "Table '#{table}' #{reason}ed to #{Priv.spid(self())} via #{Priv.spid(from)}", 47 | opts 48 | ) 49 | 50 | {:noreply, state} 51 | end 52 | 53 | # Handles the termination of an heir, through the usual GenServer stop functions. 54 | # In this scenario the heir will attempt to message through to the owner in order 55 | # to inform it to create a new heir, which it will then carry out. 56 | def handle_info({:"ETS-HEIR-UP", table, pid}, {table, opts} = state) do 57 | Priv.heir(table, pid) 58 | Priv.log("Assigned new heir #{Priv.spid(pid)}", opts) 59 | {:noreply, state} 60 | end 61 | 62 | # Catch all info handler to ensure that we don't crash for whatever reason when 63 | # an unrecognised message is sent. In theory, a crash shouldn't be an issue, but 64 | # it's better logically to avoid doing so here. 65 | def handle_info(_msg, state), 66 | do: {:noreply, state} 67 | end 68 | -------------------------------------------------------------------------------- /lib/eternal/supervisor.ex: -------------------------------------------------------------------------------- 1 | defmodule Eternal.Supervisor do 2 | @moduledoc false 3 | # This module contains the main Eternal Supervisor which is used to manage the 4 | # two internal GenServers which act as owner/heir to the ETS table. There's little 5 | # in here beyond setting up a Supervision tree, as we want to keep the code simple 6 | # to make it pretty bulletproof as an implementation. 7 | 8 | # this is a supervisor 9 | use Supervisor 10 | 11 | # we need some guards 12 | import Eternal.Priv 13 | 14 | # add alias for convenience 15 | alias Application, as: App 16 | alias Eternal.Priv 17 | alias Eternal.Table 18 | 19 | @doc """ 20 | Starts an Eternal Supervision tree which manages the two internal servers. 21 | 22 | This returns a Tuple containing the table name, so it cannot be used inside a 23 | Supervision tree directly. If you want to use this Supervisor, you should go 24 | via the main Eternal module. 25 | """ 26 | @spec start_link(name :: atom, ets_opts :: Keyword.t(), opts :: Keyword.t()) :: 27 | {:ok, pid, Table.t()} 28 | | :ignore 29 | | {:error, {:already_started, pid} | {:shutdown, term} | term} 30 | def start_link(name, ets_opts \\ [], opts \\ []) 31 | when is_opts(name, ets_opts, opts) do 32 | detect_clash(name, ets_opts, fn -> 33 | super_tab = :ets.new(name, [:public] ++ ets_opts) 34 | super_args = {super_tab, opts, self()} 35 | super_opts = [name: gen_name(opts, super_tab)] 36 | super_proc = Supervisor.start_link(__MODULE__, super_args, super_opts) 37 | 38 | with {:ok, pid} <- super_proc do 39 | {:ok, pid, super_tab} 40 | end 41 | end) 42 | end 43 | 44 | @doc false 45 | # Main initialization phase which takes a table and options as an argument and 46 | # sets up a child spec containing the two GenServers, passing through arguments 47 | # as necessary. We also ensure that the Logger application is started at this 48 | # point, just in case the user has been unable to start it for some reason. 49 | @spec init({table :: Table.t(), opts :: Keyword.t()}) :: {:ok, tuple} 50 | def init({table, opts, base}) do 51 | flags = Keyword.take(opts, [:monitor, :quiet]) 52 | 53 | Priv.noisy(flags, fn -> 54 | App.ensure_all_started(:logger) 55 | end) 56 | 57 | [{table, flags, base}] 58 | |> init_children 59 | |> init_supervisor 60 | end 61 | 62 | # Conditionally compile child specifications based on Elixir version. 63 | if Version.match?(System.version(), ">= 1.5.0") do 64 | # Creates a child spec using the >= v1.5 Elixir formatting and options. 65 | defp init_children(arguments), 66 | do: [ 67 | %{id: Server.One, start: {Eternal.Server, :start_link, arguments}}, 68 | %{id: Server.Two, start: {Eternal.Server, :start_link, arguments}} 69 | ] 70 | 71 | # Initializes a Supervisor using the >= v1.5 Elixir options. 72 | defp init_supervisor(children), 73 | do: Supervisor.init(children, strategy: :one_for_one) 74 | else 75 | # Creates a child spec using the < v1.5 Elixir formatting and options. 76 | defp init_children(arguments), 77 | do: [ 78 | worker(Eternal.Server, arguments, id: Server.One), 79 | worker(Eternal.Server, arguments, id: Server.Two) 80 | ] 81 | 82 | # Initializes a Supervisor using the < v1.5 Elixir options. 83 | defp init_supervisor(children), 84 | do: supervise(children, strategy: :one_for_one) 85 | end 86 | 87 | # Detects a potential name clash inside ETS. If we have a named table and the 88 | # table is already in use, we return a link to the existing Supervisor. This 89 | # means we can be transparent to any crashes caused by starting the same ETS 90 | # table twice. Otherwise, we execute the callback which will create the table. 91 | defp detect_clash(name, ets_opts, fun) do 92 | if exists?(name, ets_opts) do 93 | {:error, {:already_started, Process.whereis(name)}} 94 | else 95 | fun.() 96 | end 97 | end 98 | 99 | # Shorthand function to determine if an ETS table exists or not. We calculate 100 | # this by looking for the name inside the list of ETS tables, but only if the 101 | # options specify that we should name the ETS table. If it's not named, there 102 | # won't be a table clash when starting a new table, so we're safe to continue. 103 | defp exists?(name, ets_opts) do 104 | Enum.member?(ets_opts, :named_table) and 105 | Enum.member?(:ets.all(), name) 106 | end 107 | 108 | # Generates the name to use for the Supervisor. If the name is provided, we use 109 | # that, otherwise we generates a default table name from the table identifier. 110 | defp gen_name(opts, super_tab) do 111 | Keyword.get_lazy(opts, :name, fn -> 112 | Table.to_name(super_tab, true) 113 | end) 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/eternal/table.ex: -------------------------------------------------------------------------------- 1 | defmodule Eternal.Table do 2 | @moduledoc false 3 | # This module contains functions related to interactions with tables. At the moment 4 | # this just consists of guard expressions to determine valid tables, and the ability 5 | # to convert a table identifier to a valid Supervisor name. 6 | 7 | # define a table typespec 8 | @opaque t :: number | atom 9 | 10 | @doc """ 11 | Converts a table name to a valid Supervisor name. 12 | 13 | Because tables can be integer references, we convert this to an atom only if 14 | the `create` flag is set to true. Otherwise, we attempt to convert to an existing 15 | name (as it should have already been created). 16 | """ 17 | @spec to_name(name :: number | atom, create :: true | false) :: 18 | name :: atom | nil 19 | def to_name(name, create \\ false) 20 | 21 | def to_name(name, _create) when is_atom(name), 22 | do: name 23 | 24 | def to_name(name, true) when is_number(name) do 25 | name 26 | |> Kernel.to_string() 27 | |> String.to_atom() 28 | end 29 | 30 | def to_name(name, false) when is_number(name) do 31 | name 32 | |> Kernel.to_string() 33 | |> String.to_existing_atom() 34 | rescue 35 | _ -> nil 36 | end 37 | 38 | @doc """ 39 | Determines whether a value is a table or not. Tables can be either atoms or 40 | integer values. 41 | """ 42 | defmacro is_table(val) do 43 | quote do 44 | is_atom(unquote(val)) or 45 | is_integer(unquote(val)) 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Eternal.Mixfile do 2 | use Mix.Project 3 | 4 | @url_docs "http://hexdocs.pm/eternal" 5 | @url_github "https://github.com/whitfin/eternal" 6 | 7 | def project do 8 | [ 9 | app: :eternal, 10 | name: "Eternal", 11 | description: "Make your ETS tables live forever", 12 | package: %{ 13 | files: [ 14 | "lib", 15 | "mix.exs", 16 | "LICENSE", 17 | "README.md" 18 | ], 19 | licenses: ["MIT"], 20 | links: %{ 21 | "Docs" => @url_docs, 22 | "GitHub" => @url_github 23 | }, 24 | maintainers: ["Isaac Whitfield"] 25 | }, 26 | version: "1.2.2", 27 | elixir: "~> 1.2", 28 | deps: deps(), 29 | docs: [ 30 | extras: ["README.md"], 31 | source_ref: "master", 32 | source_url: @url_github 33 | ], 34 | test_coverage: [ 35 | tool: ExCoveralls 36 | ], 37 | preferred_cli_env: [ 38 | docs: :docs, 39 | coveralls: :cover, 40 | "coveralls.html": :cover, 41 | "coveralls.github": :cover 42 | ] 43 | ] 44 | end 45 | 46 | # Configuration for the OTP application 47 | # 48 | # Type "mix help compile.app" for more information 49 | def application do 50 | [extra_applications: [:logger]] 51 | end 52 | 53 | # Dependencies can be Hex packages: 54 | # 55 | # {:mydep, "~> 0.3.0"} 56 | # 57 | # Or git/path repositories: 58 | # 59 | # {:mydep, git: "https://github.com/elixir-lang/mydep.git", tag: "0.1.0"} 60 | # 61 | # Type "mix help deps" for more examples and options 62 | defp deps do 63 | [ 64 | {:ex_doc, "~> 0.31", optional: true, only: [:docs]}, 65 | {:excoveralls, "~> 0.18", optional: true, only: [:cover]} 66 | ] 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /test/eternal/priv_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Eternal.PrivTest do 2 | use ExUnit.Case 3 | 4 | alias Eternal.Priv 5 | 6 | import Priv 7 | 8 | test "is_opts/3 with valid arguments" do 9 | assert(detect(:test, [], []) == true) 10 | end 11 | 12 | test "is_opts/3 with invalid name" do 13 | refute(detect(12345, [], []) == true) 14 | end 15 | 16 | test "is_opts/3 with invaid ets_opts" do 17 | refute(detect(:test, 1, []) == true) 18 | end 19 | 20 | test "is_opts/3 with invalid opts" do 21 | refute(detect(:test, [], 1) == true) 22 | end 23 | 24 | defp detect(a, b, c) when is_opts(a, b, c), 25 | do: true 26 | 27 | defp detect(_a, _b, _c), 28 | do: false 29 | end 30 | -------------------------------------------------------------------------------- /test/eternal/table_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Eternal.TableTest do 2 | use ExUnit.Case 3 | 4 | alias Eternal.Table 5 | 6 | import Table 7 | 8 | test "converting an atom name" do 9 | assert(Table.to_name(:test) == :test) 10 | end 11 | 12 | test "converting an integer name" do 13 | assert(Table.to_name(12345, true) == :"12345") 14 | end 15 | 16 | test "converting a missing integer name" do 17 | assert(Table.to_name(54321) == nil) 18 | end 19 | 20 | test "is_table/1 with a table identifier" do 21 | assert(detect(12345) == true) 22 | end 23 | 24 | test "is_table/1 with a table name" do 25 | assert(detect(:test) == true) 26 | end 27 | 28 | test "is_table/1 with an invalid id" do 29 | refute(detect("test") == true) 30 | end 31 | 32 | defp detect(tab) when is_table(tab), 33 | do: true 34 | 35 | defp detect(_tab), 36 | do: false 37 | end 38 | -------------------------------------------------------------------------------- /test/eternal_test.exs: -------------------------------------------------------------------------------- 1 | defmodule EternalTest do 2 | use ExUnit.Case 3 | 4 | import ExUnit.CaptureLog 5 | 6 | test "starting a table successfully" do 7 | assert( 8 | match?( 9 | {:ok, _pid}, 10 | Eternal.start_link(:table_no_options, [], quiet: true) 11 | ) 12 | ) 13 | end 14 | 15 | test "starting a table with options" do 16 | assert( 17 | match?( 18 | {:ok, _pid}, 19 | Eternal.start_link(:table_with_options, [:compressed], quiet: true) 20 | ) 21 | ) 22 | 23 | assert(:ets.info(:table_with_options, :compressed) == true) 24 | end 25 | 26 | test "starting a table with no link" do 27 | spawn(fn -> 28 | Eternal.start(:unlinked, [], quiet: true) 29 | end) 30 | 31 | :timer.sleep(25) 32 | 33 | assert(:unlinked in :ets.all()) 34 | end 35 | 36 | test "recovering from a stopd owner" do 37 | tab = create(:recover_stopd_owner) 38 | 39 | owner = Eternal.owner(tab) 40 | heir = Eternal.heir(tab) 41 | 42 | GenServer.stop(owner) 43 | 44 | :timer.sleep(5) 45 | 46 | assert(is_pid(owner)) 47 | assert(owner != Eternal.owner(tab)) 48 | assert(is_pid(heir)) 49 | assert(heir != Eternal.heir(tab)) 50 | end 51 | 52 | test "recovering from a stopped heir" do 53 | tab = create(:recover_stopped_heir) 54 | 55 | owner = Eternal.owner(tab) 56 | heir = Eternal.heir(tab) 57 | 58 | GenServer.stop(heir) 59 | 60 | :timer.sleep(5) 61 | 62 | assert(owner == Eternal.owner(tab)) 63 | assert(is_pid(heir)) 64 | assert(heir != Eternal.heir(tab)) 65 | end 66 | 67 | test "terminating a table and eternal" do 68 | tab = create(:terminating_table, []) 69 | 70 | owner = Eternal.owner(tab) 71 | heir = Eternal.heir(tab) 72 | 73 | Eternal.stop(tab) 74 | 75 | :timer.sleep(5) 76 | 77 | refute(Process.alive?(owner)) 78 | refute(Process.alive?(heir)) 79 | 80 | assert_raise(ArgumentError, fn -> 81 | :ets.first(tab) 82 | end) 83 | end 84 | 85 | test "logging output when creating a table" do 86 | msg = 87 | capture_log(fn -> 88 | Eternal.start_link(:logging_output) 89 | :timer.sleep(25) 90 | Eternal.stop(:logging_output) 91 | end) 92 | 93 | assert( 94 | Regex.match?( 95 | ~r/\[debug\] \[eternal\] Table 'logging_output' gifted to #PID<\d\.\d+\.\d> via #PID<\d\.\d+\.\d>/, 96 | msg 97 | ) 98 | ) 99 | end 100 | 101 | test "starting a table twice finds the previous owner" do 102 | {:ok, pid} = Eternal.start_link(:existing_table, [], quiet: true) 103 | result2 = Eternal.start_link(:existing_table, [], quiet: true) 104 | assert(result2 == {:error, {:already_started, pid}}) 105 | end 106 | 107 | defp create(name, tab_opts \\ [], opts \\ []) do 108 | {:ok, _pid} = Eternal.start_link(name, tab_opts, opts ++ [quiet: true]) 109 | name 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------