diff --git a/.credo.exs b/.credo.exs index 6537d951..db0f4c3c 100644 --- a/.credo.exs +++ b/.credo.exs @@ -117,9 +117,9 @@ ## Refactoring Opportunities # {Credo.Check.Refactor.CondStatements, []}, - {Credo.Check.Refactor.CyclomaticComplexity, []}, + {Credo.Check.Refactor.CyclomaticComplexity, [max_complexity: 40]}, {Credo.Check.Refactor.FunctionArity, []}, - {Credo.Check.Refactor.LongQuoteBlocks, [max_line_count: 300, ignore_comments: true]}, + {Credo.Check.Refactor.LongQuoteBlocks, [max_line_count: 200]}, # {Credo.Check.Refactor.MapInto, []}, {Credo.Check.Refactor.MatchInCondition, []}, {Credo.Check.Refactor.NegatedConditionsInUnless, []}, diff --git a/.formatter.exs b/.formatter.exs index f8bb4242..9f1a7935 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,5 +1,30 @@ +locals_without_parens = [ + # Nebulex.Helpers + unwrap_or_raise: 1, + wrap_ok: 1, + wrap_error: 1, + wrap_error: 2, + + # Nebulex.Cache + dynamic_cache: 2, + + # Nebulex.Caching + cache_ref: 1, + cache_ref: 2, + + # Tests + deftests: 1, + deftests: 2, + setup_with_cache: 1, + setup_with_cache: 2, + setup_with_dynamic_cache: 2, + setup_with_dynamic_cache: 3 +] + [ import_deps: [:stream_data], inputs: ["{mix,.formatter}.exs", "{config,lib,test,benchmarks}/**/*.{ex,exs}"], - line_length: 100 + line_length: 100, + locals_without_parens: locals_without_parens, + export: [locals_without_parens: locals_without_parens] ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f58d9175..4ea070fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,24 +11,25 @@ on: jobs: nebulex_test: name: 'Nebulex Test (Elixir ${{ matrix.elixir }} OTP ${{ matrix.otp }})' - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: matrix: include: - elixir: 1.14.x otp: 25.x + os: 'ubuntu-latest' style: true coverage: true sobelow: true dialyzer: true - elixir: 1.13.x otp: 24.x + os: 'ubuntu-latest' - elixir: 1.11.x otp: 23.x + os: 'ubuntu-20.04' inch-report: true - - elixir: 1.9.x - otp: 22.x env: GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' @@ -101,7 +102,7 @@ jobs: id: plt-cache with: path: priv/plts - key: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-plt-v1 + key: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-plt-v3-1 if: ${{ matrix.dialyzer }} - name: Create PLTs diff --git a/.gitignore b/.gitignore index 076b29cc..20270cae 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,4 @@ erl_crash.dump /priv .sobelow* /config +Elixir* diff --git a/CHANGELOG.md b/CHANGELOG.md index 76f6defc..c93e0a0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,33 +4,6 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [v2.4.2](https://github.com/cabol/nebulex/tree/v2.4.2) (2022-11-04) - -[Full Changelog](https://github.com/cabol/nebulex/compare/v2.4.1...v2.4.2) - -**Closed issues:** - -- Adapter configuration per-env? - [#171](https://github.com/cabol/nebulex/issues/171) -- On-change handler for write-through decorators - [#165](https://github.com/cabol/nebulex/issues/165) -- Document test env setup with decorators? - [#155](https://github.com/cabol/nebulex/issues/155) -- Managing Failovers in the cluster - [#131](https://github.com/cabol/nebulex/issues/131) - -**Merged pull requests:** - -- Make Multilevel adapter apply deletes in reverse order - [#174](https://github.com/cabol/nebulex/pull/174) - ([martosaur](https://github.com/martosaur)) -- Use import Bitwise instead of use Bitwise - [#172](https://github.com/cabol/nebulex/pull/172) - ([ryvasquez](https://github.com/ryvasquez)) -- Fix result of getting value by non existent key - [#166](https://github.com/cabol/nebulex/pull/166) - ([fuelen](https://github.com/fuelen)) - ## [v2.4.1](https://github.com/cabol/nebulex/tree/v2.4.1) (2022-07-10) [Full Changelog](https://github.com/cabol/nebulex/compare/v2.4.0...v2.4.1) diff --git a/README.md b/README.md index c245ff0f..b8c2a8f4 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ for more information. [cachex]: https://github.com/whitfin/cachex [redis]: https://redis.io/ [memcached]: https://memcached.org/ -[nbx_caching]: http://hexdocs.pm/nebulex/Nebulex.Caching.html +[nbx_caching]: http://hexdocs.pm/nebulex/Nebulex.Caching.Decorators.html [cache_patterns]: http://hexdocs.pm/nebulex/cache-usage-patterns.html [cache_topologies]: https://docs.oracle.com/middleware/1221/coherence/develop-applications/cache_intro.htm @@ -47,8 +47,8 @@ Partitioned | [Nebulex.Adapters.Partitioned][pa] | Built-In Replicated | [Nebulex.Adapters.Replicated][ra] | Built-In Multilevel | [Nebulex.Adapters.Multilevel][ma] | Built-In Nil (special adapter that disables the cache) | [Nebulex.Adapters.Nil][nil] | Built-In -Cachex | Nebulex.Adapters.Cachex | [nebulex_adapters_cachex][nbx_cachex] Redis | NebulexRedisAdapter | [nebulex_redis_adapter][nbx_redis] +Cachex | Nebulex.Adapters.Cachex | [nebulex_adapters_cachex][nbx_cachex] Distributed with Horde | Nebulex.Adapters.Horde | [nebulex_adapters_horde][nbx_horde] [la]: http://hexdocs.pm/nebulex/Nebulex.Adapters.Local.html @@ -56,11 +56,10 @@ Distributed with Horde | Nebulex.Adapters.Horde | [nebulex_adapters_horde][nbx_h [ra]: http://hexdocs.pm/nebulex/Nebulex.Adapters.Replicated.html [ma]: http://hexdocs.pm/nebulex/Nebulex.Adapters.Multilevel.html [nil]: http://hexdocs.pm/nebulex/Nebulex.Adapters.Nil.html -[nbx_cachex]: https://github.com/cabol/nebulex_adapters_cachex [nbx_redis]: https://github.com/cabol/nebulex_redis_adapter +[nbx_cachex]: https://github.com/cabol/nebulex_adapters_cachex [nbx_horde]: https://github.com/eliasdarruda/nebulex_adapters_horde - For example, if you want to use a built-in cache, add to your `mix.exs` file: ```elixir diff --git a/benchmarks/bench_helper.exs b/benchmarks/bench_helper.exs index 18c816bd..483f3ec0 100644 --- a/benchmarks/bench_helper.exs +++ b/benchmarks/bench_helper.exs @@ -30,8 +30,8 @@ defmodule BenchHelper do "take" => fn input -> cache.take(input) end, - "has_key?" => fn input -> - cache.has_key?(input) + "exists?" => fn input -> + cache.exists?(input) end, "count_all" => fn _input -> cache.count_all() diff --git a/coveralls.json b/coveralls.json index a9713911..f56f12f2 100644 --- a/coveralls.json +++ b/coveralls.json @@ -4,6 +4,7 @@ }, "skip_files": [ + "lib/nebulex/cache/options.ex", "test/support/*", "test/dialyzer/*" ] diff --git a/guides/cache-usage-patterns.md b/guides/cache-usage-patterns.md index ba730f5e..a7e19b49 100644 --- a/guides/cache-usage-patterns.md +++ b/guides/cache-usage-patterns.md @@ -1,9 +1,10 @@ -# Cache Usage Patterns via Nebulex.Caching +# Cache Usage Patterns via Nebulex.Caching.Decorators There are several common access patterns when using a cache. **Nebulex** -supports most of these patterns by means of [Nebulex.Caching][nbx_caching]. +supports most of these patterns by means of +[Nebulex.Caching.Decorators][nbx_caching]. -[nbx_caching]: http://hexdocs.pm/nebulex/Nebulex.Caching.html +[nbx_caching]: http://hexdocs.pm/nebulex/Nebulex.Caching.Decorators.html > Most of the following documentation about caching patterns it based on [EHCache Docs][EHCache] diff --git a/guides/creating-new-adapter.md b/guides/creating-new-adapter.md index 4b4d8ffb..74407f1f 100644 --- a/guides/creating-new-adapter.md +++ b/guides/creating-new-adapter.md @@ -231,10 +231,10 @@ mix test 54) test put_all/2 puts the given entries using different data types at once (NebulexMemoryAdapterTest) test/nebulex_memory_adapter_test.exs:128 ** (UndefinedFunctionError) function NebulexMemoryAdapter.TestCache.delete_all/0 is undefined or private. Did you mean: - + * delete/1 * delete/2 - + stacktrace: (nebulex_memory_adapter 0.1.0) NebulexMemoryAdapter.TestCache.delete_all() test/nebulex_memory_adapter_test.exs:9: NebulexMemoryAdapterTest.__ex_unit_setup_0/1 @@ -256,21 +256,25 @@ defmodule NebulexMemoryAdapter do @behaviour Nebulex.Adapter @behaviour Nebulex.Adapter.Queryable + import Nebulex.Helpers + @impl Nebulex.Adapter defmacro __before_compile__(_env), do: :ok @impl Nebulex.Adapter def init(_opts) do child_spec = Supervisor.child_spec({Agent, fn -> %{} end}, id: {Agent, 1}) + {:ok, child_spec, %{}} end @impl Nebulex.Adapter.Queryable def execute(adapter_meta, :delete_all, query, opts) do deleted = Agent.get(adapter_meta.pid, &map_size/1) + Agent.update(adapter_meta.pid, fn _state -> %{} end) - deleted + wrap_ok deleted end end ``` @@ -304,23 +308,26 @@ defmodule NebulexMemoryAdapter do @behaviour Nebulex.Adapter.Entry @behaviour Nebulex.Adapter.Queryable + import Nebulex.Helpers + @impl Nebulex.Adapter defmacro __before_compile__(_env), do: :ok @impl Nebulex.Adapter def init(_opts) do child_spec = Supervisor.child_spec({Agent, fn -> %{} end}, id: {Agent, 1}) + {:ok, child_spec, %{}} end @impl Nebulex.Adapter.Entry - def get(adapter_meta, key, _opts) do - Agent.get(adapter_meta.pid, &Map.get(&1, key)) + def fetch(adapter_meta, key, _opts) do + wrap_ok Agent.get(adapter_meta.pid, &Map.get(&1, key)) end @impl Nebulex.Adapter.Entry def get_all(adapter_meta, keys, _opts) do - Agent.get(adapter_meta.pid, &Map.take(&1, keys)) + wrap_ok Agent.get(adapter_meta.pid, &Map.take(&1, keys)) end @impl Nebulex.Adapter.Entry @@ -331,48 +338,58 @@ defmodule NebulexMemoryAdapter do put(adapter_meta, key, value, ttl, :put, opts) true end + |> wrap_ok() end def put(adapter_meta, key, value, ttl, :replace, opts) do if get(adapter_meta, key, []) do put(adapter_meta, key, value, ttl, :put, opts) + true else false end + |> wrap_ok() end def put(adapter_meta, key, value, _ttl, _on_write, _opts) do Agent.update(adapter_meta.pid, &Map.put(&1, key, value)) - true + + wrap_ok true end @impl Nebulex.Adapter.Entry def put_all(adapter_meta, entries, ttl, :put_new, opts) do if get_all(adapter_meta, Map.keys(entries), []) == %{} do put_all(adapter_meta, entries, ttl, :put, opts) + true else false end + |> wrap_ok() end def put_all(adapter_meta, entries, _ttl, _on_write, _opts) do entries = Map.new(entries) + Agent.update(adapter_meta.pid, &Map.merge(&1, entries)) - true + + wrap_ok true end @impl Nebulex.Adapter.Entry def delete(adapter_meta, key, _opts) do - Agent.update(adapter_meta.pid, &Map.delete(&1, key)) + wrap_ok Agent.update(adapter_meta.pid, &Map.delete(&1, key)) end @impl Nebulex.Adapter.Entry def take(adapter_meta, key, _opts) do value = get(adapter_meta, key, []) + delete(adapter_meta, key, []) - value + + wrap_ok value end @impl Nebulex.Adapter.Entry @@ -381,48 +398,51 @@ defmodule NebulexMemoryAdapter do Map.update(state, key, default + amount, fn v -> v + amount end) end) - get(adapter_meta, key, []) + wrap_ok get(adapter_meta, key, []) end @impl Nebulex.Adapter.Entry - def has_key?(adapter_meta, key) do - Agent.get(adapter_meta.pid, &Map.has_key?(&1, key)) + def exists?(adapter_meta, key, _opts) do + wrap_ok Agent.get(adapter_meta.pid, &Map.has_key?(&1, key)) end @impl Nebulex.Adapter.Entry - def ttl(_adapter_meta, _key) do - nil + def ttl(_adapter_meta, _key, _opts) do + wrap_ok nil end @impl Nebulex.Adapter.Entry - def expire(_adapter_meta, _key, _ttl) do - true + def expire(_adapter_meta, _key, _ttl, _opts) do + wrap_ok true end @impl Nebulex.Adapter.Entry - def touch(_adapter_meta, _key) do - true + def touch(_adapter_meta, _key, _opts) do + wrap_ok true end @impl Nebulex.Adapter.Queryable def execute(adapter_meta, :delete_all, _query, _opts) do deleted = execute(adapter_meta, :count_all, nil, []) + Agent.update(adapter_meta.pid, fn _state -> %{} end) - deleted + wrap_ok deleted end def execute(adapter_meta, :count_all, _query, _opts) do - Agent.get(adapter_meta.pid, &map_size/1) + wrap_ok Agent.get(adapter_meta.pid, &map_size/1) end def execute(adapter_meta, :all, _query, _opts) do - Agent.get(adapter_meta.pid, &Map.values/1) + wrap_ok Agent.get(adapter_meta.pid, &Map.values/1) end @impl Nebulex.Adapter.Queryable def stream(_adapter_meta, :invalid_query, _opts) do raise Nebulex.QueryError, message: "foo", query: :invalid_query + + wrap_error Nebulex.QueryError, message: "foo", query: :invalid_query end def stream(adapter_meta, _query, opts) do @@ -438,7 +458,7 @@ defmodule NebulexMemoryAdapter do &Map.keys/1 end - Agent.get(adapter_meta.pid, fun) + wrap_ok Agent.get(adapter_meta.pid, fun) end end ``` diff --git a/guides/getting-started.md b/guides/getting-started.md index b3e3455c..1329cc9c 100644 --- a/guides/getting-started.md +++ b/guides/getting-started.md @@ -55,7 +55,7 @@ makes all its dependencies as optional. For example: * If you want to use an external adapter (e.g: Cachex or Redis adapter), you have to add the adapter dependency too. -[nbx_caching]: http://hexdocs.pm/nebulex/Nebulex.Caching.html +[nbx_caching]: http://hexdocs.pm/nebulex/Nebulex.Caching.Decorators.html [telemetry]: http://hexdocs.pm/nebulex/telemetry.html To install these dependencies, we will run this command: @@ -257,13 +257,13 @@ iex> for key <- 1..3 do ["Galileo", "Charles", "Albert"] ``` -There is a function `has_key?` to check if a key exist in cache: +There is a function `exists?` to check if a key exist in cache: ```elixir -iex> Blog.Cache.has_key?(1) +iex> Blog.Cache.exists?(1) true -iex> Blog.Cache.has_key?(10) +iex> Blog.Cache.exists?(10) false ``` @@ -672,5 +672,5 @@ To learn more about how multilevel-cache works, please check ## Next - * [Cache Usage Patterns via Nebulex.Caching](http://hexdocs.pm/nebulex/cache-usage-patterns.html) - - Annotations-based DSL to implement different cache usage patterns. + * [Cache Usage Patterns via Nebulex.Caching.Decorators](http://hexdocs.pm/nebulex/cache-usage-patterns.html) + - Annotations-based DSL to implement different cache usage patterns. diff --git a/lib/nebulex.ex b/lib/nebulex.ex index 540e5156..fd577686 100644 --- a/lib/nebulex.ex +++ b/lib/nebulex.ex @@ -63,6 +63,6 @@ defmodule Nebulex do ## Declarative annotation-based caching - See [Nebulex.Caching](http://hexdocs.pm/nebulex/Nebulex.Caching.html). + See [Nebulex.Caching.Decorators](http://hexdocs.pm/nebulex/Nebulex.Caching.Decorators.html). """ end diff --git a/lib/nebulex/adapter.ex b/lib/nebulex/adapter.ex index 8be20bc4..9cf8a755 100644 --- a/lib/nebulex/adapter.ex +++ b/lib/nebulex/adapter.ex @@ -14,15 +14,18 @@ defmodule Nebulex.Adapter do @typedoc """ The metadata returned by the adapter `c:init/1`. - It must be a map and Nebulex itself will always inject two keys into - the meta: + It must be a map and Nebulex itself will always inject + the following keys into the meta: * `:cache` - The cache module. - * `:pid` - The PID returned by the child spec returned in `c:init/1` + * `:pid` - The PID returned by the child spec returned in `c:init/1`. + * `:adapter` - The defined cache adapter. """ @type adapter_meta :: metadata + ## Callbacks + @doc """ The callback invoked in case the adapter needs to inject code. """ @@ -31,7 +34,28 @@ defmodule Nebulex.Adapter do @doc """ Initializes the adapter supervision tree by returning the children. """ - @callback init(config :: Keyword.t()) :: {:ok, :supervisor.child_spec(), adapter_meta} + @callback init(config :: keyword) :: {:ok, :supervisor.child_spec(), adapter_meta} + + # Define optional callbacks + @optional_callbacks __before_compile__: 1 + + ## API + + # Inline common instructions + @compile {:inline, lookup_meta: 1} + + @doc """ + Returns the adapter metadata from its `c:init/1` callback. + + It expects a process name of the cache. The name is either + an atom or a PID. For a given cache, you often want to call + this function based on the dynamic cache: + + Nebulex.Adapter.lookup_meta(cache.get_dynamic_cache()) + + """ + @spec lookup_meta(atom | pid) :: {:ok, adapter_meta} | {:error, Nebulex.Error.t()} + defdelegate lookup_meta(name_or_pid), to: Nebulex.Cache.Registry, as: :lookup @doc """ Executes the function `fun` passing as parameters the adapter and metadata @@ -39,10 +63,11 @@ defmodule Nebulex.Adapter do It expects a name or a PID representing the cache. """ - @spec with_meta(atom | pid, (module, adapter_meta -> term)) :: term + @spec with_meta(atom | pid, (adapter_meta -> term)) :: term | {:error, Nebulex.Error.t()} def with_meta(name_or_pid, fun) do - {adapter, adapter_meta} = Nebulex.Cache.Registry.lookup(name_or_pid) - fun.(adapter, adapter_meta) + with {:ok, adapter_meta} <- lookup_meta(name_or_pid) do + fun.(adapter_meta) + end end # FIXME: ExCoveralls does not mark most of this section as covered diff --git a/lib/nebulex/adapter/entry.ex b/lib/nebulex/adapter/entry.ex index ccf6efff..3c1344f4 100644 --- a/lib/nebulex/adapter/entry.ex +++ b/lib/nebulex/adapter/entry.ex @@ -24,36 +24,48 @@ defmodule Nebulex.Adapter.Entry do @typedoc "TTL for a cache entry" @type ttl :: timeout - @typedoc "Write command" + @typedoc "Write command type" @type on_write :: :put | :put_new | :replace @doc """ - Gets the value for a specific `key` in `cache`. + Fetches the value for a specific `key` in the cache. - See `c:Nebulex.Cache.get/2`. + If the cache contains the given `key`, then its value is returned + in the shape of `{:ok, value}`. + + If the cache does not contain `key`, `{:error, Nebulex.KeyError.t()}` + is returned. + + Returns `{:error, Nebulex.Error.t()}` if any other error occurs while + executing the command. + + See `c:Nebulex.Cache.fetch/2`. """ - @callback get(adapter_meta, key, opts) :: value + @callback fetch(adapter_meta, key, opts) :: + Nebulex.Cache.ok_error_tuple(value, Nebulex.Cache.fetch_error_reason()) @doc """ - Gets a collection of entries from the Cache, returning them as `Map.t()` of - the values associated with the set of keys requested. + Returns a map in the shape of `{:ok, map}` with the key-value pairs of all + specified `keys`. For every key that does not hold a value or does not exist, + it is ignored and not added into the returned map. - For every key that does not hold a value or does not exist, that key is - simply ignored. Because of this, the operation never fails. + Returns `{:error, reason}` if an error occurs while executing the command. See `c:Nebulex.Cache.get_all/2`. """ - @callback get_all(adapter_meta, [key], opts) :: map + @callback get_all(adapter_meta, [key], opts) :: Nebulex.Cache.ok_error_tuple(map) @doc """ Puts the given `value` under `key` into the `cache`. - Returns `true` if the `value` with key `key` is successfully inserted; - otherwise `false` is returned. - The `ttl` argument sets the time-to-live for the stored entry. If it is not set, it means the entry hasn't a time-to-live, then it shouldn't expire. + Returns `{:ok, true}` if the `value` with key `key` is successfully inserted, + otherwise, `{:ok, false}` is returned. + + Returns `{:error, reason}` if an error occurs. + ## OnWrite The `on_write` argument supports the following values: @@ -63,27 +75,30 @@ defmodule Nebulex.Adapter.Entry do operation. * `:put_new` - It only stores the entry if the `key` does not already exist, - otherwise, `false` is returned. + otherwise, `{:ok, false}` is returned. * `:replace` - Alters the value stored under the given `key`, but only - if the key already exists into the cache, otherwise, `false` is + if the key already exists into the cache, otherwise, `{ok, false}` is returned. See `c:Nebulex.Cache.put/3`, `c:Nebulex.Cache.put_new/3`, `c:Nebulex.Cache.replace/3`. """ - @callback put(adapter_meta, key, value, ttl, on_write, opts) :: boolean + @callback put(adapter_meta, key, value, ttl, on_write, opts) :: + Nebulex.Cache.ok_error_tuple(boolean) @doc """ Puts the given `entries` (key/value pairs) into the `cache`. - Returns `true` if all the keys were inserted. If no key was inserted - (at least one key already existed), `false` is returned. - The `ttl` argument sets the time-to-live for the stored entry. If it is not set, it means the entry hasn't a time-to-live, then it shouldn't expire. The given `ttl` is applied to all keys. + Returns `{:ok, true}` if all the keys were inserted. If no key was inserted + (at least one key already existed), `{:ok, false}` is returned. + + Returns `{:error, reason}` if an error occurs. + ## OnWrite The `on_write` argument supports the following values: @@ -93,7 +108,7 @@ defmodule Nebulex.Adapter.Entry do operation. * `:put_new` - It only stores the entry if the `key` does not already exist, - otherwise, `false` is returned. + otherwise, `{:ok, false}` is returned. Ideally, this operation should be atomic, so all given keys are set at once. But it depends purely on the adapter's implementation and the backend used @@ -102,21 +117,34 @@ defmodule Nebulex.Adapter.Entry do See `c:Nebulex.Cache.put_all/2`. """ - @callback put_all(adapter_meta, entries, ttl, on_write, opts) :: boolean + @callback put_all(adapter_meta, entries, ttl, on_write, opts) :: + Nebulex.Cache.ok_error_tuple(boolean) @doc """ Deletes a single entry from cache. + Returns `{:error, reason}` if an error occurs. + See `c:Nebulex.Cache.delete/2`. """ - @callback delete(adapter_meta, key, opts) :: :ok + @callback delete(adapter_meta, key, opts) :: :ok | Nebulex.Cache.error() @doc """ - Returns and removes the entry with key `key` in the cache. + Removes and returns the value associated with `key` in the cache. + + If `key` is present in the cache, its value is removed and then returned + in the shape of `{:ok, value}`. + + If `key` is not present in the cache, `{:error, Nebulex.KeyError.t()}` + is returned. + + Returns `{:error, Nebulex.Error.t()}` if any other error occurs while + executing the command. See `c:Nebulex.Cache.take/2`. """ - @callback take(adapter_meta, key, opts) :: value + @callback take(adapter_meta, key, opts) :: + Nebulex.Cache.ok_error_tuple(value, Nebulex.Cache.fetch_error_reason()) @doc """ Updates the counter mapped to the given `key`. @@ -125,41 +153,61 @@ defmodule Nebulex.Adapter.Entry do If `amount` < 0, the counter is decremented by the given `amount`. If `amount` == 0, the counter is not updated. + Returns `{:error, reason}` if an error occurs. + See `c:Nebulex.Cache.incr/3`. See `c:Nebulex.Cache.decr/3`. """ @callback update_counter(adapter_meta, key, amount, ttl, default, opts) :: - integer + Nebulex.Cache.ok_error_tuple(integer) when amount: integer, default: integer @doc """ - Returns whether the given `key` exists in cache. + Determines if the cache contains an entry for the specified `key`. + + More formally, returns `{:ok, true}` if the cache contains the given `key`. + If the cache doesn't contain `key`, `{:ok, :false}` is returned. + + Returns `{:error, reason}` if an error occurs. - See `c:Nebulex.Cache.has_key?/1`. + See `c:Nebulex.Cache.exists?/2`. """ - @callback has_key?(adapter_meta, key) :: boolean + @callback exists?(adapter_meta, key, opts) :: Nebulex.Cache.ok_error_tuple(boolean) @doc """ - Returns the TTL (time-to-live) for the given `key`. If the `key` does not - exist, then `nil` is returned. + Returns the remaining time-to-live for the given `key`. - See `c:Nebulex.Cache.ttl/1`. + If `key` is present in the cache, then its remaining TTL is returned + in the shape of `{:ok, ttl}`. + + If `key` is not present in the cache, `{:error, Nebulex.KeyError.t()}` + is returned. + + Returns `{:error, Nebulex.Error.t()}` if any other error occurs while + executing the command. + + See `c:Nebulex.Cache.ttl/2`. """ - @callback ttl(adapter_meta, key) :: ttl | nil + @callback ttl(adapter_meta, key, opts) :: + Nebulex.Cache.ok_error_tuple(value, Nebulex.Cache.fetch_error_reason()) @doc """ - Returns `true` if the given `key` exists and the new `ttl` was successfully - updated, otherwise, `false` is returned. + Returns `{:ok, true}` if the given `key` exists and the new `ttl` was + successfully updated, otherwise, `{:ok, false}` is returned. - See `c:Nebulex.Cache.expire/2`. + Returns `{:error, reason}` if an error occurs. + + See `c:Nebulex.Cache.expire/3`. """ - @callback expire(adapter_meta, key, ttl) :: boolean + @callback expire(adapter_meta, key, ttl, opts) :: Nebulex.Cache.ok_error_tuple(boolean) @doc """ - Returns `true` if the given `key` exists and the last access time was - successfully updated, otherwise, `false` is returned. + Returns `{:ok, true}` if the given `key` exists and the last access time was + successfully updated, otherwise, `{:ok, false}` is returned. + + Returns `{:error, reason}` if an error occurs. - See `c:Nebulex.Cache.touch/1`. + See `c:Nebulex.Cache.touch/2`. """ - @callback touch(adapter_meta, key) :: boolean + @callback touch(adapter_meta, key, opts) :: Nebulex.Cache.ok_error_tuple(boolean) end diff --git a/lib/nebulex/adapter/persistence.ex b/lib/nebulex/adapter/persistence.ex index c2f9f5fa..5842453b 100644 --- a/lib/nebulex/adapter/persistence.ex +++ b/lib/nebulex/adapter/persistence.ex @@ -32,7 +32,7 @@ defmodule Nebulex.Adapter.Persistence do See `c:Nebulex.Cache.dump/2`. """ @callback dump(Nebulex.Adapter.adapter_meta(), Path.t(), Nebulex.Cache.opts()) :: - :ok | {:error, term} + :ok | Nebulex.Cache.error() @doc """ Loads a dumped cache from the given `path`. @@ -42,7 +42,7 @@ defmodule Nebulex.Adapter.Persistence do See `c:Nebulex.Cache.load/2`. """ @callback load(Nebulex.Adapter.adapter_meta(), Path.t(), Nebulex.Cache.opts()) :: - :ok | {:error, term} + :ok | Nebulex.Cache.error() alias Nebulex.Entry @@ -51,46 +51,59 @@ defmodule Nebulex.Adapter.Persistence do quote do @behaviour Nebulex.Adapter.Persistence - # sobelow_skip ["Traversal.FileModule"] + import Nebulex.Helpers + @impl true def dump(%{cache: cache}, path, opts) do - path - |> File.open([:read, :write], fn io_dev -> - nil - |> cache.stream(return: :entry) - |> Stream.filter(&(not Entry.expired?(&1))) - |> Stream.map(&{&1.key, &1.value}) - |> Stream.chunk_every(Keyword.get(opts, :entries_per_line, 10)) - |> Enum.each(fn entries -> - bin = Entry.encode(entries, get_compression(opts)) - :ok = IO.puts(io_dev, bin) - end) + with_file(path, [:read, :write], fn io_dev -> + with {:ok, stream} <- cache.stream(nil, return: :entry) do + stream + |> Stream.filter(&(not Entry.expired?(&1))) + |> Stream.map(&{&1.key, &1.value}) + |> Stream.chunk_every(Keyword.get(opts, :entries_per_line, 10)) + |> Enum.each(fn entries -> + bin = Entry.encode(entries, get_compression(opts)) + + :ok = IO.puts(io_dev, bin) + end) + end end) - |> handle_response() end - # sobelow_skip ["Traversal.FileModule"] @impl true def load(%{cache: cache}, path, opts) do - path - |> File.open([:read], fn io_dev -> + with_file(path, [:read], fn io_dev -> io_dev |> IO.stream(:line) |> Stream.map(&String.trim/1) |> Enum.each(fn line -> entries = Entry.decode(line, [:safe]) + cache.put_all(entries, opts) end) end) - |> handle_response() end defoverridable dump: 3, load: 3 ## Helpers - defp handle_response({:ok, _}), do: :ok - defp handle_response({:error, _} = error), do: error + # sobelow_skip ["Traversal.FileModule"] + defp with_file(path, modes, function) do + case File.open(path, modes) do + {:ok, io_device} -> + try do + function.(io_device) + after + :ok = File.close(io_device) + end + + {:error, reason} -> + reason = %File.Error{reason: reason, action: "open", path: path} + + wrap_error Nebulex.Error, reason: reason + end + end defp get_compression(opts) do case Keyword.get(opts, :compression) do diff --git a/lib/nebulex/adapter/queryable.ex b/lib/nebulex/adapter/queryable.ex index d4a7c530..16ca981b 100644 --- a/lib/nebulex/adapter/queryable.ex +++ b/lib/nebulex/adapter/queryable.ex @@ -31,10 +31,14 @@ defmodule Nebulex.Adapter.Queryable do @doc """ Executes the `query` according to the given `operation`. - Raises `Nebulex.QueryError` if query is invalid. + This callback returns: - In the the adapter does not support the given `operation`, an `ArgumentError` - exception should be raised. + * `{:ok, query_result}` - the query is valid, then it is executed + and the result returned. + + * `{:error, Nebulex.QueryError.t()}` - the query validation failed. + + * `{:error, reason}` - an error occurred while executing the command. ## Operations @@ -53,14 +57,26 @@ defmodule Nebulex.Adapter.Queryable do operation :: :all | :count_all | :delete_all, query :: term, opts - ) :: [term] | integer + ) :: + Nebulex.Cache.ok_error_tuple( + [term] | non_neg_integer, + Nebulex.Cache.query_error_reason() + ) @doc """ Streams the given `query`. - Raises `Nebulex.QueryError` if query is invalid. + This callback returns: + + * `{:ok, stream}` - the query is valid, then the stream is built + and returned. + + * `{:error, Nebulex.QueryError.t()}` - the query validation failed. + + * `{:error, reason}` - an error occurred while executing the command. See `c:Nebulex.Cache.stream/2`. """ - @callback stream(adapter_meta, query :: term, opts) :: Enumerable.t() + @callback stream(adapter_meta, query :: term, opts) :: + Nebulex.Cache.ok_error_tuple(Enumerable.t(), Nebulex.Cache.query_error_reason()) end diff --git a/lib/nebulex/adapter/stats.ex b/lib/nebulex/adapter/stats.ex index 0cc983c8..59422461 100644 --- a/lib/nebulex/adapter/stats.ex +++ b/lib/nebulex/adapter/stats.ex @@ -15,26 +15,36 @@ defmodule Nebulex.Adapter.Stats do """ @doc """ - Returns `Nebulex.Stats.t()` with the current stats values. + Returns current stats values. - If the stats are disabled for the cache, then `nil` is returned. + This function returns: + + * `{:ok, Nebulex.Stats.t()}` - stats are enabled and available + for the cache. + + * `{:ok, nil}` - the stats are disabled for the cache. + + * `{:error, reason}` - an error occurred while executing the command. The adapter may also include additional custom measurements, as well as metadata. - See `c:Nebulex.Cache.stats/0`. + See `c:Nebulex.Cache.stats/1`. """ - @callback stats(Nebulex.Adapter.adapter_meta()) :: Nebulex.Stats.t() | nil + @callback stats(Nebulex.Adapter.adapter_meta()) :: + Nebulex.Cache.ok_error_tuple(Nebulex.Stats.t() | nil) @doc false defmacro __using__(_opts) do quote do @behaviour Nebulex.Adapter.Stats + import Nebulex.Helpers + @impl true def stats(adapter_meta) do if counter_ref = adapter_meta[:stats_counter] do - %Nebulex.Stats{ + stats = %Nebulex.Stats{ measurements: %{ hits: :counters.get(counter_ref, 1), misses: :counters.get(counter_ref, 2), @@ -47,6 +57,11 @@ defmodule Nebulex.Adapter.Stats do cache: adapter_meta[:name] || adapter_meta[:cache] } } + + {:ok, stats} + else + wrap_error Nebulex.Error, + reason: {:stats_error, adapter_meta[:name] || adapter_meta[:cache]} end end @@ -54,8 +69,6 @@ defmodule Nebulex.Adapter.Stats do end end - import Nebulex.Helpers - @doc """ Initializes the Erlang's counter to be used by the adapter. See the module documentation for more information about the stats default implementation. @@ -73,9 +86,9 @@ defmodule Nebulex.Adapter.Stats do See adapters documentation for more information about stats implementation. """ - @spec init(Keyword.t()) :: :counters.counters_ref() | nil + @spec init(keyword) :: :counters.counters_ref() | nil def init(opts) do - case get_boolean_option(opts, :stats, false) do + case Keyword.get(opts, :stats, false) do true -> :counters.new(6, [:write_concurrency]) false -> nil end diff --git a/lib/nebulex/adapter/transaction.ex b/lib/nebulex/adapter/transaction.ex index f17fd9e6..ef2b7557 100644 --- a/lib/nebulex/adapter/transaction.ex +++ b/lib/nebulex/adapter/transaction.ex @@ -52,28 +52,43 @@ defmodule Nebulex.Adapter.Transaction do @doc """ Runs the given function inside a transaction. - A successful transaction returns the value returned by the function. + A successful transaction returns the value returned by the function wrapped + in a tuple as `{:ok, value}`. + + In case the transaction cannot be executed, then `{:error, reason}` is + returned. + + If an unhandled error/exception occurs, the error will bubble up from the + transaction function. + + If `transaction/2` is called inside another transaction, the function is + simply executed without wrapping the new transaction call in any way. See `c:Nebulex.Cache.transaction/2`. """ @callback transaction(Nebulex.Adapter.adapter_meta(), Nebulex.Cache.opts(), fun) :: any @doc """ - Returns `true` if the given process is inside a transaction. + Returns `{:ok, true}` if the current process is inside a transaction, + otherwise, `{:ok, false}` is returned. - See `c:Nebulex.Cache.in_transaction?/0`. + Returns `{:error, reason}` if an error occurs. + + See `c:Nebulex.Cache.in_transaction?/1`. """ - @callback in_transaction?(Nebulex.Adapter.adapter_meta()) :: boolean + @callback in_transaction?(Nebulex.Adapter.adapter_meta()) :: Nebulex.Cache.ok_error_tuple(boolean) @doc false defmacro __using__(_opts) do quote do @behaviour Nebulex.Adapter.Transaction + import Nebulex.Helpers + @impl true def transaction(%{cache: cache, pid: pid} = adapter_meta, opts, fun) do adapter_meta - |> in_transaction?() + |> do_in_transaction?() |> do_transaction( pid, adapter_meta[:name] || cache, @@ -85,16 +100,20 @@ defmodule Nebulex.Adapter.Transaction do end @impl true - def in_transaction?(%{pid: pid}) do - !!Process.get({pid, self()}) + def in_transaction?(adapter_meta) do + wrap_ok do_in_transaction?(adapter_meta) end defoverridable transaction: 3, in_transaction?: 1 ## Helpers + defp do_in_transaction?(%{pid: pid}) do + !!Process.get({pid, self()}) + end + defp do_transaction(true, _pid, _name, _keys, _nodes, _retries, fun) do - fun.() + {:ok, fun.()} end defp do_transaction(false, pid, name, keys, nodes, retries, fun) do @@ -105,7 +124,7 @@ defmodule Nebulex.Adapter.Transaction do try do _ = Process.put({pid, self()}, %{keys: keys, nodes: nodes}) - fun.() + {:ok, fun.()} after _ = Process.delete({pid, self()}) @@ -113,7 +132,7 @@ defmodule Nebulex.Adapter.Transaction do end false -> - raise "transaction aborted" + wrap_error Nebulex.Error, reason: {:transaction_aborted, name, nodes} end end diff --git a/lib/nebulex/adapters/local.ex b/lib/nebulex/adapters/local.ex index 64c7fd7f..c9a44fe3 100644 --- a/lib/nebulex/adapters/local.ex +++ b/lib/nebulex/adapters/local.ex @@ -45,9 +45,9 @@ defmodule Nebulex.Adapters.Local do internally, this option is used when a new table is created; see `:ets.new/2`. Defaults to `true`. - * `:compressed` - (boolean) This option is used when a new ETS table is - created and it defines whether or not it includes X as an option; see - `:ets.new/2`. Defaults to `false`. + * `:compressed` - (boolean) Since this adapter uses ETS tables internally, + this option is used when a new table is created; see `:ets.new/2`. + Defaults to `false`. * `:backend_type` - This option defines the type of ETS to be used (Defaults to `:set`). However, it is highly recommended to keep the @@ -334,14 +334,11 @@ defmodule Nebulex.Adapters.Local do key: nil, value: nil, touched: nil, - ttl: nil + exp: nil ) - # Supported Backends - @backends ~w(ets shards)a - # Inline common instructions - @compile {:inline, list_gen: 1, newer_gen: 1, test_ms: 0} + @compile {:inline, list_gen: 1, newer_gen: 1, fetch_entry: 3, pop_entry: 3} ## Nebulex.Adapter @@ -380,6 +377,9 @@ defmodule Nebulex.Adapters.Local do @impl true def init(opts) do + # Validate options + opts = __MODULE__.Options.validate!(opts) + # Required options cache = Keyword.fetch!(opts, :cache) telemetry = Keyword.fetch!(opts, :telemetry) @@ -392,37 +392,21 @@ defmodule Nebulex.Adapters.Local do stats_counter = Stats.init(opts) # Resolve the backend to be used - backend = - opts - |> Keyword.get(:backend, :ets) - |> case do - val when val in @backends -> - val - - val -> - raise "expected backend: option to be one of the supported backends " <> - "#{inspect(@backends)}, got: #{inspect(val)}" - end + backend = Keyword.fetch!(opts, :backend) # Internal option for max nested match specs based on number of keys - purge_batch_size = - get_option( - opts, - :purge_batch_size, - "an integer > 0", - &(is_integer(&1) and &1 > 0), - 100 - ) + purge_chunk_size = Keyword.fetch!(opts, :purge_chunk_size) # Build adapter metadata adapter_meta = %{ cache: cache, + name: opts[:name] || cache, telemetry: telemetry, telemetry_prefix: telemetry_prefix, meta_tab: meta_tab, stats_counter: stats_counter, backend: backend, - purge_batch_size: purge_batch_size, + purge_chunk_size: purge_chunk_size, started_at: DateTime.utc_now() } @@ -435,61 +419,45 @@ defmodule Nebulex.Adapters.Local do ## Nebulex.Adapter.Entry @impl true - def get(adapter_meta, key, _opts) do - adapter_meta - |> get_(key) - |> handle_expired() - end - - defspan get_(adapter_meta, key), as: :get do + defspan fetch(adapter_meta, key, _opts) do adapter_meta.meta_tab |> list_gen() - |> do_get(key, adapter_meta.backend) + |> do_fetch(key, adapter_meta) |> return(:value) end - defp do_get([newer], key, backend) do - gen_fetch(newer, key, backend) + defp do_fetch([newer], key, adapter_meta) do + fetch_entry(newer, key, adapter_meta) end - defp do_get([newer, older], key, backend) do - with nil <- gen_fetch(newer, key, backend), - entry(key: ^key) = cached <- gen_fetch(older, key, backend, &pop_entry/4) do - true = backend.insert(newer, cached) - cached + defp do_fetch([newer, older], key, adapter_meta) do + with {:error, _} <- fetch_entry(newer, key, adapter_meta), + {:ok, cached} <- pop_entry(older, key, adapter_meta) do + true = adapter_meta.backend.insert(newer, cached) + {:ok, cached} end end - defp gen_fetch(gen, key, backend, fun \\ &get_entry/4) do - gen - |> fun.(key, nil, backend) - |> validate_ttl(gen, backend) - end - @impl true - defspan get_all(adapter_meta, keys, _opts) do + defspan get_all(adapter_meta, keys, opts) do adapter_meta = %{adapter_meta | telemetry: Map.get(adapter_meta, :in_span?, false)} - Enum.reduce(keys, %{}, fn key, acc -> - if obj = get(adapter_meta, key, []), - do: Map.put(acc, key, obj), - else: acc + keys + |> Enum.reduce(%{}, fn key, acc -> + case fetch(adapter_meta, key, opts) do + {:ok, val} -> Map.put(acc, key, val) + {:error, _} -> acc + end end) + |> wrap_ok() end @impl true defspan put(adapter_meta, key, value, ttl, on_write, _opts) do - do_put( - on_write, - adapter_meta.meta_tab, - adapter_meta.backend, - entry( - key: key, - value: value, - touched: Time.now(), - ttl: ttl - ) - ) + now = Time.now() + entry = entry(key: key, value: value, touched: now, exp: exp(now, ttl)) + + wrap_ok do_put(on_write, adapter_meta.meta_tab, adapter_meta.backend, entry) end defp do_put(:put, meta_tab, backend, entry) do @@ -506,26 +474,30 @@ defmodule Nebulex.Adapters.Local do @impl true defspan put_all(adapter_meta, entries, ttl, on_write, _opts) do + now = Time.now() + exp = exp(now, ttl) + entries = - for {key, value} <- entries, value != nil do - entry(key: key, value: value, touched: Time.now(), ttl: ttl) + for {key, value} <- entries do + entry(key: key, value: value, touched: now, exp: exp) end do_put_all( on_write, adapter_meta.meta_tab, adapter_meta.backend, - adapter_meta.purge_batch_size, + adapter_meta.purge_chunk_size, entries ) + |> wrap_ok() end - defp do_put_all(:put, meta_tab, backend, batch_size, entries) do - put_entries(meta_tab, backend, entries, batch_size) + defp do_put_all(:put, meta_tab, backend, chunk_size, entries) do + put_entries(meta_tab, backend, entries, chunk_size) end - defp do_put_all(:put_new, meta_tab, backend, batch_size, entries) do - put_new_entries(meta_tab, backend, entries, batch_size) + defp do_put_all(:put_new, meta_tab, backend, chunk_size, entries) do + put_new_entries(meta_tab, backend, entries, chunk_size) end @impl true @@ -536,33 +508,22 @@ defmodule Nebulex.Adapters.Local do end @impl true - def take(adapter_meta, key, _opts) do - adapter_meta - |> take_(key) - |> handle_expired() - end - - defspan take_(adapter_meta, key), as: :take do + defspan take(adapter_meta, key, _opts) do adapter_meta.meta_tab |> list_gen() - |> Enum.reduce_while(nil, fn gen, acc -> - case pop_entry(gen, key, nil, adapter_meta.backend) do - nil -> - {:cont, acc} - - res -> - value = - res - |> validate_ttl(gen, adapter_meta.backend) - |> return(:value) - - {:halt, value} + |> Enum.reduce_while(nil, fn gen, _acc -> + case pop_entry(gen, key, adapter_meta) do + {:ok, res} -> {:halt, return({:ok, res}, :value)} + error -> {:cont, error} end end) end @impl true defspan update_counter(adapter_meta, key, amount, ttl, default, _opts) do + # Current time + now = Time.now() + # Get needed metadata meta_tab = adapter_meta.meta_tab backend = adapter_meta.backend @@ -571,7 +532,7 @@ defmodule Nebulex.Adapters.Local do _ = meta_tab |> list_gen() - |> do_get(key, backend) + |> do_fetch(key, adapter_meta) # Run the counter operation meta_tab @@ -579,47 +540,50 @@ defmodule Nebulex.Adapters.Local do |> backend.update_counter( key, {3, amount}, - entry(key: key, value: default, touched: Time.now(), ttl: ttl) + entry(key: key, value: default, touched: now, exp: exp(now, ttl)) ) + |> wrap_ok() end @impl true - def has_key?(adapter_meta, key) do - case get(adapter_meta, key, []) do - nil -> false - _ -> true + def exists?(adapter_meta, key, _opts) do + case fetch(adapter_meta, key, []) do + {:ok, _} -> {:ok, true} + {:error, _} -> {:ok, false} end end @impl true - defspan ttl(adapter_meta, key) do - adapter_meta.meta_tab - |> list_gen() - |> do_get(key, adapter_meta.backend) - |> return() - |> entry_ttl() + defspan ttl(adapter_meta, key, _opts) do + with {:ok, res} <- adapter_meta.meta_tab |> list_gen() |> do_fetch(key, adapter_meta) do + {:ok, entry_ttl(res)} + end end - defp entry_ttl(nil), do: nil - defp entry_ttl(:"$expired"), do: nil - defp entry_ttl(entry(ttl: :infinity)), do: :infinity + defp entry_ttl(entry(exp: :infinity)), do: :infinity - defp entry_ttl(entry(ttl: ttl, touched: touched)) do - ttl - (Time.now() - touched) + defp entry_ttl(entry(exp: exp)) do + exp - Time.now() end defp entry_ttl(entries) when is_list(entries) do - for entry <- entries, do: entry_ttl(entry) + Enum.map(entries, &entry_ttl/1) end @impl true - defspan expire(adapter_meta, key, ttl) do - update_entry(adapter_meta.meta_tab, adapter_meta.backend, key, [{4, Time.now()}, {5, ttl}]) + defspan expire(adapter_meta, key, ttl, _opts) do + now = Time.now() + + adapter_meta.meta_tab + |> update_entry(adapter_meta.backend, key, [{4, now}, {5, exp(now, ttl)}]) + |> wrap_ok() end @impl true - defspan touch(adapter_meta, key) do - update_entry(adapter_meta.meta_tab, adapter_meta.backend, key, [{4, Time.now()}]) + defspan touch(adapter_meta, key, _opts) do + adapter_meta.meta_tab + |> update_entry(adapter_meta.backend, key, [{4, Time.now()}]) + |> wrap_ok() end ## Nebulex.Adapter.Queryable @@ -637,10 +601,14 @@ defmodule Nebulex.Adapters.Local do |> backend.info(:size) |> Kernel.+(acc) end) + |> wrap_ok() end - defp do_execute(%{meta_tab: meta_tab}, :delete_all, nil, _opts) do - Generation.delete_all(meta_tab) + defp do_execute(%{meta_tab: meta_tab} = adapter_meta, :delete_all, nil, _opts) do + with {:ok, count_all} <- do_execute(adapter_meta, :count_all, nil, []) do + :ok = Generation.delete_all(meta_tab) + {:ok, count_all} + end end defp do_execute(%{meta_tab: meta_tab} = adapter_meta, :delete_all, {:in, keys}, _opts) @@ -648,36 +616,38 @@ defmodule Nebulex.Adapters.Local do meta_tab |> list_gen() |> Enum.reduce(0, fn gen, acc -> - do_delete_all(adapter_meta.backend, gen, keys, adapter_meta.purge_batch_size) + acc + do_delete_all(adapter_meta.backend, gen, keys, adapter_meta.purge_chunk_size) + acc end) end defp do_execute(%{meta_tab: meta_tab, backend: backend}, operation, query, opts) do - query = - query - |> validate_match_spec(opts) - |> maybe_match_spec_return_true(operation) - - {reducer, acc_in} = - case operation do - :all -> {&(backend.select(&1, query) ++ &2), []} - :count_all -> {&(backend.select_count(&1, query) + &2), 0} - :delete_all -> {&(backend.select_delete(&1, query) + &2), 0} - end + with {:ok, query} <- validate_match_spec(query, opts) do + query = maybe_match_spec_return_true(query, operation) + + {reducer, acc_in} = + case operation do + :all -> {&(backend.select(&1, query) ++ &2), []} + :count_all -> {&(backend.select_count(&1, query) + &2), 0} + :delete_all -> {&(backend.select_delete(&1, query) + &2), 0} + end - meta_tab - |> list_gen() - |> Enum.reduce(acc_in, reducer) + meta_tab + |> list_gen() + |> Enum.reduce(acc_in, reducer) + |> wrap_ok() + end end @impl true defspan stream(adapter_meta, query, opts) do - query - |> validate_match_spec(opts) - |> do_stream(adapter_meta, Keyword.get(opts, :page_size, 20)) + with {:ok, query} <- validate_match_spec(query, opts) do + adapter_meta + |> do_stream(query, Keyword.get(opts, :page_size, 20)) + |> wrap_ok() + end end - defp do_stream(match_spec, %{meta_tab: meta_tab, backend: backend}, page_size) do + defp do_stream(%{meta_tab: meta_tab, backend: backend}, match_spec, page_size) do Stream.resource( fn -> [newer | _] = generations = list_gen(meta_tab) @@ -731,13 +701,16 @@ defmodule Nebulex.Adapters.Local do @impl true defspan stats(adapter_meta) do - if stats = super(adapter_meta) do - %{stats | metadata: Map.put(stats.metadata, :started_at, adapter_meta.started_at)} + with {:ok, %Nebulex.Stats{} = stats} <- super(adapter_meta) do + {:ok, %{stats | metadata: Map.put(stats.metadata, :started_at, adapter_meta.started_at)}} end end ## Helpers + defp exp(_now, :infinity), do: :infinity + defp exp(now, ttl), do: now + ttl + defp list_gen(meta_tab) do Metadata.fetch!(meta_tab, :generations) end @@ -748,35 +721,72 @@ defmodule Nebulex.Adapters.Local do |> hd() end - defp get_entry(tab, key, default, backend) do - case backend.lookup(tab, key) do - [] -> default - [entry] -> entry - entries -> entries + defmacrop backend_call(adapter_meta, fun, tab, key) do + quote do + case unquote(adapter_meta).backend.unquote(fun)(unquote(tab), unquote(key)) do + [] -> + wrap_error Nebulex.KeyError, key: unquote(key), cache: unquote(adapter_meta).name + + [entry(exp: :infinity) = entry] -> + {:ok, entry} + + [entry() = entry] -> + validate_exp(entry, unquote(tab), unquote(adapter_meta)) + + entries when is_list(entries) -> + now = Time.now() + + {:ok, for(entry(touched: touched, exp: exp) = e <- entries, now < exp, do: e)} + end end end - defp pop_entry(tab, key, default, backend) do - case backend.take(tab, key) do - [] -> default - [entry] -> entry - entries -> entries + defp validate_exp(entry(key: key, exp: exp) = entry, tab, adapter_meta) do + if Time.now() >= exp do + true = adapter_meta.backend.delete(tab, key) + + wrap_error Nebulex.KeyError, key: key, cache: adapter_meta.name, reason: :expired + else + {:ok, entry} end end - defp put_entries(meta_tab, backend, entries, batch_size \\ 0) + defp fetch_entry(tab, key, adapter_meta) do + backend_call(adapter_meta, :lookup, tab, key) + end - defp put_entries(meta_tab, backend, entries, batch_size) when is_list(entries) do - do_put_entries(meta_tab, backend, entries, fn older_gen -> - keys = Enum.map(entries, fn entry(key: key) -> key end) + defp pop_entry(tab, key, adapter_meta) do + backend_call(adapter_meta, :take, tab, key) + end - do_delete_all(backend, older_gen, keys, batch_size) - end) + defp put_entries(meta_tab, backend, entries, chunk_size \\ 0) + + defp put_entries( + meta_tab, + backend, + entry(key: key, value: val, touched: touched, exp: exp) = entry, + _chunk_size + ) do + case list_gen(meta_tab) do + [newer_gen] -> + backend.insert(newer_gen, entry) + + [newer_gen, older_gen] -> + changes = [{3, val}, {4, touched}, {5, exp}] + + with false <- backend.update_element(newer_gen, key, changes) do + true = backend.delete(older_gen, key) + + backend.insert(newer_gen, entry) + end + end end - defp put_entries(meta_tab, backend, entry(key: key) = entry, _batch_size) do - do_put_entries(meta_tab, backend, entry, fn older_gen -> - true = backend.delete(older_gen, key) + defp put_entries(meta_tab, backend, entries, chunk_size) when is_list(entries) do + do_put_entries(meta_tab, backend, entries, fn older_gen -> + keys = Enum.map(entries, fn entry(key: key) -> key end) + + do_delete_all(backend, older_gen, keys, chunk_size) end) end @@ -792,26 +802,26 @@ defmodule Nebulex.Adapters.Local do end end - defp put_new_entries(meta_tab, backend, entries, batch_size \\ 0) + defp put_new_entries(meta_tab, backend, entries, chunk_size \\ 0) - defp put_new_entries(meta_tab, backend, entries, batch_size) when is_list(entries) do - do_put_new_entries(meta_tab, backend, entries, fn newer_gen, older_gen -> - with true <- backend.insert_new(older_gen, entries) do - keys = Enum.map(entries, fn entry(key: key) -> key end) - - _ = do_delete_all(backend, older_gen, keys, batch_size) + defp put_new_entries(meta_tab, backend, entry(key: key) = entry, _chunk_size) do + do_put_new_entries(meta_tab, backend, entry, fn newer_gen, older_gen -> + with true <- backend.insert_new(older_gen, entry) do + true = backend.delete(older_gen, key) - backend.insert_new(newer_gen, entries) + backend.insert_new(newer_gen, entry) end end) end - defp put_new_entries(meta_tab, backend, entry(key: key) = entry, _batch_size) do - do_put_new_entries(meta_tab, backend, entry, fn newer_gen, older_gen -> - with true <- backend.insert_new(older_gen, entry) do - true = backend.delete(older_gen, key) + defp put_new_entries(meta_tab, backend, entries, chunk_size) when is_list(entries) do + do_put_new_entries(meta_tab, backend, entries, fn newer_gen, older_gen -> + with true <- backend.insert_new(older_gen, entries) do + keys = Enum.map(entries, fn entry(key: key) -> key end) - backend.insert_new(newer_gen, entry) + _ = do_delete_all(backend, older_gen, keys, chunk_size) + + backend.insert_new(newer_gen, entries) end end) end @@ -833,30 +843,33 @@ defmodule Nebulex.Adapters.Local do [newer_gen, older_gen] -> with false <- backend.update_element(newer_gen, key, updates), - entry() = entry <- pop_entry(older_gen, key, false, backend) do + [entry() = entry] <- backend.take(older_gen, key) do entry = Enum.reduce(updates, entry, fn {3, value}, acc -> entry(acc, value: value) {4, value}, acc -> entry(acc, touched: value) - {5, value}, acc -> entry(acc, ttl: value) + {5, value}, acc -> entry(acc, exp: value) end) backend.insert(newer_gen, entry) + else + [] -> false + other -> other end end end - defp do_delete_all(backend, tab, keys, batch_size) do - do_delete_all(backend, tab, keys, batch_size, 0) + defp do_delete_all(backend, tab, keys, chunk_size) do + do_delete_all(backend, tab, keys, chunk_size, 0) end - defp do_delete_all(backend, tab, [key], _batch_size, deleted) do + defp do_delete_all(backend, tab, [key], _chunk_size, deleted) do true = backend.delete(tab, key) deleted + 1 end - defp do_delete_all(backend, tab, [k1, k2 | keys], batch_size, deleted) do + defp do_delete_all(backend, tab, [k1, k2 | keys], chunk_size, deleted) do k1 = if is_tuple(k1), do: {k1}, else: k1 k2 = if is_tuple(k2), do: {k2}, else: k2 @@ -864,87 +877,82 @@ defmodule Nebulex.Adapters.Local do backend, tab, keys, - batch_size, + chunk_size, deleted, 2, {:orelse, {:==, :"$1", k1}, {:==, :"$1", k2}} ) end - defp do_delete_all(backend, tab, [], _batch_size, deleted, _count, acc) do + defp do_delete_all(backend, tab, [], _chunk_size, deleted, _count, acc) do backend.select_delete(tab, delete_all_match_spec(acc)) + deleted end - defp do_delete_all(backend, tab, keys, batch_size, deleted, count, acc) - when count >= batch_size do + defp do_delete_all(backend, tab, keys, chunk_size, deleted, count, acc) + when count >= chunk_size do deleted = backend.select_delete(tab, delete_all_match_spec(acc)) + deleted - do_delete_all(backend, tab, keys, batch_size, deleted) + do_delete_all(backend, tab, keys, chunk_size, deleted) end - defp do_delete_all(backend, tab, [k | keys], batch_size, deleted, count, acc) do + defp do_delete_all(backend, tab, [k | keys], chunk_size, deleted, count, acc) do k = if is_tuple(k), do: {k}, else: k do_delete_all( backend, tab, keys, - batch_size, + chunk_size, deleted, count + 1, {:orelse, acc, {:==, :"$1", k}} ) end - defp return(entry_or_entries, field \\ nil) - - defp return(nil, _field), do: nil - defp return(:"$expired", _field), do: :"$expired" - defp return(entry(value: value), :value), do: value - defp return(entry(key: _) = entry, _field), do: entry - - defp return(entries, field) when is_list(entries) do - Enum.map(entries, &return(&1, field)) + defp return({:ok, entry(value: value)}, :value) do + {:ok, value} end - defp validate_ttl(nil, _, _), do: nil - defp validate_ttl(entry(ttl: :infinity) = entry, _, _), do: entry - - defp validate_ttl(entry(key: key, touched: touched, ttl: ttl) = entry, gen, backend) do - if Time.now() - touched >= ttl do - true = backend.delete(gen, key) - :"$expired" - else - entry - end + defp return({:ok, entries}, :value) when is_list(entries) do + {:ok, for(entry(value: value) <- entries, do: value)} end - defp validate_ttl(entries, gen, backend) when is_list(entries) do - Enum.filter(entries, fn entry -> - not is_nil(validate_ttl(entry, gen, backend)) - end) + defp return(other, _field) do + other end - defp handle_expired(:"$expired"), do: nil - defp handle_expired(result), do: result - defp validate_match_spec(spec, opts) when spec in [nil, :unexpired, :expired] do [ { - entry(key: :"$1", value: :"$2", touched: :"$3", ttl: :"$4"), + entry(key: :"$1", value: :"$2", touched: :"$3", exp: :"$4"), if(spec = comp_match_spec(spec), do: [spec], else: []), ret_match_spec(opts) } ] + |> wrap_ok() end defp validate_match_spec(spec, _opts) do case :ets.test_ms(test_ms(), spec) do {:ok, _result} -> - spec + {:ok, spec} {:error, _result} -> - raise Nebulex.QueryError, message: "invalid match spec", query: spec + msg = """ + expected query to be one of: + + - `nil` - match all entries + - `:unexpired` - match only unexpired entries + - `:expired` - match only expired entries + - `{:in, list_of_keys}` - special form only available for delete_all + - `:ets.match_spec()` - ETS match spec + + but got: + + #{inspect(spec, pretty: true)} + """ + + wrap_error Nebulex.QueryError, message: msg, query: spec end end @@ -952,7 +960,7 @@ defmodule Nebulex.Adapters.Local do do: nil defp comp_match_spec(:unexpired), - do: {:orelse, {:==, :"$4", :infinity}, {:<, {:-, Time.now(), :"$3"}, :"$4"}} + do: {:orelse, {:==, :"$4", :infinity}, {:<, Time.now(), :"$4"}} defp comp_match_spec(:expired), do: {:not, comp_match_spec(:unexpired)} @@ -962,7 +970,7 @@ defmodule Nebulex.Adapters.Local do :key -> [:"$1"] :value -> [:"$2"] {:key, :value} -> [{{:"$1", :"$2"}}] - :entry -> [%Entry{key: :"$1", value: :"$2", touched: :"$3", ttl: :"$4"}] + :entry -> [%Entry{key: :"$1", value: :"$2", touched: :"$3", exp: :"$4"}] end end @@ -978,12 +986,12 @@ defmodule Nebulex.Adapters.Local do defp delete_all_match_spec(conds) do [ { - entry(key: :"$1", value: :"$2", touched: :"$3", ttl: :"$4"), + entry(key: :"$1", value: :"$2", touched: :"$3", exp: :"$4"), [conds], [true] } ] end - defp test_ms, do: entry(key: 1, value: 1, touched: Time.now(), ttl: 1000) + defp test_ms, do: entry(key: 1, value: 1, touched: Time.now(), exp: 1000) end diff --git a/lib/nebulex/adapters/local/backend.ex b/lib/nebulex/adapters/local/backend.ex index 0ba56700..708ab405 100644 --- a/lib/nebulex/adapters/local/backend.ex +++ b/lib/nebulex/adapters/local/backend.ex @@ -4,8 +4,6 @@ defmodule Nebulex.Adapters.Local.Backend do @doc false defmacro __using__(_opts) do quote do - import Nebulex.Helpers - alias Nebulex.Adapters.Local.Generation defp generation_spec(opts) do @@ -24,10 +22,10 @@ defmodule Nebulex.Adapters.Local.Backend do end defp parse_opts(opts, extra \\ []) do - type = get_option(opts, :backend_type, "an atom", &is_atom/1, :set) + type = Keyword.fetch!(opts, :backend_type) compressed = - case get_option(opts, :compressed, "boolean", &is_boolean/1, false) do + case Keyword.fetch!(opts, :compressed) do true -> [:compressed] false -> [] end @@ -37,10 +35,8 @@ defmodule Nebulex.Adapters.Local.Backend do type, :public, {:keypos, 2}, - {:read_concurrency, - get_option(opts, :read_concurrency, "boolean", &is_boolean/1, true)}, - {:write_concurrency, - get_option(opts, :write_concurrency, "boolean", &is_boolean/1, true)}, + {:read_concurrency, Keyword.fetch!(opts, :read_concurrency)}, + {:write_concurrency, Keyword.fetch!(opts, :write_concurrency)}, compressed, extra ] diff --git a/lib/nebulex/adapters/local/backend/shards.ex b/lib/nebulex/adapters/local/backend/shards.ex index 949863a5..a0d097c2 100644 --- a/lib/nebulex/adapters/local/backend/shards.ex +++ b/lib/nebulex/adapters/local/backend/shards.ex @@ -20,6 +20,7 @@ if Code.ensure_loaded?(:shards) do @impl true def init(meta_tab) do :ok = Metadata.put(meta_tab, :shards_sup, self()) + DynamicSupervisor.init(strategy: :one_for_one) end end @@ -32,14 +33,7 @@ if Code.ensure_loaded?(:shards) do @doc false def child_spec(opts) do - partitions = - get_option( - opts, - :partitions, - "an integer > 0", - &(is_integer(&1) and &1 > 0), - System.schedulers_online() - ) + partitions = Keyword.get_lazy(opts, :partitions, &System.schedulers_online/0) meta_tab = opts @@ -73,6 +67,7 @@ if Code.ensure_loaded?(:shards) do def start_table(opts) do tab = :shards.new(__MODULE__, opts) pid = :shards_meta.tab_pid(tab) + {:ok, pid, tab} end diff --git a/lib/nebulex/adapters/local/generation.ex b/lib/nebulex/adapters/local/generation.ex index ccfb4312..102ea67b 100644 --- a/lib/nebulex/adapters/local/generation.ex +++ b/lib/nebulex/adapters/local/generation.ex @@ -30,11 +30,11 @@ defmodule Nebulex.Adapters.Local.Generation do the check to release memory is not performed (the default). * `:allocated_memory` - If it is set, an integer > 0 is expected defining - the max size in bytes allocated for a cache generation. When this option - is set and the configured value is reached, a new cache generation is - created so the oldest is deleted and force releasing memory space. - If it is not set (`nil`), the cleanup check to release memory is - not performed (the default). + the max size in bytes for the cache storage. When this option is set + and the configured value is reached, a new cache generation is created + so the oldest is deleted and force releasing memory space. If it is not + set (`nil`), the cleanup check to release memory is not performed + (the default). * `:gc_cleanup_min_timeout` - An integer > 0 defining the min timeout in milliseconds for triggering the next cleanup and memory check. This will @@ -72,8 +72,7 @@ defmodule Nebulex.Adapters.Local.Generation do alias Nebulex.Adapter alias Nebulex.Adapter.Stats - alias Nebulex.Adapters.Local - alias Nebulex.Adapters.Local.{Backend, Metadata} + alias Nebulex.Adapters.Local.{Backend, Metadata, Options} alias Nebulex.Telemetry alias Nebulex.Telemetry.StatsHandler @@ -106,11 +105,14 @@ defmodule Nebulex.Adapters.Local.Generation do Nebulex.Adapters.Local.Generation.new(MyCache) Nebulex.Adapters.Local.Generation.new(MyCache, reset_timer: false) + """ @spec new(server_ref, opts) :: [atom] def new(server_ref, opts \\ []) do - reset_timer? = get_option(opts, :reset_timer, "boolean", &is_boolean/1, true) - do_call(server_ref, {:new_generation, reset_timer?}) + # Validate options + opts = Options.validate!(opts) + + do_call(server_ref, {:new_generation, Keyword.fetch!(opts, :reset_timer)}) end @doc """ @@ -119,8 +121,9 @@ defmodule Nebulex.Adapters.Local.Generation do ## Example Nebulex.Adapters.Local.Generation.delete_all(MyCache) + """ - @spec delete_all(server_ref) :: integer + @spec delete_all(server_ref) :: :ok def delete_all(server_ref) do do_call(server_ref, :delete_all) end @@ -133,6 +136,7 @@ defmodule Nebulex.Adapters.Local.Generation do ## Example Nebulex.Adapters.Local.Generation.realloc(MyCache, 1_000_000) + """ @spec realloc(server_ref, pos_integer) :: :ok def realloc(server_ref, size) do @@ -145,6 +149,7 @@ defmodule Nebulex.Adapters.Local.Generation do ## Example Nebulex.Adapters.Local.Generation.memory_info(MyCache) + """ @spec memory_info(server_ref) :: {used_mem :: non_neg_integer, total_mem :: non_neg_integer} def memory_info(server_ref) do @@ -157,6 +162,7 @@ defmodule Nebulex.Adapters.Local.Generation do ## Example Nebulex.Adapters.Local.Generation.reset_timer(MyCache) + """ def reset_timer(server_ref) do server_ref @@ -170,6 +176,7 @@ defmodule Nebulex.Adapters.Local.Generation do ## Example Nebulex.Adapters.Local.Generation.list(MyCache) + """ @spec list(server_ref) :: [:ets.tid()] def list(server_ref) do @@ -184,6 +191,7 @@ defmodule Nebulex.Adapters.Local.Generation do ## Example Nebulex.Adapters.Local.Generation.newer(MyCache) + """ @spec newer(server_ref) :: :ets.tid() def newer(server_ref) do @@ -199,6 +207,7 @@ defmodule Nebulex.Adapters.Local.Generation do ## Example Nebulex.Adapters.Local.Generation.server(MyCache) + """ @spec server(server_ref) :: pid def server(server_ref) do @@ -224,9 +233,7 @@ defmodule Nebulex.Adapters.Local.Generation do end defp get_meta_tab(server_ref) when is_atom(server_ref) or is_pid(server_ref) do - Adapter.with_meta(server_ref, fn _, %{meta_tab: meta_tab} -> - meta_tab - end) + unwrap_or_raise Adapter.with_meta(server_ref, & &1.meta_tab) end defp get_meta_tab(server_ref), do: server_ref @@ -266,19 +273,25 @@ defmodule Nebulex.Adapters.Local.Generation do meta_tab = Map.fetch!(adapter_meta, :meta_tab) :ok = Metadata.put(meta_tab, :gc_pid, self()) - # Common validators - pos_integer = &(is_integer(&1) and &1 > 0) - pos_integer_or_nil = &((is_integer(&1) and &1 > 0) or is_nil(&1)) + gc_opts = + opts + |> Keyword.take([ + :gc_interval, + :max_size, + :allocated_memory, + :gc_cleanup_min_timeout, + :gc_cleanup_max_timeout, + :reset_timer + ]) + |> Options.validate!() Map.merge(adapter_meta, %{ backend_opts: Keyword.get(opts, :backend_opts, []), - gc_interval: get_option(opts, :gc_interval, "an integer > 0", pos_integer_or_nil), - max_size: get_option(opts, :max_size, "an integer > 0", pos_integer_or_nil), - allocated_memory: get_option(opts, :allocated_memory, "an integer > 0", pos_integer_or_nil), - gc_cleanup_min_timeout: - get_option(opts, :gc_cleanup_min_timeout, "an integer > 0", pos_integer, 10_000), - gc_cleanup_max_timeout: - get_option(opts, :gc_cleanup_max_timeout, "an integer > 0", pos_integer, 600_000) + gc_interval: Keyword.get(gc_opts, :gc_interval), + max_size: Keyword.get(gc_opts, :max_size), + allocated_memory: Keyword.get(gc_opts, :allocated_memory), + gc_cleanup_min_timeout: Keyword.get(gc_opts, :gc_cleanup_min_timeout), + gc_cleanup_max_timeout: Keyword.get(gc_opts, :gc_cleanup_max_timeout) }) end @@ -306,11 +319,6 @@ defmodule Nebulex.Adapters.Local.Generation do @impl true def handle_call(:delete_all, _from, %__MODULE__{} = state) do - size = - state - |> Map.from_struct() - |> Local.execute(:count_all, nil, []) - :ok = new_gen(state) :ok = @@ -318,11 +326,12 @@ defmodule Nebulex.Adapters.Local.Generation do |> list() |> Enum.each(&state.backend.delete_all_objects(&1)) - {:reply, size, %{state | gc_heartbeat_ref: maybe_reset_timer(true, state)}} + {:reply, :ok, %{state | gc_heartbeat_ref: maybe_reset_timer(true, state)}} end def handle_call({:new_generation, reset_timer?}, _from, state) do :ok = new_gen(state) + {:reply, :ok, %{state | gc_heartbeat_ref: maybe_reset_timer(reset_timer?, state)}} end @@ -350,6 +359,7 @@ defmodule Nebulex.Adapters.Local.Generation do @impl true def handle_info(:heartbeat, %__MODULE__{gc_interval: time, gc_heartbeat_ref: ref} = state) do :ok = new_gen(state) + {:noreply, %{state | gc_heartbeat_ref: start_timer(time, ref)}} end diff --git a/lib/nebulex/adapters/local/options.ex b/lib/nebulex/adapters/local/options.ex new file mode 100644 index 00000000..5dac0085 --- /dev/null +++ b/lib/nebulex/adapters/local/options.ex @@ -0,0 +1,132 @@ +defmodule Nebulex.Adapters.Local.Options do + @moduledoc """ + Option definitions for the local adapter. + """ + use Nebulex.Cache.Options + + definition = + [ + gc_interval: [ + required: false, + type: :pos_integer, + doc: """ + The interval time in milliseconds to garbage collection to run, + delete the oldest generation and create a new one. + """ + ], + max_size: [ + required: false, + type: :pos_integer, + doc: """ + The max number of cached entries (cache limit). + """ + ], + allocated_memory: [ + required: false, + type: :pos_integer, + doc: """ + The max size in bytes for the cache storage. + """ + ], + gc_cleanup_min_timeout: [ + required: false, + type: :pos_integer, + default: 10_000, + doc: """ + The min timeout in milliseconds for triggering the next cleanup + and memory check. + """ + ], + gc_cleanup_max_timeout: [ + required: false, + type: :pos_integer, + default: 600_000, + doc: """ + The max timeout in milliseconds for triggering the next cleanup + and memory check. + """ + ], + reset_timer: [ + required: false, + type: :boolean, + default: true, + doc: """ + Whether the GC timer should be reset or not. + """ + ], + backend: [ + required: false, + type: {:in, [:ets, :shards]}, + default: :ets, + doc: """ + The backend or storage to be used for the adapter. + Supported backends are: `:ets` and `:shards`. + """ + ], + read_concurrency: [ + required: false, + type: :boolean, + default: true, + doc: """ + Since this adapter uses ETS tables internally, this option is used when + a new table is created; see `:ets.new/2`. + """ + ], + write_concurrency: [ + required: false, + type: :boolean, + default: true, + doc: """ + Since this adapter uses ETS tables internally, this option is used when + a new table is created; see `:ets.new/2`. + """ + ], + compressed: [ + required: false, + type: :boolean, + default: false, + doc: """ + Since this adapter uses ETS tables internally, this option is used when + a new table is created; see `:ets.new/2`. + """ + ], + backend_type: [ + required: false, + type: {:in, [:set, :ordered_set, :bag, :duplicate_bag]}, + default: :set, + doc: """ + This option defines the type of ETS to be used internally when + a new table is created; see `:ets.new/2`. + """ + ], + partitions: [ + required: false, + type: :pos_integer, + doc: """ + This option is only available for `:shards` backend and defines + the number of partitions to use. + """ + ], + backend_opts: [ + required: false, + doc: """ + This option is built internally for creating the ETS tables + used by the local adapter underneath. + """ + ], + purge_chunk_size: [ + required: false, + type: :pos_integer, + default: 100, + doc: """ + This option is for limiting the max nested match specs based on number + of keys when purging the older cache generation. + """ + ] + ] ++ base_definition() + + @definition NimbleOptions.new!(definition) + + @doc false + def definition, do: @definition +end diff --git a/lib/nebulex/adapters/multilevel.ex b/lib/nebulex/adapters/multilevel.ex index e604123a..8d9ee9d0 100644 --- a/lib/nebulex/adapters/multilevel.ex +++ b/lib/nebulex/adapters/multilevel.ex @@ -76,7 +76,7 @@ defmodule Nebulex.Adapters.Multilevel do the cache configuration: * `:levels` - This option is to define the levels, a list of tuples - `{cache_level :: Nebulex.Cache.t(), opts :: Keyword.t()}`, where + `{cache_level :: Nebulex.Cache.t(), opts :: keyword}`, where the first element is the module that defines the cache for that level, and the second one is the options that will be passed to that level in the `start/link/1` (which depends on the adapter @@ -221,9 +221,6 @@ defmodule Nebulex.Adapters.Multilevel do alias Nebulex.Cache.Cluster - # Multi-level Cache Models - @models [:inclusive, :exclusive] - ## Nebulex.Adapter @impl true @@ -233,15 +230,16 @@ defmodule Nebulex.Adapters.Multilevel do A convenience function to get the cache model. """ def model(name \\ __MODULE__) do - with_meta(name, fn _adapter, %{model: model} -> - model - end) + with_meta(name, & &1.model) end end end @impl true def init(opts) do + # Validate options + opts = __MODULE__.Options.validate!(opts) + # Required options telemetry_prefix = Keyword.fetch!(opts, :telemetry_prefix) telemetry = Keyword.fetch!(opts, :telemetry) @@ -249,19 +247,13 @@ defmodule Nebulex.Adapters.Multilevel do name = opts[:name] || cache # Maybe use stats - stats = get_boolean_option(opts, :stats) + stats = Keyword.fetch!(opts, :stats) # Get cache levels - levels = - get_option( - opts, - :levels, - "a list with at least one level definition", - &(Keyword.keyword?(&1) && length(&1) > 0) - ) + levels = Keyword.fetch!(opts, :levels) # Get multilevel-cache model - model = get_option(opts, :model, ":inclusive or :exclusive", &(&1 in @models), :inclusive) + model = Keyword.fetch!(opts, :model) # Build multi-level specs {children, meta_list, _} = children(levels, telemetry_prefix, telemetry, stats) @@ -311,45 +303,58 @@ defmodule Nebulex.Adapters.Multilevel do ## Nebulex.Adapter.Entry @impl true - defspan get(adapter_meta, key, opts) do + defspan fetch(adapter_meta, key, opts) do + default = wrap_error Nebulex.KeyError, key: key, cache: adapter_meta.name + fun = fn level, {default, prev} -> - if value = with_dynamic_cache(level, :get, [key, opts]) do - {:halt, {value, [level | prev]}} - else - {:cont, {default, [level | prev]}} + case with_dynamic_cache(level, :fetch, [key, opts]) do + {:ok, _} = ok -> + {:halt, {ok, [level | prev]}} + + {:error, %Nebulex.KeyError{}} -> + {:cont, {default, [level | prev]}} + + {:error, _} = error -> + {:halt, {error, [level | prev]}} end end opts |> levels(adapter_meta.levels) - |> Enum.reduce_while({nil, []}, fun) + |> Enum.reduce_while({default, []}, fun) |> maybe_replicate(key, adapter_meta.model) end @impl true defspan get_all(adapter_meta, keys, opts) do - fun = fn level, {keys_acc, map_acc} -> - map = with_dynamic_cache(level, :get_all, [keys_acc, opts]) - map_acc = Map.merge(map_acc, map) - - case keys_acc -- Map.keys(map) do - [] -> {:halt, {[], map_acc}} - keys_acc -> {:cont, {keys_acc, map_acc}} + fun = fn level, {{:ok, map_acc}, keys_acc} -> + case with_dynamic_cache(level, :get_all, [keys_acc, opts]) do + {:ok, map} -> + map_acc = Map.merge(map_acc, map) + + case keys_acc -- Map.keys(map) do + [] -> {:halt, {{:ok, map_acc}, []}} + keys_acc -> {:cont, {{:ok, map_acc}, keys_acc}} + end + + {:error, _} = error -> + {:halt, {error, keys_acc}} end end opts |> levels(adapter_meta.levels) - |> Enum.reduce_while({keys, %{}}, fun) - |> elem(1) + |> Enum.reduce_while({{:ok, %{}}, keys}, fun) + |> elem(0) end @impl true defspan put(adapter_meta, key, value, _ttl, on_write, opts) do case on_write do :put -> - :ok = eval(adapter_meta, :put, [key, value, opts], opts) - true + with :ok <- eval(adapter_meta, :put, [key, value, opts], opts) do + {:ok, true} + end :put_new -> eval(adapter_meta, :put_new, [key, value, opts], opts) @@ -366,14 +371,18 @@ defmodule Nebulex.Adapters.Multilevel do reducer = fn level, {_, level_acc} -> case with_dynamic_cache(level, action, [entries, opts]) do :ok -> - {:cont, {true, [level | level_acc]}} + {:cont, {{:ok, true}, [level | level_acc]}} - true -> - {:cont, {true, [level | level_acc]}} + {:ok, true} -> + {:cont, {{:ok, true}, [level | level_acc]}} - false -> + {:ok, false} -> _ = delete_from_levels(level_acc, entries) - {:halt, {on_write == :put, level_acc}} + {:halt, {{:ok, false}, level_acc}} + + {:error, _} = error -> + _ = delete_from_levels(level_acc, entries) + {:halt, {error, level_acc}} end end @@ -390,26 +399,36 @@ defmodule Nebulex.Adapters.Multilevel do @impl true defspan take(adapter_meta, key, opts) do + default = wrap_error Nebulex.KeyError, key: key, cache: adapter_meta.name + opts |> levels(adapter_meta.levels) - |> do_take(nil, key, opts) + |> do_take(default, key, opts) end defp do_take([], result, _key, _opts), do: result - defp do_take([l_meta | rest], nil, key, opts) do + defp do_take([l_meta | rest], {:error, %Nebulex.KeyError{}}, key, opts) do result = with_dynamic_cache(l_meta, :take, [key, opts]) + do_take(rest, result, key, opts) end defp do_take(levels, result, key, _opts) do _ = eval(levels, :delete, [key, []], reverse: true) + result end @impl true - defspan has_key?(adapter_meta, key) do - eval_while(adapter_meta, :has_key?, [key], false) + defspan exists?(adapter_meta, key, opts) do + Enum.reduce_while(adapter_meta.levels, {:ok, false}, fn l_meta, acc -> + case with_dynamic_cache(l_meta, :exists?, [key, opts]) do + {:ok, true} -> {:halt, {:ok, true}} + {:ok, false} -> {:cont, acc} + {:error, _} = error -> {:halt, error} + end + end) end @impl true @@ -418,39 +437,48 @@ defmodule Nebulex.Adapters.Multilevel do end @impl true - defspan ttl(adapter_meta, key) do - eval_while(adapter_meta, :ttl, [key], nil) + defspan ttl(adapter_meta, key, opts) do + default = wrap_error Nebulex.KeyError, key: key, cache: adapter_meta.name + + Enum.reduce_while(adapter_meta.levels, default, fn l_meta, acc -> + case with_dynamic_cache(l_meta, :ttl, [key, opts]) do + {:ok, _} = ok -> {:halt, ok} + {:error, %Nebulex.KeyError{}} -> {:cont, acc} + {:error, _} = error -> {:halt, error} + end + end) end @impl true - defspan expire(adapter_meta, key, ttl) do - Enum.reduce(adapter_meta.levels, false, fn l_meta, acc -> - with_dynamic_cache(l_meta, :expire, [key, ttl]) or acc - end) + defspan expire(adapter_meta, key, ttl, opts) do + eval_while(adapter_meta, :expire, [key, ttl, opts], {:ok, false}, &(&1 or &2)) end @impl true - defspan touch(adapter_meta, key) do - Enum.reduce(adapter_meta.levels, false, fn l_meta, acc -> - with_dynamic_cache(l_meta, :touch, [key]) or acc - end) + defspan touch(adapter_meta, key, opts) do + eval_while(adapter_meta, :touch, [key, opts], {:ok, false}, &(&1 or &2)) end ## Nebulex.Adapter.Queryable @impl true defspan execute(adapter_meta, operation, query, opts) do + do_execute(adapter_meta.levels, operation, query, opts) + end + + defp do_execute(levels, operation, query, opts) do {levels, reducer, acc_in} = case operation do - :all -> {adapter_meta.levels, &(&1 ++ &2), []} - :delete_all -> {Enum.reverse(adapter_meta.levels), &(&1 + &2), 0} - _ -> {adapter_meta.levels, &(&1 + &2), 0} + :all -> {levels, &(&1 ++ &2), []} + :delete_all -> {Enum.reverse(levels), &(&1 + &2), 0} + _ -> {levels, &(&1 + &2), 0} end - Enum.reduce(levels, acc_in, fn level, acc -> - level - |> with_dynamic_cache(operation, [query, opts]) - |> reducer.(acc) + Enum.reduce_while(levels, {:ok, acc_in}, fn level, {:ok, acc} -> + case with_dynamic_cache(level, operation, [query, opts]) do + {:ok, result} -> {:cont, {:ok, reducer.(result, acc)}} + {:error, _} = error -> {:halt, error} + end end) end @@ -467,13 +495,14 @@ defmodule Nebulex.Adapters.Multilevel do [level | levels] -> elements = level - |> with_dynamic_cache(:stream, [query, opts]) + |> with_dynamic_cache(:stream!, [query, opts]) |> Enum.to_list() {elements, levels} end, & &1 ) + |> wrap_ok() end ## Nebulex.Adapter.Transaction @@ -515,21 +544,30 @@ defmodule Nebulex.Adapters.Multilevel do adapter_meta.levels |> Enum.with_index(1) - |> Enum.reduce(init_acc, &update_stats/2) + |> Enum.reduce_while({:ok, init_acc}, &update_stats/2) + else + wrap_error Nebulex.Error, + reason: {:stats_error, adapter_meta[:name] || adapter_meta[:cache]} end end # We can safely disable this warning since the atom created dynamically is # always re-used; the number of levels is limited and known before hand. # sobelow_skip ["DOS.BinToAtom"] - defp update_stats({meta, idx}, stats_acc) do - if stats = with_dynamic_cache(meta, :stats, []) do - level_idx = :"l#{idx}" - measurements = Map.put(stats_acc.measurements, level_idx, stats.measurements) - metadata = Map.put(stats_acc.metadata, level_idx, stats.metadata) - %{stats_acc | measurements: measurements, metadata: metadata} - else - stats_acc + defp update_stats({meta, idx}, {:ok, stats_acc}) do + case with_dynamic_cache(meta, :stats, []) do + {:ok, stats} -> + level_idx = :"l#{idx}" + measurements = Map.put(stats_acc.measurements, level_idx, stats.measurements) + metadata = Map.put(stats_acc.metadata, level_idx, stats.metadata) + + {:cont, {:ok, %{stats_acc | measurements: measurements, metadata: metadata}}} + + {:error, %Nebulex.Error{reason: {:stats_error, _}}} -> + {:cont, {:ok, stats_acc}} + + {:error, _} = error -> + {:halt, error} end end @@ -556,8 +594,21 @@ defmodule Nebulex.Adapters.Multilevel do end defp eval([level_meta | next], fun, args) do - Enum.reduce(next, with_dynamic_cache(level_meta, fun, args), fn l_meta, acc -> - ^acc = with_dynamic_cache(l_meta, fun, args) + Enum.reduce_while(next, with_dynamic_cache(level_meta, fun, args), fn + _l_meta, {:error, _} = error -> + {:halt, error} + + l_meta, ok -> + {:cont, ^ok = with_dynamic_cache(l_meta, fun, args)} + end) + end + + defp eval_while(%{levels: levels}, fun, args, init, reducer) do + Enum.reduce_while(levels, init, fn l_meta, {:ok, acc} -> + case with_dynamic_cache(l_meta, fun, args) do + {:ok, bool} -> {:cont, {:ok, reducer.(bool, acc)}} + {:error, _} = error -> {:halt, error} + end end) end @@ -575,34 +626,24 @@ defmodule Nebulex.Adapters.Multilevel do end end - defp eval_while(%{levels: levels}, fun, args, init) do - Enum.reduce_while(levels, init, fn level_meta, acc -> - if return = with_dynamic_cache(level_meta, fun, args), - do: {:halt, return}, - else: {:cont, acc} - end) - end - defp delete_from_levels(levels, entries) do for level_meta <- levels, {key, _} <- entries do with_dynamic_cache(level_meta, :delete, [key, []]) end end - defp maybe_replicate({nil, _}, _, _), do: nil - - defp maybe_replicate({value, [level_meta | [_ | _] = levels]}, key, :inclusive) do - ttl = with_dynamic_cache(level_meta, :ttl, [key]) || :infinity - - :ok = - Enum.each(levels, fn l_meta -> - _ = with_dynamic_cache(l_meta, :put, [key, value, [ttl: ttl]]) - end) + defp maybe_replicate({{:ok, value} = ok, [level_meta | [_ | _] = levels]}, key, :inclusive) do + with {:ok, ttl} <- with_dynamic_cache(level_meta, :ttl, [key]) do + :ok = + Enum.each(levels, fn l_meta -> + _ = with_dynamic_cache(l_meta, :put, [key, value, [ttl: ttl]]) + end) - value + ok + end end - defp maybe_replicate({value, _levels}, _key, _model) do - value + defp maybe_replicate({result, _levels}, _key, _model) do + result end end diff --git a/lib/nebulex/adapters/multilevel/options.ex b/lib/nebulex/adapters/multilevel/options.ex new file mode 100644 index 00000000..99aca7d0 --- /dev/null +++ b/lib/nebulex/adapters/multilevel/options.ex @@ -0,0 +1,30 @@ +defmodule Nebulex.Adapters.Multilevel.Options do + @moduledoc """ + Option definitions for the multilevel adapter. + """ + use Nebulex.Cache.Options + + definition = + [ + levels: [ + required: true, + type: :non_empty_keyword_list, + doc: """ + The list of the cache levels. + """ + ], + model: [ + required: false, + type: {:in, [:inclusive, :exclusive]}, + default: :inclusive, + doc: """ + Specifies the cache model: `:inclusive` or `:exclusive`. + """ + ] + ] ++ base_definition() + + @definition NimbleOptions.new!(definition) + + @doc false + def definition, do: @definition +end diff --git a/lib/nebulex/adapters/nil.ex b/lib/nebulex/adapters/nil.ex index 1203f47a..745ad38f 100644 --- a/lib/nebulex/adapters/nil.ex +++ b/lib/nebulex/adapters/nil.ex @@ -70,6 +70,8 @@ defmodule Nebulex.Adapters.Nil do # Inherit default transaction implementation use Nebulex.Adapter.Transaction + import Nebulex.Helpers + ## Nebulex.Adapter @impl true @@ -78,52 +80,61 @@ defmodule Nebulex.Adapters.Nil do @impl true def init(_opts) do child_spec = Supervisor.child_spec({Agent, fn -> :ok end}, id: {Agent, 1}) + {:ok, child_spec, %{}} end ## Nebulex.Adapter.Entry @impl true - def get(_, _, _), do: nil + def fetch(adapter_meta, key, _) do + wrap_error Nebulex.KeyError, key: key, cache: adapter_meta.cache + end @impl true - def get_all(_, _, _), do: %{} + def get_all(_, _, _), do: {:ok, %{}} @impl true - def put(_, _, _, _, _, _), do: true + def put(_, _, _, _, _, _), do: {:ok, true} @impl true - def put_all(_, _, _, _, _), do: true + def put_all(_, _, _, _, _), do: {:ok, true} @impl true def delete(_, _, _), do: :ok @impl true - def take(_, _, _), do: nil + def take(adapter_meta, key, _) do + wrap_error Nebulex.KeyError, key: key, cache: adapter_meta.cache + end @impl true - def has_key?(_, _), do: false + def exists?(_, _, _), do: {:ok, false} @impl true - def ttl(_, _), do: nil + def ttl(adapter_meta, key, _opts) do + wrap_error Nebulex.KeyError, key: key, cache: adapter_meta.cache + end @impl true - def expire(_, _, _), do: true + def expire(_, _, _, _), do: {:ok, false} @impl true - def touch(_, _), do: true + def touch(_, _, _), do: {:ok, false} @impl true - def update_counter(_, _, amount, _, default, _), do: default + amount + def update_counter(_, _, amount, _, default, _) do + {:ok, default + amount} + end ## Nebulex.Adapter.Queryable @impl true - def execute(_, :all, _, _), do: [] - def execute(_, _, _, _), do: 0 + def execute(_, :all, _, _), do: {:ok, []} + def execute(_, _, _, _), do: {:ok, 0} @impl true - def stream(_, _, _), do: Stream.each([], & &1) + def stream(_, _, _), do: {:ok, Stream.each([], & &1)} ## Nebulex.Adapter.Persistence @@ -136,5 +147,5 @@ defmodule Nebulex.Adapters.Nil do ## Nebulex.Adapter.Stats @impl true - def stats(_), do: %Nebulex.Stats{} + def stats(_), do: {:ok, %Nebulex.Stats{}} end diff --git a/lib/nebulex/adapters/partitioned.ex b/lib/nebulex/adapters/partitioned.ex index 173acde7..5d2ea2e6 100644 --- a/lib/nebulex/adapters/partitioned.ex +++ b/lib/nebulex/adapters/partitioned.ex @@ -154,9 +154,6 @@ defmodule Nebulex.Adapters.Partitioned do * `:keyslot` - Defines the module implementing `Nebulex.Adapter.Keyslot` behaviour. - * `:task_supervisor_opts` - Start-time options passed to - `Task.Supervisor.start_link/1` when the adapter is initialized. - * `:join_timeout` - Interval time in milliseconds for joining the running partitioned cache to the cluster. This is to ensure it is always joined. Defaults to `:timer.seconds(180)`. @@ -352,7 +349,7 @@ defmodule Nebulex.Adapters.Partitioned do A convenience function to get the node of the given `key`. """ def get_node(key) do - with_meta(get_dynamic_cache(), fn _adapter, %{name: name, keyslot: keyslot} -> + with_meta(get_dynamic_cache(), fn %{name: name, keyslot: keyslot} -> Cluster.get_node(name, key, keyslot) end) end @@ -375,6 +372,9 @@ defmodule Nebulex.Adapters.Partitioned do @impl true def init(opts) do + # Validate options + opts = __MODULE__.Options.validate!(opts) + # Required options telemetry_prefix = Keyword.fetch!(opts, :telemetry_prefix) telemetry = Keyword.fetch!(opts, :telemetry) @@ -382,7 +382,7 @@ defmodule Nebulex.Adapters.Partitioned do name = opts[:name] || cache # Maybe use stats - stats = get_boolean_option(opts, :stats) + stats = Keyword.fetch!(opts, :stats) # Primary cache options primary_opts = @@ -400,19 +400,15 @@ defmodule Nebulex.Adapters.Partitioned do # Keyslot module for selecting nodes keyslot = opts - |> get_option(:keyslot, "an atom", &is_atom/1, __MODULE__) + |> Keyword.get(:keyslot, __MODULE__) |> assert_behaviour(Nebulex.Adapter.Keyslot, "keyslot") - # Maybe task supervisor for distributed tasks - {task_sup_name, children} = task_sup_child_spec(name, opts) - # Prepare metadata adapter_meta = %{ telemetry_prefix: telemetry_prefix, telemetry: telemetry, name: name, primary_name: primary_opts[:name], - task_sup: task_sup_name, keyslot: keyslot, stats: stats } @@ -425,65 +421,39 @@ defmodule Nebulex.Adapters.Partitioned do children: [ {cache.__primary__, primary_opts}, {__MODULE__.Bootstrap, {Map.put(adapter_meta, :cache, cache), opts}} - | children ] ) {:ok, child_spec, adapter_meta} end - if Code.ensure_loaded?(:erpc) do - defp task_sup_child_spec(_name, _opts) do - {nil, []} - end - else - defp task_sup_child_spec(name, opts) do - # task supervisor to execute parallel and/or remote commands - task_sup_name = normalize_module_name([name, TaskSupervisor]) - task_sup_opts = Keyword.get(opts, :task_supervisor_opts, []) - - children = [ - {Task.Supervisor, [name: task_sup_name] ++ task_sup_opts} - ] - - {task_sup_name, children} - end - end - ## Nebulex.Adapter.Entry @impl true - defspan get(adapter_meta, key, opts) do - call(adapter_meta, key, :get, [key, opts], opts) + defspan fetch(adapter_meta, key, opts) do + adapter_meta + |> call(key, :fetch, [key, opts], opts) + |> handle_key_error(adapter_meta.name) end @impl true defspan get_all(adapter_meta, keys, opts) do - map_reduce( - keys, - adapter_meta, - :get_all, - [opts], - Keyword.get(opts, :timeout), - { - %{}, - fn - {:ok, res}, _, acc when is_map(res) -> - Map.merge(acc, res) - - _, _, acc -> - acc - end - } - ) + case map_reduce(keys, adapter_meta, :get_all, [opts], Keyword.get(opts, :timeout)) do + {res, []} -> + {:ok, Enum.reduce(res, %{}, &Map.merge(&2, &1))} + + {_ok, errors} -> + wrap_error Nebulex.Error, reason: {:rpc_multicall_error, errors}, module: RPC + end end @impl true defspan put(adapter_meta, key, value, _ttl, on_write, opts) do case on_write do :put -> - :ok = call(adapter_meta, key, :put, [key, value, opts], opts) - true + with :ok <- call(adapter_meta, key, :put, [key, value, opts], opts) do + {:ok, true} + end :put_new -> call(adapter_meta, key, :put_new, [key, value, opts], opts) @@ -505,38 +475,15 @@ defmodule Nebulex.Adapters.Partitioned do end def do_put_all(action, adapter_meta, entries, opts) do - reducer = { - {true, []}, - fn - {:ok, :ok}, {_, {_, _, [_, _, [kv, _]]}}, {bool, acc} -> - {bool, Enum.reduce(kv, acc, &[elem(&1, 0) | &2])} - - {:ok, true}, {_, {_, _, [_, _, [kv, _]]}}, {bool, acc} -> - {bool, Enum.reduce(kv, acc, &[elem(&1, 0) | &2])} - - {:ok, false}, _, {_, acc} -> - {false, acc} - - {:error, _}, _, {_, acc} -> - {false, acc} - end - } + case {action, map_reduce(entries, adapter_meta, action, [opts], Keyword.get(opts, :timeout))} do + {:put_all, {_res, []}} -> + {:ok, true} - entries - |> map_reduce( - adapter_meta, - action, - [opts], - Keyword.get(opts, :timeout), - reducer - ) - |> case do - {true, _} -> - true + {:put_new_all, {res, []}} -> + {:ok, Enum.reduce(res, true, &(&1 and &2))} - {false, keys} -> - :ok = Enum.each(keys, &delete(adapter_meta, &1, [])) - action == :put_all + {_, {_ok, errors}} -> + wrap_error Nebulex.Error, reason: {:rpc_multicall_error, errors}, module: RPC end end @@ -547,12 +494,14 @@ defmodule Nebulex.Adapters.Partitioned do @impl true defspan take(adapter_meta, key, opts) do - call(adapter_meta, key, :take, [key, opts], opts) + adapter_meta + |> call(key, :take, [key, opts], opts) + |> handle_key_error(adapter_meta.name) end @impl true - defspan has_key?(adapter_meta, key) do - call(adapter_meta, key, :has_key?, [key]) + defspan exists?(adapter_meta, key, opts) do + call(adapter_meta, key, :exists?, [key, opts]) end @impl true @@ -561,18 +510,20 @@ defmodule Nebulex.Adapters.Partitioned do end @impl true - defspan ttl(adapter_meta, key) do - call(adapter_meta, key, :ttl, [key]) + defspan ttl(adapter_meta, key, opts) do + adapter_meta + |> call(key, :ttl, [key, opts]) + |> handle_key_error(adapter_meta.name) end @impl true - defspan expire(adapter_meta, key, ttl) do - call(adapter_meta, key, :expire, [key, ttl]) + defspan expire(adapter_meta, key, ttl, opts) do + call(adapter_meta, key, :expire, [key, ttl, opts]) end @impl true - defspan touch(adapter_meta, key) do - call(adapter_meta, key, :touch, [key]) + defspan touch(adapter_meta, key, opts) do + call(adapter_meta, key, :touch, [key, opts]) end ## Nebulex.Adapter.Queryable @@ -585,19 +536,21 @@ defmodule Nebulex.Adapters.Partitioned do _ -> &Enum.sum/1 end - adapter_meta.task_sup - |> RPC.multi_call( - Cluster.get_nodes(adapter_meta.name), + adapter_meta.name + |> Cluster.get_nodes() + |> RPC.multicall( __MODULE__, :with_dynamic_cache, [adapter_meta, operation, [query, opts]], opts ) - |> handle_rpc_multi_call(operation, reducer) + |> handle_rpc_multicall(reducer) end @impl true defspan stream(adapter_meta, query, opts) do + timeout = opts[:timeout] || 5000 + Stream.resource( fn -> Cluster.get_nodes(adapter_meta.name) @@ -608,19 +561,19 @@ defmodule Nebulex.Adapters.Partitioned do [node | nodes] -> elements = - rpc_call( - adapter_meta.task_sup, - node, - __MODULE__, - :eval_stream, - [adapter_meta, query, opts], - opts - ) + unwrap_or_raise RPC.call( + node, + __MODULE__, + :eval_stream, + [adapter_meta, query, opts], + timeout + ) {elements, nodes} end, & &1 ) + |> wrap_ok() end ## Nebulex.Adapter.Persistence @@ -639,7 +592,9 @@ defmodule Nebulex.Adapters.Partitioned do @impl true defspan transaction(adapter_meta, opts, fun) do - super(adapter_meta, Keyword.put(opts, :nodes, Cluster.get_nodes(adapter_meta.name)), fun) + nodes = Keyword.put_new_lazy(opts, :nodes, fn -> Cluster.get_nodes(adapter_meta.name) end) + + super(adapter_meta, nodes, fun) end @impl true @@ -674,13 +629,21 @@ defmodule Nebulex.Adapters.Partitioned do Helper to perform `stream/3` locally. """ def eval_stream(meta, query, opts) do - meta - |> with_dynamic_cache(:stream, [query, opts]) - |> Enum.to_list() + with {:ok, stream} <- with_dynamic_cache(meta, :stream, [query, opts]) do + {:ok, Enum.to_list(stream)} + end end ## Private Functions + defp handle_key_error({:error, %Nebulex.KeyError{} = e}, name) do + {:error, %{e | cache: name}} + end + + defp handle_key_error(other, _name) do + other + end + defp get_node(%{name: name, keyslot: keyslot}, key) do Cluster.get_node(name, key, keyslot) end @@ -691,62 +654,39 @@ defmodule Nebulex.Adapters.Partitioned do |> rpc_call(adapter_meta, action, args, opts) end - defp rpc_call(node, %{task_sup: task_sup} = meta, fun, args, opts) do - rpc_call(task_sup, node, __MODULE__, :with_dynamic_cache, [meta, fun, args], opts) - end - - if Code.ensure_loaded?(:erpc) do - defp rpc_call(supervisor, node, mod, fun, args, opts) do - RPC.call(supervisor, node, mod, fun, args, opts[:timeout] || 5000) - end - else - defp rpc_call(supervisor, node, mod, fun, args, opts) do - case RPC.call(supervisor, node, mod, fun, args, opts[:timeout] || 5000) do - {:badrpc, remote_ex} -> - raise remote_ex - - response -> - response - end - end + defp rpc_call(node, meta, fun, args, opts) do + RPC.call(node, __MODULE__, :with_dynamic_cache, [meta, fun, args], opts[:timeout] || 5000) end defp group_keys_by_node(enum, adapter_meta) do Enum.reduce(enum, %{}, fn {key, _} = entry, acc -> node = get_node(adapter_meta, key) + Map.put(acc, node, [entry | Map.get(acc, node, [])]) key, acc -> node = get_node(adapter_meta, key) + Map.put(acc, node, [key | Map.get(acc, node, [])]) end) end - defp map_reduce( - enum, - %{task_sup: task_sup} = meta, - action, - args, - timeout, - reducer - ) do - groups = - enum - |> group_keys_by_node(meta) - |> Enum.map(fn {node, group} -> - {node, {__MODULE__, :with_dynamic_cache, [meta, action, [group | args]]}} - end) - - RPC.multi_call(task_sup, groups, timeout: timeout, reducer: reducer) + defp map_reduce(enum, meta, action, args, timeout) do + enum + |> group_keys_by_node(meta) + |> Enum.map(fn {node, group} -> + {node, {__MODULE__, :with_dynamic_cache, [meta, action, [group | args]]}} + end) + |> RPC.multicall(timeout: timeout) end - defp handle_rpc_multi_call({res, []}, _action, fun) do - fun.(res) + defp handle_rpc_multicall({res, []}, fun) do + {:ok, fun.(res)} end - defp handle_rpc_multi_call({responses, errors}, action, _) do - raise Nebulex.RPCMultiCallError, action: action, responses: responses, errors: errors + defp handle_rpc_multicall({_ok, errors}, _) do + wrap_error Nebulex.Error, reason: {:rpc_multicall_error, errors}, module: RPC end end @@ -759,9 +699,6 @@ defmodule Nebulex.Adapters.Partitioned.Bootstrap do alias Nebulex.Cache.Cluster alias Nebulex.Telemetry - # Default join timeout - @join_timeout :timer.seconds(180) - # State defstruct [:adapter_meta, :join_timeout] @@ -832,14 +769,7 @@ defmodule Nebulex.Adapters.Partitioned.Bootstrap do defp build_state(adapter_meta, opts) do # Join timeout to ensure it is always joined to the cluster - join_timeout = - get_option( - opts, - :join_timeout, - "an integer > 0", - &(is_integer(&1) and &1 > 0), - @join_timeout - ) + join_timeout = Keyword.fetch!(opts, :join_timeout) %__MODULE__{adapter_meta: adapter_meta, join_timeout: join_timeout} end diff --git a/lib/nebulex/adapters/partitioned/options.ex b/lib/nebulex/adapters/partitioned/options.ex new file mode 100644 index 00000000..eb81c59c --- /dev/null +++ b/lib/nebulex/adapters/partitioned/options.ex @@ -0,0 +1,39 @@ +defmodule Nebulex.Adapters.Partitioned.Options do + @moduledoc """ + Option definitions for the partitioned adapter. + """ + use Nebulex.Cache.Options + + definition = + [ + primary: [ + required: false, + type: :keyword_list, + doc: """ + The options that will be passed to the adapter associated with the + local primary storage. + """ + ], + keyslot: [ + required: false, + type: :atom, + doc: """ + Defines the module implementing `Nebulex.Adapter.Keyslot` behaviour. + """ + ], + join_timeout: [ + required: false, + type: :pos_integer, + default: :timer.seconds(180), + doc: """ + Interval time in milliseconds for joining the running partitioned cache + to the cluster. This is to ensure it is always joined. + """ + ] + ] ++ base_definition() + + @definition NimbleOptions.new!(definition) + + @doc false + def definition, do: @definition +end diff --git a/lib/nebulex/adapters/replicated.ex b/lib/nebulex/adapters/replicated.ex index 7502be76..534b331e 100644 --- a/lib/nebulex/adapters/replicated.ex +++ b/lib/nebulex/adapters/replicated.ex @@ -102,9 +102,6 @@ defmodule Nebulex.Adapters.Replicated do with the local primary storage. These options will depend on the local adapter to use. - * `:task_supervisor_opts` - Start-time options passed to - `Task.Supervisor.start_link/1` when the adapter is initialized. - ## Shared options Almost all of the cache functions outlined in `Nebulex.Cache` module @@ -322,6 +319,9 @@ defmodule Nebulex.Adapters.Replicated do @impl true def init(opts) do + # Validate options + opts = __MODULE__.Options.validate!(opts) + # Required options telemetry_prefix = Keyword.fetch!(opts, :telemetry_prefix) telemetry = Keyword.fetch!(opts, :telemetry) @@ -329,7 +329,7 @@ defmodule Nebulex.Adapters.Replicated do name = opts[:name] || cache # Maybe use stats - stats = get_boolean_option(opts, :stats) + stats = Keyword.fetch!(opts, :stats) # Primary cache options primary_opts = @@ -344,16 +344,12 @@ defmodule Nebulex.Adapters.Replicated do do: [name: normalize_module_name([name, Primary])] ++ primary_opts, else: primary_opts - # Maybe task supervisor for distributed tasks - {task_sup_name, children} = sup_child_spec(name, opts) - # Prepare metadata adapter_meta = %{ telemetry_prefix: telemetry_prefix, telemetry: telemetry, name: name, primary_name: primary_opts[:name], - task_sup: task_sup_name, stats: stats } @@ -365,36 +361,19 @@ defmodule Nebulex.Adapters.Replicated do children: [ {cache.__primary__, primary_opts}, {__MODULE__.Bootstrap, Map.put(adapter_meta, :cache, cache)} - | children ] ) {:ok, child_spec, adapter_meta} end - if Code.ensure_loaded?(:erpc) do - defp sup_child_spec(_name, _opts) do - {nil, []} - end - else - defp sup_child_spec(name, opts) do - # Task supervisor to execute parallel and/or remote commands - task_sup_name = normalize_module_name([name, TaskSupervisor]) - task_sup_opts = Keyword.get(opts, :task_supervisor_opts, []) - - children = [ - {Task.Supervisor, [name: task_sup_name] ++ task_sup_opts} - ] - - {task_sup_name, children} - end - end - ## Nebulex.Adapter.Entry @impl true - defspan get(adapter_meta, key, opts) do - with_dynamic_cache(adapter_meta, :get, [key, opts]) + defspan fetch(adapter_meta, key, opts) do + adapter_meta + |> with_dynamic_cache(:fetch, [key, opts]) + |> handle_key_error(adapter_meta.name) end @impl true @@ -405,8 +384,8 @@ defmodule Nebulex.Adapters.Replicated do @impl true defspan put(adapter_meta, key, value, _ttl, on_write, opts) do case with_transaction(adapter_meta, on_write, [key], [key, value, opts], opts) do - :ok -> true - bool -> bool + :ok -> {:ok, true} + other -> other end end @@ -415,7 +394,10 @@ defmodule Nebulex.Adapters.Replicated do action = if on_write == :put_new, do: :put_new_all, else: :put_all keys = for {k, _} <- entries, do: k - with_transaction(adapter_meta, action, keys, [entries, opts], opts) || action == :put_all + case with_transaction(adapter_meta, action, keys, [entries, opts], opts) do + :ok -> {:ok, true} + other -> other + end end @impl true @@ -425,7 +407,9 @@ defmodule Nebulex.Adapters.Replicated do @impl true defspan take(adapter_meta, key, opts) do - with_transaction(adapter_meta, :take, [key], [key, opts], opts) + adapter_meta + |> with_transaction(:take, [key], [key, opts], opts) + |> handle_key_error(adapter_meta.name) end @impl true @@ -434,23 +418,25 @@ defmodule Nebulex.Adapters.Replicated do end @impl true - defspan has_key?(adapter_meta, key) do - with_dynamic_cache(adapter_meta, :has_key?, [key]) + defspan exists?(adapter_meta, key, opts) do + with_dynamic_cache(adapter_meta, :exists?, [key, opts]) end @impl true - defspan ttl(adapter_meta, key) do - with_dynamic_cache(adapter_meta, :ttl, [key]) + defspan ttl(adapter_meta, key, opts) do + adapter_meta + |> with_dynamic_cache(:ttl, [key, opts]) + |> handle_key_error(adapter_meta.name) end @impl true - defspan expire(adapter_meta, key, ttl) do - with_transaction(adapter_meta, :expire, [key], [key, ttl]) + defspan expire(adapter_meta, key, ttl, opts) do + with_transaction(adapter_meta, :expire, [key], [key, ttl, opts]) end @impl true - defspan touch(adapter_meta, key) do - with_transaction(adapter_meta, :touch, [key], [key]) + defspan touch(adapter_meta, key, opts) do + with_transaction(adapter_meta, :touch, [key], [key, opts]) end ## Nebulex.Adapter.Queryable @@ -461,15 +447,17 @@ defmodule Nebulex.Adapters.Replicated do end defp do_execute(%{name: name} = adapter_meta, :delete_all, query, opts) do + nodes = Cluster.get_nodes(name) + # It is blocked until ongoing write operations finish (if there is any). # Similarly, while it is executed, all later write-like operations are # blocked until it finishes. :global.trans( {name, self()}, fn -> - multi_call(adapter_meta, :delete_all, [query, opts], opts) + multicall(adapter_meta, nodes, :delete_all, [query, opts], opts) end, - Cluster.get_nodes(name) + nodes ) end @@ -498,7 +486,9 @@ defmodule Nebulex.Adapters.Replicated do @impl true defspan transaction(adapter_meta, opts, fun) do - super(adapter_meta, Keyword.put(opts, :nodes, Cluster.get_nodes(adapter_meta.name)), fun) + nodes = Keyword.put_new_lazy(opts, :nodes, fn -> Cluster.get_nodes(adapter_meta.name) end) + + super(adapter_meta, nodes, fun) end @impl true @@ -531,6 +521,14 @@ defmodule Nebulex.Adapters.Replicated do ## Private Functions + defp handle_key_error({:error, %Nebulex.KeyError{} = e}, name) do + {:error, %{e | cache: name}} + end + + defp handle_key_error(other, _name) do + other + end + defp with_transaction(adapter_meta, action, keys, args, opts \\ []) do do_with_transaction(adapter_meta, action, keys, args, opts, 1) end @@ -543,7 +541,7 @@ defmodule Nebulex.Adapters.Replicated do # operations until it finishes. The other option would be trying to # lock the same key `:"$sync_lock"`, and then when the lock is acquired, # delete it before processing the write operation. But this means another - # global lock across the cluster every time there is a write. So for the + # global lock across the cluster everytime there is a write. So for the # time being, we just read the global table to validate it which is much # faster; since it is a local read with the global ETS, there is no global # locks across the cluster. @@ -558,58 +556,72 @@ defmodule Nebulex.Adapters.Replicated do # Write-like operation must be wrapped within a transaction # to ensure proper replication - transaction(adapter_meta, [keys: keys, nodes: nodes], fn -> - multi_call(adapter_meta, action, args, opts) - end) + with {:ok, res} <- + transaction(adapter_meta, [keys: keys, nodes: nodes], fn -> + multicall(adapter_meta, nodes, action, args, opts) + end) do + res + end end end - defp multi_call(%{name: name, task_sup: task_sup} = meta, action, args, opts) do - # Run the command locally first - local = with_dynamic_cache(meta, action, args) - - # Run the command on the remote nodes - {ok_nodes, error_nodes} = - RPC.multi_call( - task_sup, - Cluster.get_nodes(name) -- [node()], - __MODULE__, - :with_dynamic_cache, - [meta, action, args], - opts - ) + defp multicall(meta, nodes, action, args, opts) do + # Run the command locally first and run replication only if success + case with_dynamic_cache(meta, action, args) do + {:error, _} = error -> + error + + local -> + # Run the command on the remote nodes + {ok_nodes, error_nodes} = + RPC.multicall( + nodes -- [node()], + __MODULE__, + :with_dynamic_cache, + [meta, action, args], + opts + ) + + # Process the responses adding the local one as main result + handle_rpc_multicall({[local | ok_nodes], error_nodes}, meta, action) + end + end - # Process the responses adding the local one as source of truth - handle_rpc_multi_call({[local | ok_nodes], error_nodes}, meta, action) + defp handle_rpc_multicall({res, []}, _meta, _action) do + hd(res) end - defp handle_rpc_multi_call({res, []}, _meta, _action), do: hd(res) + defp handle_rpc_multicall({res, {[], ignored_errors}}, meta, action) do + _ = dispatch_replication_error(meta, action, ignored_errors) - defp handle_rpc_multi_call({res, {:sanitized, {[], rpc_errors}}}, meta, action) do - _ = dispatch_replication_error(meta, action, rpc_errors) hd(res) end - defp handle_rpc_multi_call({responses, {:sanitized, {errors, rpc_errors}}}, meta, action) do - _ = dispatch_replication_error(meta, action, rpc_errors) + defp handle_rpc_multicall({_responses, {filtered_errors, ignored_errors}}, meta, action) do + _ = dispatch_replication_error(meta, action, ignored_errors) - raise Nebulex.RPCMultiCallError, action: action, responses: responses, errors: errors + wrap_error Nebulex.Error, reason: {:rpc_multicall_error, filtered_errors}, module: RPC end - defp handle_rpc_multi_call({responses, errors}, meta, action) do - handle_rpc_multi_call({responses, {:sanitized, sanitize_errors(errors)}}, meta, action) + defp handle_rpc_multicall({responses, errors}, meta, action) do + handle_rpc_multicall({responses, filter_errors(errors)}, meta, action) end - defp sanitize_errors(errors) do + defp filter_errors(errors) do Enum.reduce(errors, {[], []}, fn - {{:error, {:exception, %Nebulex.RegistryLookupError{} = error, _}}, node}, {acc1, acc2} -> - # The cache was not found in the node, maybe it was stopped and - # "Process Groups" is not updated yet, then ignore the error - {acc1, [{node, error} | acc2]} + {_node, {:error, %Nebulex.KeyError{}}} = error, {acc1, acc2} -> + # The key was not found on remote node, ignore the error + {acc1, [error | acc2]} + + {_node, {:error, %Nebulex.Error{reason: {:registry_lookup_error, _}}}} = error, + {acc1, acc2} -> + # The cache was not found in the remote node, maybe it was stopped and + # :pg ("Process Groups") is not updated yet, then ignore the error + {acc1, [error | acc2]} - {{:error, {:erpc, :noconnection}}, node}, {acc1, acc2} -> - # Remote node is down and maybe the "Process Groups" is not updated yet - {acc1, [{node, :noconnection} | acc2]} + {_node, {:error, {:erpc, :noconnection}}} = error, {acc1, acc2} -> + # Remote node is down, maybe :pg ("Process Groups") is not updated yet + {acc1, [error | acc2]} error, {acc1, acc2} -> {[error | acc1], acc2} @@ -710,11 +722,12 @@ defmodule Nebulex.Adapters.Replicated.Bootstrap do def handle_info(:timeout, %{name: name, retries: retries} = state) when retries < @max_retries do - Adapter.with_meta(name, fn _adapter, adapter_meta -> - handle_info(:timeout, adapter_meta) - end) - rescue - ArgumentError -> {:noreply, %{state | retries: retries + 1}, 1} + with {:error, _} <- + Adapter.with_meta(name, fn adapter_meta -> + handle_info(:timeout, adapter_meta) + end) do + {:noreply, %{state | retries: retries + 1}, 1} + end end def handle_info(:timeout, state) do @@ -724,9 +737,12 @@ defmodule Nebulex.Adapters.Replicated.Bootstrap do end @impl true - def terminate(_reason, state) do + def terminate(_reason, %{name: name}) do + # Delete global lock set when the server started + :ok = unlock(name) + # Ensure leaving the cluster when the cache stops - :ok = Cluster.leave(state.name) + :ok = Cluster.leave(name) end ## Helpers @@ -806,16 +822,18 @@ defmodule Nebulex.Adapters.Replicated.Bootstrap do defp stream_entries(meta, node, acc) do stream_fun = fn -> - meta - |> Replicated.stream(nil, return: :entry, page_size: 100) - |> Stream.filter(&(not Entry.expired?(&1))) - |> Stream.map(& &1) - |> Enum.to_list() + with {:ok, stream} <- Replicated.stream(meta, nil, return: :entry, page_size: 100) do + stream + |> Stream.filter(&(not Entry.expired?(&1))) + |> Stream.map(& &1) + |> Enum.to_list() + |> wrap_ok() + end end case :rpc.call(node, Kernel, :apply, [stream_fun, []]) do - {:badrpc, _} -> {:cont, acc} - entries -> {:halt, entries} + {:ok, entries} -> {:halt, entries} + _error -> {:cont, acc} end end diff --git a/lib/nebulex/adapters/replicated/options.ex b/lib/nebulex/adapters/replicated/options.ex new file mode 100644 index 00000000..ae00a74c --- /dev/null +++ b/lib/nebulex/adapters/replicated/options.ex @@ -0,0 +1,23 @@ +defmodule Nebulex.Adapters.Replicated.Options do + @moduledoc """ + Option definitions for the replicated adapter. + """ + use Nebulex.Cache.Options + + definition = + [ + primary: [ + required: false, + type: :keyword_list, + doc: """ + The options that will be passed to the adapter associated with the + local primary storage. + """ + ] + ] ++ base_definition() + + @definition NimbleOptions.new!(definition) + + @doc false + def definition, do: @definition +end diff --git a/lib/nebulex/adapters/supervisor.ex b/lib/nebulex/adapters/supervisor.ex index e1a3670d..65edc0e4 100644 --- a/lib/nebulex/adapters/supervisor.ex +++ b/lib/nebulex/adapters/supervisor.ex @@ -6,7 +6,7 @@ defmodule Nebulex.Adapters.Supervisor do Builds a supervisor spec with the given `options` for wrapping up the adapter's children. """ - @spec child_spec(Keyword.t()) :: Supervisor.child_spec() + @spec child_spec(keyword) :: Supervisor.child_spec() def child_spec(options) do {children, options} = Keyword.pop(options, :children, []) diff --git a/lib/nebulex/cache.ex b/lib/nebulex/cache.ex index f2113630..a8d428ef 100644 --- a/lib/nebulex/cache.ex +++ b/lib/nebulex/cache.ex @@ -53,6 +53,17 @@ defmodule Nebulex.Cache do implementing `Nebulex.Adapter.Stats` behaviour. See the "Stats" section below. + ## Shared options + + Almost all of the cache functions outlined in this module accept the following + options: + + * `:dynamic_cache` - The name of the cache supervisor process. The name is + either an atom or a PID. There might be cases where we want to have + different cache instances but access them through the same cache module. + This option tells the executed cache command what cache instance to use + dynamically in runtime. + ## Telemetry events Similar to Ecto or Phoenix, Nebulex also provides built-in Telemetry events @@ -64,7 +75,8 @@ defmodule Nebulex.Cache do * `[:nebulex, :cache, :init]` - it is dispatched whenever a cache starts. The measurement is a single `system_time` entry in native unit. The - metadata is the `:cache` and all initialization options under `:opts`. + metadata is the `:cache`, the `:name`, and all initialization options + under `:opts`. ### Adapter-specific events @@ -199,7 +211,7 @@ defmodule Nebulex.Cache do > Remember to check if the underlying adapter implements the `Nebulex.Adapter.Stats` behaviour. - See `c:Nebulex.Cache.stats/0` for more information. + See `c:Nebulex.Cache.stats/1` for more information. ## Dispatching stats via Telemetry @@ -271,38 +283,105 @@ defmodule Nebulex.Cache do @type entries :: map | [{key, value}] @typedoc "Cache action options" - @type opts :: Keyword.t() + @type opts :: keyword + + @typedoc "Proxy type for base Nebulex error" + @type nbx_error :: Nebulex.Error.t() + + @typedoc "Fetch error reasons" + @type fetch_error_reason :: Nebulex.KeyError.t() | nbx_error + + @typedoc "Query error reasons" + @type query_error_reason :: Nebulex.QueryError.t() | nbx_error + + @typedoc "Error tuple with common reasons" + @type error :: error(nbx_error) + + @typedoc "Error tuple type" + @type error(reason) :: {:error, reason} + + @typedoc "Ok/Error tuple with default error reasons" + @type ok_error_tuple(ok) :: ok_error_tuple(ok, nbx_error) + + @typedoc "Ok/Error tuple type" + @type ok_error_tuple(ok, error) :: {:ok, ok} | {:error, error} @doc false defmacro __using__(opts) do - quote bind_quoted: [opts: opts] do - @behaviour Nebulex.Cache + quote do + unquote(prelude(opts)) + unquote(base_defs()) + unquote(entry_defs()) - alias Nebulex.Cache.{ - Entry, - Persistence, - Queryable, - Stats, - Storage, - Transaction - } + if Nebulex.Adapter.Queryable in behaviours do + unquote(queryable_defs()) + end - alias Nebulex.Hook + if Nebulex.Adapter.Persistence in behaviours do + unquote(persistence_defs()) + end - {otp_app, adapter, behaviours} = Nebulex.Cache.Supervisor.compile_config(opts) + if Nebulex.Adapter.Transaction in behaviours do + unquote(transaction_defs()) + end + + if Nebulex.Adapter.Stats in behaviours do + unquote(stats_defs()) + end + end + end + + defp prelude(opts) do + quote do + @behaviour Nebulex.Cache + + {otp_app, adapter, behaviours} = Nebulex.Cache.Supervisor.compile_config(unquote(opts)) @otp_app otp_app @adapter adapter - @opts opts - @default_dynamic_cache opts[:default_dynamic_cache] || __MODULE__ - @default_key_generator opts[:default_key_generator] || Nebulex.Caching.SimpleKeyGenerator + @opts unquote(opts) + @default_dynamic_cache @opts[:default_dynamic_cache] || __MODULE__ + @default_key_generator @opts[:default_key_generator] || Nebulex.Caching.SimpleKeyGenerator @before_compile adapter + end + end + + defp base_defs do + quote do + ## Helpers + + @doc """ + Helper macro to resolve the dynamic cache. + """ + defmacro dynamic_cache(opts, do: block) do + quote do + {dynamic_cache, opts} = + __MODULE__.pop_first_lazy( + unquote(opts), + :dynamic_cache, + fn -> get_dynamic_cache() end + ) + + unquote(block) + end + end + + @doc """ + Custom convenience `pop_first_lazy/3` function. + """ + def pop_first_lazy(keyword, key, fun) do + case :lists.keytake(key, 1, keyword) do + {:value, {^key, value}, rest} -> {value, rest} + false -> {fun.(), keyword} + end + end ## Config and metadata @impl true def config do {:ok, config} = Nebulex.Cache.Supervisor.runtime_config(__MODULE__, @otp_app, []) + config end @@ -329,10 +408,13 @@ defmodule Nebulex.Cache do end @impl true - def stop(timeout \\ 5000) do - Supervisor.stop(get_dynamic_cache(), :normal, timeout) + def stop(opts \\ []) do + dynamic_cache opts do + Supervisor.stop(dynamic_cache, :normal, Keyword.get(opts, :timeout, 5000)) + end end + # Iniline common instructions @compile {:inline, get_dynamic_cache: 0} @impl true @@ -351,6 +433,7 @@ defmodule Nebulex.Cache do try do _ = put_dynamic_cache(name) + fun.() after _ = put_dynamic_cache(default_dynamic_cache) @@ -361,186 +444,295 @@ defmodule Nebulex.Cache do def with_dynamic_cache(name, module, fun, args) do with_dynamic_cache(name, fn -> apply(module, fun, args) end) end + end + end - ## Entry + defp entry_defs do + quote do + alias Nebulex.Cache.Entry + + @impl true + def fetch(key, opts \\ []) do + dynamic_cache opts, do: Entry.fetch(dynamic_cache, key, opts) + end + + @impl true + def fetch!(key, opts \\ []) do + dynamic_cache opts, do: Entry.fetch!(dynamic_cache, key, opts) + end @impl true - def get(key, opts \\ []) do - Entry.get(get_dynamic_cache(), key, opts) + def get(key, default \\ nil, opts \\ []) do + dynamic_cache opts, do: Entry.get(dynamic_cache, key, default, opts) end @impl true - def get!(key, opts \\ []) do - Entry.get!(get_dynamic_cache(), key, opts) + def get!(key, default \\ nil, opts \\ []) do + dynamic_cache opts, do: Entry.get!(dynamic_cache, key, default, opts) end @impl true def get_all(keys, opts \\ []) do - Entry.get_all(get_dynamic_cache(), keys, opts) + dynamic_cache opts, do: Entry.get_all(dynamic_cache, keys, opts) + end + + @impl true + def get_all!(keys, opts \\ []) do + dynamic_cache opts, do: Entry.get_all!(dynamic_cache, keys, opts) end @impl true def put(key, value, opts \\ []) do - Entry.put(get_dynamic_cache(), key, value, opts) + dynamic_cache opts, do: Entry.put(dynamic_cache, key, value, opts) + end + + @impl true + def put!(key, value, opts \\ []) do + dynamic_cache opts, do: Entry.put!(dynamic_cache, key, value, opts) end @impl true def put_new(key, value, opts \\ []) do - Entry.put_new(get_dynamic_cache(), key, value, opts) + dynamic_cache opts, do: Entry.put_new(dynamic_cache, key, value, opts) end @impl true def put_new!(key, value, opts \\ []) do - Entry.put_new!(get_dynamic_cache(), key, value, opts) + dynamic_cache opts, do: Entry.put_new!(dynamic_cache, key, value, opts) end @impl true def replace(key, value, opts \\ []) do - Entry.replace(get_dynamic_cache(), key, value, opts) + dynamic_cache opts, do: Entry.replace(dynamic_cache, key, value, opts) end @impl true def replace!(key, value, opts \\ []) do - Entry.replace!(get_dynamic_cache(), key, value, opts) + dynamic_cache opts, do: Entry.replace!(dynamic_cache, key, value, opts) end @impl true def put_all(entries, opts \\ []) do - Entry.put_all(get_dynamic_cache(), entries, opts) + dynamic_cache opts, do: Entry.put_all(dynamic_cache, entries, opts) + end + + @impl true + def put_all!(entries, opts \\ []) do + dynamic_cache opts, do: Entry.put_all!(dynamic_cache, entries, opts) end @impl true def put_new_all(entries, opts \\ []) do - Entry.put_new_all(get_dynamic_cache(), entries, opts) + dynamic_cache opts, do: Entry.put_new_all(dynamic_cache, entries, opts) + end + + @impl true + def put_new_all!(entries, opts \\ []) do + dynamic_cache opts, do: Entry.put_new_all!(dynamic_cache, entries, opts) end @impl true def delete(key, opts \\ []) do - Entry.delete(get_dynamic_cache(), key, opts) + dynamic_cache opts, do: Entry.delete(dynamic_cache, key, opts) + end + + @impl true + def delete!(key, opts \\ []) do + dynamic_cache opts, do: Entry.delete!(dynamic_cache, key, opts) end @impl true def take(key, opts \\ []) do - Entry.take(get_dynamic_cache(), key, opts) + dynamic_cache opts, do: Entry.take(dynamic_cache, key, opts) end @impl true def take!(key, opts \\ []) do - Entry.take!(get_dynamic_cache(), key, opts) + dynamic_cache opts, do: Entry.take!(dynamic_cache, key, opts) end @impl true - def has_key?(key) do - Entry.has_key?(get_dynamic_cache(), key) + def exists?(key, opts \\ []) do + dynamic_cache opts, do: Entry.exists?(dynamic_cache, key, opts) end @impl true def get_and_update(key, fun, opts \\ []) do - Entry.get_and_update(get_dynamic_cache(), key, fun, opts) + dynamic_cache opts, do: Entry.get_and_update(dynamic_cache, key, fun, opts) + end + + @impl true + def get_and_update!(key, fun, opts \\ []) do + dynamic_cache opts, do: Entry.get_and_update!(dynamic_cache, key, fun, opts) end @impl true def update(key, initial, fun, opts \\ []) do - Entry.update(get_dynamic_cache(), key, initial, fun, opts) + dynamic_cache opts, do: Entry.update(dynamic_cache, key, initial, fun, opts) + end + + @impl true + def update!(key, initial, fun, opts \\ []) do + dynamic_cache opts, do: Entry.update!(get_dynamic_cache(), key, initial, fun, opts) end @impl true def incr(key, amount \\ 1, opts \\ []) do - Entry.incr(get_dynamic_cache(), key, amount, opts) + dynamic_cache opts, do: Entry.incr(dynamic_cache, key, amount, opts) + end + + @impl true + def incr!(key, amount \\ 1, opts \\ []) do + dynamic_cache opts, do: Entry.incr!(dynamic_cache, key, amount, opts) end @impl true def decr(key, amount \\ 1, opts \\ []) do - Entry.decr(get_dynamic_cache(), key, amount, opts) + dynamic_cache opts, do: Entry.decr(dynamic_cache, key, amount, opts) end @impl true - def ttl(key) do - Entry.ttl(get_dynamic_cache(), key) + def decr!(key, amount \\ 1, opts \\ []) do + dynamic_cache opts, do: Entry.decr!(dynamic_cache, key, amount, opts) end @impl true - def expire(key, ttl) do - Entry.expire(get_dynamic_cache(), key, ttl) + def ttl(key, opts \\ []) do + dynamic_cache opts, do: Entry.ttl(dynamic_cache, key, opts) end @impl true - def touch(key) do - Entry.touch(get_dynamic_cache(), key) + def ttl!(key, opts \\ []) do + dynamic_cache opts, do: Entry.ttl!(dynamic_cache, key, opts) end - ## Queryable + @impl true + def expire(key, ttl, opts \\ []) do + dynamic_cache opts, do: Entry.expire(dynamic_cache, key, ttl, opts) + end - if Nebulex.Adapter.Queryable in behaviours do - @impl true - def all(query \\ nil, opts \\ []) do - Queryable.all(get_dynamic_cache(), query, opts) - end + @impl true + def expire!(key, ttl, opts \\ []) do + dynamic_cache opts, do: Entry.expire!(dynamic_cache, key, ttl, opts) + end - @impl true - def count_all(query \\ nil, opts \\ []) do - Queryable.count_all(get_dynamic_cache(), query, opts) - end + @impl true + def touch(key, opts \\ []) do + dynamic_cache opts, do: Entry.touch(dynamic_cache, key, opts) + end - @impl true - def delete_all(query \\ nil, opts \\ []) do - Queryable.delete_all(get_dynamic_cache(), query, opts) - end + @impl true + def touch!(key, opts \\ []) do + dynamic_cache opts, do: Entry.touch!(dynamic_cache, key, opts) + end + end + end - @impl true - def stream(query \\ nil, opts \\ []) do - Queryable.stream(get_dynamic_cache(), query, opts) - end + defp queryable_defs do + quote do + alias Nebulex.Cache.Queryable + + @impl true + def all(query \\ nil, opts \\ []) do + dynamic_cache opts, do: Queryable.all(dynamic_cache, query, opts) + end - ## Deprecated functions (for backwards compatibility) + @impl true + def all!(query \\ nil, opts \\ []) do + dynamic_cache opts, do: Queryable.all!(dynamic_cache, query, opts) + end - @impl true - defdelegate size, to: __MODULE__, as: :count_all + @impl true + def count_all(query \\ nil, opts \\ []) do + dynamic_cache opts, do: Queryable.count_all(dynamic_cache, query, opts) + end - @impl true - defdelegate flush, to: __MODULE__, as: :delete_all + @impl true + def count_all!(query \\ nil, opts \\ []) do + dynamic_cache opts, do: Queryable.count_all!(dynamic_cache, query, opts) end - ## Persistence + @impl true + def delete_all(query \\ nil, opts \\ []) do + dynamic_cache opts, do: Queryable.delete_all(dynamic_cache, query, opts) + end - if Nebulex.Adapter.Persistence in behaviours do - @impl true - def dump(path, opts \\ []) do - Persistence.dump(get_dynamic_cache(), path, opts) - end + @impl true + def delete_all!(query \\ nil, opts \\ []) do + dynamic_cache opts, do: Queryable.delete_all!(dynamic_cache, query, opts) + end - @impl true - def load(path, opts \\ []) do - Persistence.load(get_dynamic_cache(), path, opts) - end + @impl true + def stream(query \\ nil, opts \\ []) do + dynamic_cache opts, do: Queryable.stream(dynamic_cache, query, opts) end - ## Transactions + @impl true + def stream!(query \\ nil, opts \\ []) do + dynamic_cache opts, do: Queryable.stream!(dynamic_cache, query, opts) + end + end + end - if Nebulex.Adapter.Transaction in behaviours do - @impl true - def transaction(opts \\ [], fun) do - Transaction.transaction(get_dynamic_cache(), opts, fun) - end + defp persistence_defs do + quote do + alias Nebulex.Cache.Persistence - @impl true - def in_transaction? do - Transaction.in_transaction?(get_dynamic_cache()) - end + @impl true + def dump(path, opts \\ []) do + dynamic_cache opts, do: Persistence.dump(dynamic_cache, path, opts) end - ## Stats + @impl true + def dump!(path, opts \\ []) do + dynamic_cache opts, do: Persistence.dump!(dynamic_cache, path, opts) + end - if Nebulex.Adapter.Stats in behaviours do - @impl true - def stats do - Stats.stats(get_dynamic_cache()) - end + @impl true + def load(path, opts \\ []) do + dynamic_cache opts, do: Persistence.load(dynamic_cache, path, opts) + end - @impl true - def dispatch_stats(opts \\ []) do - Stats.dispatch_stats(get_dynamic_cache(), opts) - end + @impl true + def load!(path, opts \\ []) do + dynamic_cache opts, do: Persistence.load!(dynamic_cache, path, opts) + end + end + end + + defp transaction_defs do + quote do + alias Nebulex.Cache.Transaction + + @impl true + def transaction(opts \\ [], fun) do + dynamic_cache opts, do: Transaction.transaction(dynamic_cache, opts, fun) + end + + @impl true + def in_transaction?(opts \\ []) do + dynamic_cache opts, do: Transaction.in_transaction?(dynamic_cache) + end + end + end + + defp stats_defs do + quote do + alias Nebulex.Cache.Stats + + @impl true + def stats(opts \\ []) do + dynamic_cache opts, do: Stats.stats(dynamic_cache) + end + + @impl true + def stats!(opts \\ []) do + dynamic_cache opts, do: Stats.stats!(dynamic_cache) + end + + @impl true + def dispatch_stats(opts \\ []) do + dynamic_cache opts, do: Stats.dispatch_stats(dynamic_cache, opts) end end end @@ -552,13 +744,15 @@ defmodule Nebulex.Cache do @doc """ A callback executed when the cache starts or when configuration is read. """ - @callback init(config :: Keyword.t()) :: {:ok, Keyword.t()} | :ignore + @doc group: "User callbacks" + @callback init(config :: keyword) :: {:ok, keyword} | :ignore ## Nebulex.Adapter @doc """ Returns the adapter tied to the cache. """ + @doc group: "Runtime API" @callback __adapter__ :: Nebulex.Adapter.t() @doc """ @@ -575,6 +769,7 @@ defmodule Nebulex.Cache do See `Nebulex.Caching.Decorators` and `Nebulex.Caching.KeyGenerator` for more information. """ + @doc group: "Runtime API" @callback __default_key_generator__ :: Nebulex.Caching.KeyGenerator.t() @doc """ @@ -582,7 +777,8 @@ defmodule Nebulex.Cache do If the `c:init/1` callback is implemented in the cache, it will be invoked. """ - @callback config() :: Keyword.t() + @doc group: "Runtime API" + @callback config() :: keyword @doc """ Starts a supervision and return `{:ok, pid}` or just `:ok` if nothing @@ -596,6 +792,7 @@ defmodule Nebulex.Cache do See the configuration in the moduledoc for options shared between adapters, for adapter-specific configuration see the adapter's documentation. """ + @doc group: "Runtime API" @callback start_link(opts) :: {:ok, pid} | {:error, {:already_started, pid}} @@ -603,8 +800,17 @@ defmodule Nebulex.Cache do @doc """ Shuts down the cache. + + ## Options + + `:timeout` - It is an integer that specifies how many milliseconds to wait + for the cache supervisor process to terminate, or the atom `:infinity` to + wait indefinitely. Defaults to `5000`. See `Supervisor.stop/3`. + + See the "Shared options" section at the module documentation for more options. """ - @callback stop(timeout) :: :ok + @doc group: "Runtime API" + @callback stop(opts) :: :ok @doc """ Returns the atom name or pid of the current cache @@ -612,14 +818,15 @@ defmodule Nebulex.Cache do See also `c:put_dynamic_cache/1`. """ - @callback get_dynamic_cache() :: atom() | pid() + @doc group: "Runtime API" + @callback get_dynamic_cache() :: atom | pid @doc """ Sets the dynamic cache to be used in further commands (based on Ecto dynamic repo). There might be cases where we want to have different cache instances but - accessing them through the same cache module. By default, when you call + access them through the same cache module. By default, when you call `MyApp.Cache.start_link/1`, it will start a cache with the name `MyApp.Cache`. But it is also possible to start multiple caches by using a different name for each of them: @@ -650,7 +857,8 @@ defmodule Nebulex.Cache do From this moment on, all future commands performed by the current process will run on `:another_cache_name`. """ - @callback put_dynamic_cache(atom() | pid()) :: atom() | pid() + @doc group: "Runtime API" + @callback put_dynamic_cache(atom | pid) :: atom | pid @doc """ Invokes the given function `fun` for the dynamic cache `name_or_pid`. @@ -663,7 +871,8 @@ defmodule Nebulex.Cache do See `c:get_dynamic_cache/0` and `c:put_dynamic_cache/1`. """ - @callback with_dynamic_cache(name_or_pid :: atom() | pid(), fun) :: term + @doc group: "Runtime API" + @callback with_dynamic_cache(name_or_pid :: atom | pid, fun) :: term @doc """ For the dynamic cache `name_or_pid`, invokes the given function name `fun` @@ -675,8 +884,9 @@ defmodule Nebulex.Cache do See `c:get_dynamic_cache/0` and `c:put_dynamic_cache/1`. """ + @doc group: "Runtime API" @callback with_dynamic_cache( - name_or_pid :: atom() | pid(), + name_or_pid :: atom | pid, module, fun :: atom, args :: [term] @@ -685,9 +895,16 @@ defmodule Nebulex.Cache do ## Nebulex.Adapter.Entry @doc """ - Gets a value from Cache where the key matches the given `key`. + Fetches the value for a specific `key` in the cache. - Returns `nil` if no result was found. + If the cache contains the given `key`, then its value is returned + in the shape of `{:ok, value}`. + + If the cache does not contain `key`, `{:error, Nebulex.KeyError.t()}` + is returned. + + Returns `{:error, Nebulex.Error.t()}` if any other error occurs while + executing the command. ## Options @@ -698,17 +915,34 @@ defmodule Nebulex.Cache do iex> MyCache.put("foo", "bar") :ok - iex> MyCache.get("foo") - "bar" + iex> MyCache.fetch("foo") + {:ok, "bar"} - iex> MyCache.get(:non_existent_key) - nil + iex> {:error, %Nebulex.KeyError{key: "bar"}} = MyCache.fetch("bar") + _error """ - @callback get(key, opts) :: value + @doc group: "Entry API" + @callback fetch(key, opts) :: ok_error_tuple(value, fetch_error_reason) + + @doc """ + Same as `c:fetch/2` but raises `Nebulex.KeyError` if the cache doesn't + contain `key`, or `Nebulex.Error` if any other error occurs while executing + the command. + """ + @doc group: "Entry API" + @callback fetch!(key, opts) :: value @doc """ - Similar to `c:get/2` but raises `KeyError` if `key` is not found. + Gets a value from cache where the key matches the given `key`. + + If the cache contains the given `key`, then its value is returned + in the shape of `{:ok, value}`. + + If the cache does not contain `key`, `{:ok, default}` is returned. + + Returns `{:error, Nebulex.Error.t()}` if an error occurs while + executing the command. ## Options @@ -716,16 +950,34 @@ defmodule Nebulex.Cache do ## Example - MyCache.get!(:a) + iex> MyCache.put("foo", "bar") + :ok + + iex> MyCache.get("foo") + {:ok, "bar"} + + iex> MyCache.get(:inexistent) + {:ok, nil} + + iex> MyCache.get(:inexistent, :default) + {:ok, :default} """ - @callback get!(key, opts) :: value + @doc group: "Entry API" + @callback get(key, default :: value, opts) :: ok_error_tuple(value) @doc """ - Returns a `map` with all the key-value pairs in the Cache where the key - is in `keys`. + Same as `c:get/3` but raises an exception if an error occurs. + """ + @doc group: "Entry API" + @callback get!(key, default :: value, opts) :: value - If `keys` contains keys that are not in the Cache, they're simply ignored. + @doc """ + Returns a map in the shape of `{:ok, map}` with the key-value pairs of all + specified `keys`. For every key that does not hold a value or does not exist, + it is ignored and not added into the returned map. + + Returns `{:error, reason}` if an error occurs while executing the command. ## Options @@ -737,10 +989,17 @@ defmodule Nebulex.Cache do :ok iex> MyCache.get_all([:a, :b, :c]) - %{a: 1, c: 3} + {:ok, %{a: 1, c: 3}} + + """ + @doc group: "Entry API" + @callback get_all(keys :: [key], opts) :: ok_error_tuple(map) + @doc """ + Same as `c:get_all/2` but raises an exception if an error occurs. """ - @callback get_all(keys :: [key], opts) :: map + @doc group: "Entry API" + @callback get_all!(keys :: [key], opts) :: map @doc """ Puts the given `value` under `key` into the Cache. @@ -749,6 +1008,8 @@ defmodule Nebulex.Cache do time to live associated with the key is discarded on successful `put` operation. + Returns `:ok` if successful, or `{:error, reason}` if an error occurs. + ## Options * `:ttl` - (positive integer or `:infinity`) Defines the time-to-live @@ -762,18 +1023,11 @@ defmodule Nebulex.Cache do iex> MyCache.put("foo", "bar") :ok - If the value is nil, then it is not stored (operation is skipped): - - iex> MyCache.put("foo", nil) - :ok - - Put key with time-to-live: + Putting entries with specific time-to-live: iex> MyCache.put("foo", "bar", ttl: 10_000) :ok - Using Nebulex.Time for TTL: - iex> MyCache.put("foo", "bar", ttl: :timer.hours(1)) :ok @@ -784,12 +1038,21 @@ defmodule Nebulex.Cache do :ok """ - @callback put(key, value, opts) :: :ok + @doc group: "Entry API" + @callback put(key, value, opts) :: :ok | error @doc """ - Puts the given `entries` (key/value pairs) into the Cache. It replaces + Same as `c:put/3` but raises an exception if an error occurs. + """ + @doc group: "Entry API" + @callback put!(key, value, opts) :: :ok + + @doc """ + Puts the given `entries` (key/value pairs) into the cache. It replaces existing values with new values (just as regular `put`). + Returns `:ok` if successful, or `{:error, reason}` if an error occurs. + ## Options * `:ttl` - (positive integer or `:infinity`) Defines the time-to-live @@ -806,18 +1069,28 @@ defmodule Nebulex.Cache do iex> MyCache.put_all(%{apples: 2, oranges: 1}, ttl: 10_000) :ok - Ideally, this operation should be atomic, so all given keys are put at once. - But it depends purely on the adapter's implementation and the backend used - internally by the adapter. Hence, it is recommended to review the adapter's - documentation. + **NOTE:** Ideally, this operation should be atomic, so all given keys are + put at once. But it depends purely on the adapter's implementation and the + backend used internally by the adapter. Hence, it is recommended to review + the adapter's documentation. + """ + @doc group: "Entry API" + @callback put_all(entries, opts) :: :ok | error + + @doc """ + Same as `c:put_all/2` but raises an exception if an error occurs. """ - @callback put_all(entries, opts) :: :ok + @doc group: "Entry API" + @callback put_all!(entries, opts) :: :ok @doc """ Puts the given `value` under `key` into the cache, only if it does not already exist. - Returns `true` if a value was set, otherwise, `false` is returned. + Returns `{:ok, true}` if a value was set, otherwise, `{:ok, false}` + is returned. + + Returns `{:error, reason}` if an error occurs. ## Options @@ -830,46 +1103,30 @@ defmodule Nebulex.Cache do ## Example iex> MyCache.put_new("foo", "bar") - true + {:ok, true} iex> MyCache.put_new("foo", "bar") - false - - If the value is nil, it is not stored (operation is skipped): - - iex> MyCache.put_new("other", nil) - true + {:ok, false} """ - @callback put_new(key, value, opts) :: boolean + @doc group: "Entry API" + @callback put_new(key, value, opts) :: ok_error_tuple(boolean) @doc """ - Similar to `c:put_new/3` but raises `Nebulex.KeyAlreadyExistsError` if the - key already exists. - - ## Options - - * `:ttl` - (positive integer or `:infinity`) Defines the time-to-live - (or expiry time) for the given key in **milliseconds**. Defaults - to `:infinity`. - - See the "Shared options" section at the module documentation for more options. - - ## Example - - iex> MyCache.put_new!("foo", "bar") - true - + Same as `c:put_new/3` but raises an exception if an error occurs. """ - @callback put_new!(key, value, opts) :: true + @doc group: "Entry API" + @callback put_new!(key, value, opts) :: boolean @doc """ Puts the given `entries` (key/value pairs) into the `cache`. It will not perform any operation at all even if just a single key already exists. - Returns `true` if all entries were successfully set. It returns `false` + Returns `{:ok, true}` if all entries were successfully set, or `{:ok, false}` if no key was set (at least one key already existed). + Returns `{:error, reason}` if an error occurs. + ## Options * `:ttl` - (positive integer or `:infinity`) Defines the time-to-live @@ -881,23 +1138,33 @@ defmodule Nebulex.Cache do ## Example iex> MyCache.put_new_all(apples: 3, bananas: 1) - true + {:ok, true} iex> MyCache.put_new_all(%{apples: 3, oranges: 1}, ttl: 10_000) - false + {:ok, false} - Ideally, this operation should be atomic, so all given keys are put at once. - But it depends purely on the adapter's implementation and the backend used - internally by the adapter. Hence, it is recommended to review the adapter's - documentation. + **NOTE:** Ideally, this operation should be atomic, so all given keys are + put at once. But it depends purely on the adapter's implementation and the + backend used internally by the adapter. Hence, it is recommended to review + the adapter's documentation. """ - @callback put_new_all(entries, opts) :: boolean + @doc group: "Entry API" + @callback put_new_all(entries, opts) :: ok_error_tuple(boolean) + + @doc """ + Same as `c:put_new_all/2` but raises an exception if an error occurs. + """ + @doc group: "Entry API" + @callback put_new_all!(entries, opts) :: boolean @doc """ Alters the entry stored under `key`, but only if the entry already exists into the Cache. - Returns `true` if a value was set, otherwise, `false` is returned. + Returns `{:ok, true}` if a value was set, otherwise, `{:ok, false}` + is returned. + + Returns `{:error, reason}` if an error occurs. ## Options @@ -910,43 +1177,33 @@ defmodule Nebulex.Cache do ## Example iex> MyCache.replace("foo", "bar") - false + {:ok, false} iex> MyCache.put_new("foo", "bar") - true + {:ok, true} iex> MyCache.replace("foo", "bar2") - true + {:ok, true} Update current value and TTL: iex> MyCache.replace("foo", "bar3", ttl: 10_000) - true + {:ok, true} """ - @callback replace(key, value, opts) :: boolean + @doc group: "Entry API" + @callback replace(key, value, opts) :: ok_error_tuple(boolean) @doc """ - Similar to `c:replace/3` but raises `KeyError` if `key` is not found. - - ## Options - - * `:ttl` - (positive integer or `:infinity`) Defines the time-to-live - (or expiry time) for the given key in **milliseconds**. Defaults - to `:infinity`. - - See the "Shared options" section at the module documentation for more options. - - ## Example - - iex> MyCache.replace!("foo", "bar") - true - + Same as `c:replace/3` but raises an exception if an error occurs. """ - @callback replace!(key, value, opts) :: true + @doc group: "Entry API" + @callback replace!(key, value, opts) :: boolean @doc """ - Deletes the entry in Cache for a specific `key`. + Deletes the entry in cache for a specific `key`. + + Returns `{:error, reason}` if an error occurs. ## Options @@ -960,153 +1217,95 @@ defmodule Nebulex.Cache do iex> MyCache.delete(:a) :ok - iex> MyCache.get(:a) + iex> MyCache.get!(:a) nil - iex> MyCache.delete(:non_existent_key) + iex> MyCache.delete(:inexistent) :ok """ - @callback delete(key, opts) :: :ok + @doc group: "Entry API" + @callback delete(key, opts) :: :ok | error @doc """ - Returns and removes the value associated with `key` in the Cache. - If the `key` does not exist, then `nil` is returned. - - ## Options - - See the "Shared options" section at the module documentation for more options. - - ## Examples - - iex> MyCache.put(:a, 1) - :ok - - iex> MyCache.take(:a) - 1 - - iex> MyCache.take(:a) - nil - + Same as `c:delete/2` but raises an exception if an error occurs. """ - @callback take(key, opts) :: value + @doc group: "Entry API" + @callback delete!(key, opts) :: :ok @doc """ - Similar to `c:take/2` but raises `KeyError` if `key` is not found. + Removes and returns the value associated with `key` in the cache. - ## Options - - See the "Shared options" section at the module documentation for more options. + If `key` is present in the cache, its value is removed and then returned + in the shape of `{:ok, value}`. - ## Example + If `key` is not present in the cache, `{:error, Nebulex.KeyError.t()}` + is returned. - MyCache.take!(:a) + Returns `{:error, Nebulex.Error.t()}` if any other error occurs while + executing the command. - """ - @callback take!(key, opts) :: value + ## Options - @doc """ - Returns whether the given `key` exists in the Cache. + See the "Shared options" section at the module documentation for more options. ## Examples iex> MyCache.put(:a, 1) :ok - iex> MyCache.has_key?(:a) - true + iex> MyCache.take(:a) + {:ok, 1} - iex> MyCache.has_key?(:b) - false + iex> {:error, %Nebulex.KeyError{key: :a}} = MyCache.take(:a) + _error """ - @callback has_key?(key) :: boolean + @doc group: "Entry API" + @callback take(key, opts) :: ok_error_tuple(value, fetch_error_reason) @doc """ - Gets the value from `key` and updates it, all in one pass. - - `fun` is called with the current cached value under `key` (or `nil` if `key` - hasn't been cached) and must return a two-element tuple: the current value - (the retrieved value, which can be operated on before being returned) and - the new value to be stored under `key`. `fun` may also return `:pop`, which - means the current value shall be removed from Cache and returned. - - The returned value is a tuple with the current value returned by `fun` and - the new updated value under `key`. - - ## Options - - * `:ttl` - (positive integer or `:infinity`) Defines the time-to-live - (or expiry time) for the given key in **milliseconds**. Defaults - to `:infinity`. - - See the "Shared options" section at the module documentation for more options. - - ## Examples - - Update nonexistent key: - - iex> MyCache.get_and_update(:a, fn current_value -> - ...> {current_value, "value!"} - ...> end) - {nil, "value!"} - - Update existing key: - - iex> MyCache.get_and_update(:a, fn current_value -> - ...> {current_value, "new value!"} - ...> end) - {"value!", "new value!"} - - Pop/remove value if exist: - - iex> MyCache.get_and_update(:a, fn _ -> :pop end) - {"new value!", nil} - - Pop/remove nonexistent key: - - iex> MyCache.get_and_update(:b, fn _ -> :pop end) - {nil, nil} - + Same as `c:take/2` but raises an exception if an error occurs. """ - @callback get_and_update(key, (value -> {current_value, new_value} | :pop), opts) :: - {current_value, new_value} - when current_value: value, new_value: value + @doc group: "Entry API" + @callback take!(key, opts) :: value @doc """ - Updates the cached `key` with the given function. + Determines if the cache contains an entry for the specified `key`. - If `key` is present in Cache with value `value`, `fun` is invoked with - argument `value` and its result is used as the new value of `key`. + More formally, returns `{:ok, true}` if the cache contains the given `key`. + If the cache doesn't contain `key`, `{:ok, :false}` is returned. - If `key` is not present in Cache, `initial` is inserted as the value of `key`. - The initial value will not be passed through the update function. + Returns `{:error, reason}` if an error occurs. ## Options - * `:ttl` - (positive integer or `:infinity`) Defines the time-to-live - (or expiry time) for the given key in **milliseconds**. Defaults - to `:infinity`. - See the "Shared options" section at the module documentation for more options. ## Examples - iex> MyCache.update(:a, 1, &(&1 * 2)) - 1 + iex> MyCache.put(:a, 1) + :ok - iex> MyCache.update(:a, 1, &(&1 * 2)) - 2 + iex> MyCache.exists?(:a) + {:ok, true} + + iex> MyCache.exists?(:b) + {:ok, false} """ - @callback update(key, initial :: value, (value -> value), opts) :: value + @doc group: "Entry API" + @callback exists?(key, opts) :: ok_error_tuple(boolean) @doc """ - Increments the counter stored at `key` by the given `amount`. + Increments the counter stored at `key` by the given `amount`, and returns + the current count in the shape of `{:ok, count}`. If `amount < 0` (negative), the value is decremented by that `amount` instead. + Returns `{:error, reason}` if an error occurs. + ## Options * `:ttl` - (positive integer or `:infinity`) Defines the time-to-live @@ -1122,26 +1321,36 @@ defmodule Nebulex.Cache do ## Examples iex> MyCache.incr(:a) - 1 + {:ok, 1} iex> MyCache.incr(:a, 2) - 3 + {:ok, 3} iex> MyCache.incr(:a, -1) - 2 + {:ok, 2} iex> MyCache.incr(:missing_key, 2, default: 10) - 12 + {:ok, 12} + + """ + @doc group: "Entry API" + @callback incr(key, amount :: integer, opts) :: ok_error_tuple(integer) + @doc """ + Same as `c:incr/3` but raises an exception if an error occurs. """ - @callback incr(key, amount :: integer, opts) :: integer + @doc group: "Entry API" + @callback incr!(key, amount :: integer, opts) :: integer @doc """ - Decrements the counter stored at `key` by the given `amount`. + Decrements the counter stored at `key` by the given `amount`, and returns + the current count in the shape of `{:ok, count}`. If `amount < 0` (negative), the value is incremented by that `amount` instead (opposite to `incr/3`). + Returns `{:error, reason}` if an error occurs. + ## Options * `:ttl` - (positive integer or `:infinity`) Defines the time-to-live @@ -1157,23 +1366,42 @@ defmodule Nebulex.Cache do ## Examples iex> MyCache.decr(:a) - -1 + {:ok, -1} iex> MyCache.decr(:a, 2) - -3 + {:ok, -3} iex> MyCache.decr(:a, -1) - -2 + {:ok, -2} iex> MyCache.decr(:missing_key, 2, default: 10) - 8 + {:ok, 8} + + """ + @doc group: "Entry API" + @callback decr(key, amount :: integer, opts) :: ok_error_tuple(integer) + @doc """ + Same as `c:decr/3` but raises an exception if an error occurs. """ - @callback decr(key, amount :: integer, opts) :: integer + @doc group: "Entry API" + @callback decr!(key, amount :: integer, opts) :: integer @doc """ - Returns the remaining time-to-live for the given `key`. If the `key` does not - exist, then `nil` is returned. + Returns the remaining time-to-live for the given `key`. + + If `key` is present in the cache, then its remaining TTL is returned + in the shape of `{:ok, ttl}`. + + If `key` is not present in the cache, `{:error, Nebulex.KeyError.t()}` + is returned. + + Returns `{:error, Nebulex.Error.t()}` if any other error occurs while + executing the command. + + ## Options + + See the "Shared options" section at the module documentation for more options. ## Examples @@ -1184,20 +1412,33 @@ defmodule Nebulex.Cache do :ok iex> MyCache.ttl(:a) - _remaining_ttl + {:ok, _remaining_ttl} iex> MyCache.ttl(:b) - :infinity + {:ok, :infinity} - iex> MyCache.ttl(:c) - nil + iex> {:error, %Nebulex.KeyError{key: :c}} = MyCache.ttl(:c) + _error """ - @callback ttl(key) :: timeout | nil + @doc group: "Entry API" + @callback ttl(key, opts) :: ok_error_tuple(timeout, fetch_error_reason) @doc """ - Returns `true` if the given `key` exists and the new `ttl` was successfully - updated, otherwise, `false` is returned. + Same as `c:ttl/2` but raises an exception if an error occurs. + """ + @doc group: "Entry API" + @callback ttl!(key, opts) :: timeout + + @doc """ + Returns `{:ok, true}` if the given `key` exists and the new `ttl` was + successfully updated, otherwise, `{:ok, false}` is returned. + + Returns `{:error, reason}` if an error occurs. + + ## Options + + See the "Shared options" section at the module documentation for more options. ## Examples @@ -1205,20 +1446,33 @@ defmodule Nebulex.Cache do :ok iex> MyCache.expire(:a, 5) - true + {:ok, true} iex> MyCache.expire(:a, :infinity) - true + {:ok, true} - iex> MyCache.ttl(:b, 5) - false + iex> MyCache.expire(:b, 5) + {:ok, false} """ - @callback expire(key, ttl :: timeout) :: boolean + @doc group: "Entry API" + @callback expire(key, ttl :: timeout, opts) :: ok_error_tuple(boolean) @doc """ - Returns `true` if the given `key` exists and the last access time was - successfully updated, otherwise, `false` is returned. + Same as `c:expire/3` but raises an exception if an error occurs. + """ + @doc group: "Entry API" + @callback expire!(key, ttl :: timeout, opts) :: boolean + + @doc """ + Returns `{:ok, true}` if the given `key` exists and the last access time was + successfully updated, otherwise, `{:ok, false}` is returned. + + Returns `{:error, reason}` if an error occurs. + + ## Options + + See the "Shared options" section at the module documentation for more options. ## Examples @@ -1226,57 +1480,147 @@ defmodule Nebulex.Cache do :ok iex> MyCache.touch(:a) - true + {:ok, true} iex> MyCache.ttl(:b) - false + {:ok, false} """ - @callback touch(key) :: boolean + @doc group: "Entry API" + @callback touch(key, opts) :: ok_error_tuple(boolean) - ## Deprecated Callbacks + @doc """ + Same as `c:touch/2` but raises an exception if an error occurs. + """ + @doc group: "Entry API" + @callback touch!(key, opts) :: boolean @doc """ - Returns the total number of cached entries. + Gets the value from `key` and updates it, all in one pass. + + `fun` is called with the current cached value under `key` (or `nil` if `key` + hasn't been cached) and must return a two-element tuple: the current value + (the retrieved value, which can be operated on before being returned) and + the new value to be stored under `key`. `fun` may also return `:pop`, which + means the current value shall be removed from Cache and returned. + + This function returns: + + * `{:ok, {current_value, new_value}}` - The `current_value` is the current + cached value and `new_value` the updated one returned by `fun`. + + * `{:error, reason}` - an error occurred while executing the command. + + ## Options + + * `:ttl` - (positive integer or `:infinity`) Defines the time-to-live + (or expiry time) for the given key in **milliseconds**. Defaults + to `:infinity`. + + See the "Shared options" section at the module documentation for more options. ## Examples - iex> :ok = Enum.each(1..10, &MyCache.put(&1, &1)) - iex> MyCache.size() - 10 + Update nonexistent key: + + iex> MyCache.get_and_update(:a, fn current_value -> + ...> {current_value, "value!"} + ...> end) + {:ok, {nil, "value!"}} + + Update existing key: + + iex> MyCache.get_and_update(:a, fn current_value -> + ...> {current_value, "new value!"} + ...> end) + {:ok, {"value!", "new value!"}} + + Pop/remove value if exist: + + iex> MyCache.get_and_update(:a, fn _ -> :pop end) + {:ok, {"new value!", nil}} + + Pop/remove nonexistent key: + + iex> MyCache.get_and_update(:b, fn _ -> :pop end) + {:ok, {nil, nil}} - iex> :ok = Enum.each(1..5, &MyCache.delete(&1)) - iex> MyCache.size() - 5 + """ + @doc group: "Entry API" + @callback get_and_update(key, (value -> {current_value, new_value} | :pop), opts) :: + ok_error_tuple({current_value, new_value}) + when current_value: value, new_value: value + @doc """ + Same as `c:get_and_update/3` but raises an exception if an error occurs. """ - @doc deprecated: "Use count_all/2 instead" - @callback size() :: integer + @doc group: "Entry API" + @callback get_and_update!(key, (value -> {current_value, new_value} | :pop), opts) :: + {current_value, new_value} + when current_value: value, new_value: value @doc """ - Flushes the cache and returns the number of evicted keys. + Updates the cached `key` with the given function. + + If` key` is present in the cache, then the existing value is passed to `fun` + and its result is used as the updated value of `key`. If `key` is not present + in the cache, `default` is inserted as the value of `key`. The default value + will not be passed through the update function. + + This function returns: + + * `{:ok, value}` - The value associated to the given `key` has been updated. + + * `{:error, reason}` - an error occurred while executing the command. + + ## Options + + * `:ttl` - (positive integer or `:infinity`) Defines the time-to-live + (or expiry time) for the given key in **milliseconds**. Defaults + to `:infinity`. + + See the "Shared options" section at the module documentation for more options. ## Examples - iex> :ok = Enum.each(1..5, &MyCache.put(&1, &1)) - iex> MyCache.flush() - 5 + iex> MyCache.update(:a, 1, &(&1 * 2)) + {:ok, 1} + + iex> MyCache.update(:a, 1, &(&1 * 2)) + {:ok, 2} - iex> MyCache.size() - 0 + """ + @doc group: "Entry API" + @callback update(key, initial :: value, (value -> value), opts) :: ok_error_tuple(value) + @doc """ + Same as `c:update/4` but raises an exception if an error occurs. """ - @doc deprecated: "Use delete_all/2 instead" - @callback flush() :: integer + @doc group: "Entry API" + @callback update!(key, initial :: value, (value -> value), opts) :: value ## Nebulex.Adapter.Queryable - @optional_callbacks all: 2, count_all: 2, delete_all: 2, stream: 2 + @optional_callbacks all: 2, + all!: 2, + count_all: 2, + count_all!: 2, + delete_all: 2, + delete_all!: 2, + stream: 2, + stream!: 2 @doc """ Fetches all entries from cache matching the given `query`. - May raise `Nebulex.QueryError` if query validation fails. + This function returns: + + * `{:ok, matched_entries}` - the query is valid, then it is executed + and the matched entries are returned. + + * `{:error, Nebulex.QueryError.t()}` - the query validation failed. + + * `{:error, reason}` - an error occurred while executing the command. ## Query values @@ -1287,14 +1631,15 @@ defmodule Nebulex.Cache do The following query values are shared and/or supported for all adapters: - * `nil` - Returns a list with all cached entries based on the `:return` - option. + * `nil` - Matches all entries cached entries. In case of `c:all/2` + for example, it returns a list with all cached entries based on + the `:return` option. ### Adapter-specific queries The `query` value depends entirely on the adapter implementation; it could any term. Therefore, it is highly recommended to see adapters' documentation - for more information about building queries. For example, the built-in + for more information about supported queries. For example, the built-in `Nebulex.Adapters.Local` adapter uses `:ets.match_spec()` for queries, as well as other pre-defined ones like `:unexpired` and `:expired`. @@ -1330,24 +1675,24 @@ defmodule Nebulex.Cache do Fetch all (with default params): iex> MyCache.all() - [1, 2, 3, 4, 5] + {:ok, [1, 2, 3, 4, 5]} Fetch all entries and return values: iex> MyCache.all(nil, return: :value) - [2, 4, 6, 8, 10] + {:ok, [2, 4, 6, 8, 10]} Fetch all entries and return them as key/value pairs: iex> MyCache.all(nil, return: {:key, :value}) - [{1, 2}, {2, 4}, {3, 6}, {4, 8}, {5, 10}] + {:ok, [{1, 2}, {2, 4}, {3, 6}, {4, 8}, {5, 10}]} Fetch all entries that match with the given query assuming we are using `Nebulex.Adapters.Local` adapter: iex> query = [{{:_, :"$1", :"$2", :_, :_}, [{:>, :"$2", 5}], [:"$1"]}] iex> MyCache.all(query) - [3, 4, 5] + {:ok, [3, 4, 5]} ## Query @@ -1360,8 +1705,8 @@ defmodule Nebulex.Cache do Additional built-in queries for `Nebulex.Adapters.Local` adapter: - iex> unexpired = MyCache.all(:unexpired) - iex> expired = MyCache.all(:expired) + iex> {:ok, unexpired} = MyCache.all(:unexpired) + iex> {:ok, expired} = MyCache.all(:expired) If we are using Nebulex.Adapters.Local adapter, the stored entry tuple `{:entry, key, value, touched, ttl}`, then the match spec could be @@ -1372,7 +1717,7 @@ defmodule Nebulex.Cache do ...> [{:>, :"$2", 5}], [{{:"$1", :"$2"}}]} ...> ] iex> MyCache.all(spec) - [{3, 6}, {4, 8}, {5, 10}] + {:ok, [{3, 6}, {4, 8}, {5, 10}]} The same previous query but using `Ex2ms`: @@ -1385,19 +1730,29 @@ defmodule Nebulex.Cache do ...> end iex> MyCache.all(spec) - [{3, 6}, {4, 8}, {5, 10}] + {:ok, [{3, 6}, {4, 8}, {5, 10}]} + + """ + @doc group: "Query API" + @callback all(query :: term, opts) :: ok_error_tuple([term], query_error_reason) + @doc """ + Same as `c:all/2` but raises an exception if an error occurs. """ - @callback all(query :: term, opts) :: [any] + @doc group: "Query API" + @callback all!(query :: term, opts) :: [any] @doc """ Similar to `c:all/2` but returns a lazy enumerable that emits all entries from the cache matching the given `query`. - If `query` is `nil`, then all entries in cache match and are returned - when the stream is evaluated; based on the `:return` option. + This function returns: + + * `{:ok, Enum.t()}` - the query is valid, then the stream is returned. - May raise `Nebulex.QueryError` if query validation fails. + * `{:error, Nebulex.QueryError.t()}` - the query validation failed. + + * `{:error, reason}` - an error occurred while executing the command. ## Query values @@ -1439,23 +1794,26 @@ defmodule Nebulex.Cache do Stream all (with default params): - iex> MyCache.stream() |> Enum.to_list() + iex> {:ok, stream} = MyCache.stream() + iex> Enum.to_list(stream) [1, 2, 3, 4, 5] Stream all entries and return values: - iex> nil |> MyCache.stream(return: :value, page_size: 3) |> Enum.to_list() + iex> {:ok, stream} = MyCache.stream(nil, return: :value, page_size: 3) + iex> Enum.to_list(stream) [2, 4, 6, 8, 10] Stream all entries and return them as key/value pairs: - iex> nil |> MyCache.stream(return: {:key, :value}) |> Enum.to_list() + iex> {:ok, stream} = MyCache.stream(nil, return: {:key, :value}) + iex> Enum.to_list(stream) [{1, 2}, {2, 4}, {3, 6}, {4, 8}, {5, 10}] Additional built-in queries for `Nebulex.Adapters.Local` adapter: - iex> unexpired_stream = MyCache.stream(:unexpired) - iex> expired_stream = MyCache.stream(:expired) + iex> {:ok, unexpired_stream} = MyCache.stream(:unexpired) + iex> {:ok, expired_stream} = MyCache.stream(:expired) If we are using Nebulex.Adapters.Local adapter, the stored entry tuple `{:entry, key, value, touched, ttl}`, then the match spec could be @@ -1465,7 +1823,8 @@ defmodule Nebulex.Cache do ...> {{:entry, :"$1", :"$2", :_, :_}, ...> [{:>, :"$2", 5}], [{{:"$1", :"$2"}}]} ...> ] - iex> MyCache.stream(spec, page_size: 100) |> Enum.to_list() + iex> {:ok, stream} = MyCache.stream(spec, page_size: 100) + iex> Enum.to_list(stream) [{3, 6}, {4, 8}, {5, 10}] The same previous query but using `Ex2ms`: @@ -1478,19 +1837,32 @@ defmodule Nebulex.Cache do ...> {_, key, value, _, _} when value > 5 -> {key, value} ...> end - iex> spec |> MyCache.stream(page_size: 100) |> Enum.to_list() + iex> {:ok, stream} = MyCache.stream(spec, page_size: 100) + iex> Enum.to_list(stream) [{3, 6}, {4, 8}, {5, 10}] """ - @callback stream(query :: term, opts) :: Enum.t() + @doc group: "Query API" + @callback stream(query :: term, opts) :: ok_error_tuple(Enum.t(), query_error_reason) + + @doc """ + Same as `c:stream/2` but raises an exception if an error occurs. + """ + @doc group: "Query API" + @callback stream!(query :: term, opts) :: Enum.t() @doc """ Deletes all entries matching the given `query`. If `query` is `nil`, then all entries in the cache are deleted. - It returns the number of deleted entries. + This function returns: + + * `{:ok, deleted_count}` - the query is valid, then the matched entries + are deleted and the `deleted_count` is returned. - May raise `Nebulex.QueryError` if query validation fails. + * `{:error, Nebulex.QueryError.t()}` - the query validation failed. + + * `{:error, reason}` - an error occurred while executing the command. ## Query values @@ -1509,39 +1881,48 @@ defmodule Nebulex.Cache do Delete all (with default params): iex> MyCache.delete_all() - 5 + {:ok, 5} Delete all entries that match with the given query assuming we are using `Nebulex.Adapters.Local` adapter: iex> query = [{{:_, :"$1", :"$2", :_, :_}, [{:>, :"$2", 5}], [true]}] - iex> MyCache.delete_all(query) - - > For the local adapter you can use [Ex2ms](https://github.com/ericmj/ex2ms) - to build the match specs much easier. - - Additional built-in queries for `Nebulex.Adapters.Local` adapter: + iex> {:ok, deleted_count} = MyCache.delete_all(query) - iex> unexpired = MyCache.delete_all(:unexpired) - iex> expired = MyCache.delete_all(:expired) + See `c:all/2` for more examples, the same applies to `c:delete_all/2`. + """ + @doc group: "Query API" + @callback delete_all(query :: term, opts) :: ok_error_tuple(non_neg_integer, query_error_reason) + @doc """ + Same as `c:delete_all/2` but raises an exception if an error occurs. """ - @callback delete_all(query :: term, opts) :: integer + @doc group: "Query API" + @callback delete_all!(query :: term, opts) :: integer @doc """ Counts all entries in cache matching the given `query`. - It returns the count of the matched entries. - If `query` is `nil` (the default), then the total number of cached entries is returned. - May raise `Nebulex.QueryError` if query validation fails. + This function returns: + + * `{:ok, count}` - the query is valid, then the `count` of the + matched entries returned. + + * `{:error, Nebulex.QueryError.t()}` - the query validation failed. + + * `{:error, reason}` - an error occurred while executing the command. ## Query values See `c:all/2` callback for more information about the query values. + ## Options + + See the "Shared options" section at the module documentation for more options. + ## Example Populate the cache with some entries: @@ -1551,28 +1932,28 @@ defmodule Nebulex.Cache do Count all entries in cache: iex> MyCache.count_all() - 5 + {:ok, 5} Count all entries that match with the given query assuming we are using `Nebulex.Adapters.Local` adapter: iex> query = [{{:_, :"$1", :"$2", :_, :_}, [{:>, :"$2", 5}], [true]}] - iex> MyCache.count_all(query) + iex> {:ok, count} = MyCache.count_all(query) - > For the local adapter you can use [Ex2ms](https://github.com/ericmj/ex2ms) - to build the match specs much easier. - - Additional built-in queries for `Nebulex.Adapters.Local` adapter: - - iex> unexpired = MyCache.count_all(:unexpired) - iex> expired = MyCache.count_all(:expired) + See `c:all/2` for more examples, the same applies to `c:count_all/2`. + """ + @doc group: "Query API" + @callback count_all(query :: term, opts) :: ok_error_tuple(non_neg_integer, query_error_reason) + @doc """ + Same as `c:count_all/2` but raises an exception if an error occurs. """ - @callback count_all(query :: term, opts) :: integer + @doc group: "Query API" + @callback count_all!(query :: term, opts) :: integer ## Nebulex.Adapter.Persistence - @optional_callbacks dump: 2, load: 2 + @optional_callbacks dump: 2, dump!: 2, load: 2, load!: 2 @doc """ Dumps a cache to the given file `path`. @@ -1587,12 +1968,14 @@ defmodule Nebulex.Cache do the default implementation from `Nebulex.Adapter.Persistence`, hence, review the available options there. + See the "Shared options" section at the module documentation for more options. + ## Examples Populate the cache with some entries: iex> entries = for x <- 1..10, into: %{}, do: {x, x} - iex> MyCache.set_many(entries) + iex> MyCache.put_all(entries) :ok Dump cache to a file: @@ -1601,7 +1984,14 @@ defmodule Nebulex.Cache do :ok """ - @callback dump(path :: Path.t(), opts) :: :ok | {:error, term} + @doc group: "Persistence API" + @callback dump(path :: Path.t(), opts) :: :ok | error + + @doc """ + Same as `c:dump/2` but raises an exception if an error occurs. + """ + @doc group: "Persistence API" + @callback dump!(path :: Path.t(), opts) :: :ok @doc """ Loads a dumped cache from the given `path`. @@ -1616,12 +2006,14 @@ defmodule Nebulex.Cache do default implementation from `Nebulex.Adapter.Persistence`, hence, review the available options there. + See the "Shared options" section at the module documentation for more options. + ## Examples Populate the cache with some entries: iex> entries = for x <- 1..10, into: %{}, do: {x, x} - iex> MyCache.set_many(entries) + iex> MyCache.put_all(entries) :ok Dump cache to a file: @@ -1635,16 +2027,33 @@ defmodule Nebulex.Cache do :ok """ - @callback load(path :: Path.t(), opts) :: :ok | {:error, term} + @doc group: "Persistence API" + @callback load(path :: Path.t(), opts) :: :ok | error + + @doc """ + Same as `c:load/2` but raises an exception if an error occurs. + """ + @doc group: "Persistence API" + @callback load!(path :: Path.t(), opts) :: :ok ## Nebulex.Adapter.Transaction - @optional_callbacks transaction: 2, in_transaction?: 0 + @optional_callbacks transaction: 2, in_transaction?: 1 @doc """ Runs the given function inside a transaction. - A successful transaction returns the value returned by the function. + A successful transaction returns the value returned by the function wrapped + in a tuple as `{:ok, value}`. + + In case the transaction cannot be executed, then `{:error, reason}` is + returned. + + If an unhandled error/exception occurs, the error will bubble up from the + transaction function. + + If `transaction/2` is called inside another transaction, the function is + simply executed without wrapping the new transaction call in any way. ## Options @@ -1669,53 +2078,81 @@ defmodule Nebulex.Cache do end """ - @callback transaction(opts, function :: fun) :: term + @doc group: "Transaction API" + @callback transaction(opts, function :: fun) :: ok_error_tuple(term, term) @doc """ - Returns `true` if the current process is inside a transaction. + Returns `{:ok, true}` if the current process is inside a transaction, + otherwise, `{:ok, false}` is returned. + + Returns `{:error, reason}` if an error occurs. + + ## Options + + See the "Shared options" section at the module documentation for more options. ## Examples MyCache.in_transaction? - #=> false + #=> {:ok, false} MyCache.transaction(fn -> - MyCache.in_transaction? #=> true + MyCache.in_transaction? #=> {:ok, true} end) """ - @callback in_transaction?() :: boolean + @doc group: "Transaction API" + @callback in_transaction?(opts) :: ok_error_tuple(boolean) ## Nebulex.Adapter.Stats - @optional_callbacks stats: 0, dispatch_stats: 1 + @optional_callbacks stats: 1, stats!: 1, dispatch_stats: 1 @doc """ - Returns `Nebulex.Stats.t()` with the current stats values. + Returns current stats values. + + This function returns: + + * `{:ok, Nebulex.Stats.t()}` - stats are enabled and available + for the cache. + + * `{:error, reason}` - an error occurred while executing the command. + + ## Options - If the stats are disabled for the cache, then `nil` is returned. + See the "Shared options" section at the module documentation for more options. ## Example iex> MyCache.stats() - %Nebulex.Stats{ - measurements: { - evictions: 0, - expirations: 0, - hits: 0, - misses: 0, - updates: 0, - writes: 0 - }, - metadata: %{} - } + {:ok, + %Nebulex.Stats{ + measurements: %{ + evictions: 0, + expirations: 0, + hits: 0, + misses: 0, + updates: 0, + writes: 0 + }, + metadata: %{} + }} + + """ + @doc group: "Stats API" + @callback stats(opts) :: ok_error_tuple(Nebulex.Stats.t()) + @doc """ + Same as `c:stats/1` but raises an exception if an error occurs. """ - @callback stats() :: Nebulex.Stats.t() | nil + @doc group: "Stats API" + @callback stats!(opts) :: Nebulex.Stats.t() @doc """ Emits a telemetry event when called with the current stats count. + Returns `{:error, reason}` if an error occurs. + The telemetry `:measurements` map will include the same as `Nebulex.Stats.t()`'s measurements. For example: @@ -1742,6 +2179,8 @@ defmodule Nebulex.Cache do * `:metadata` – A map with additional metadata fields. Defaults to `%{}`. + See the "Shared options" section at the module documentation for more options. + ## Examples iex> MyCache.dispatch_stats() @@ -1757,5 +2196,6 @@ defmodule Nebulex.Cache do defined, a default implementation is provided without any logic, just returning `:ok`. """ - @callback dispatch_stats(opts) :: :ok + @doc group: "Stats API" + @callback dispatch_stats(opts) :: :ok | error end diff --git a/lib/nebulex/cache/entry.ex b/lib/nebulex/cache/entry.ex index 187baafc..efb25223 100644 --- a/lib/nebulex/cache/entry.ex +++ b/lib/nebulex/cache/entry.ex @@ -9,37 +9,74 @@ defmodule Nebulex.Cache.Entry do @compile {:inline, get_ttl: 1} @doc """ - Implementation for `c:Nebulex.Cache.get/2`. + Implementation for `c:Nebulex.Cache.fetch/2`. """ - def get(name, key, opts) do - Adapter.with_meta(name, & &1.get(&2, key, opts)) + def fetch(name, key, opts) do + Adapter.with_meta(name, & &1.adapter.fetch(&1, key, opts)) end @doc """ - Implementation for `c:Nebulex.Cache.get!/2`. + Implementation for `c:Nebulex.Cache.fetch!/2`. """ - def get!(name, key, opts) do - if result = get(name, key, opts) do - result - else - raise KeyError, key: key, term: name + def fetch!(name, key, opts) do + unwrap_or_raise fetch(name, key, opts) + end + + @doc """ + Implementation for `c:Nebulex.Cache.get/3`. + """ + def get(name, key, default, opts) do + Adapter.with_meta(name, &do_get(&1.adapter, &1, key, default, opts)) + end + + defp do_get(adapter, adapter_meta, key, default, opts) do + with {:error, %Nebulex.KeyError{key: ^key}} <- adapter.fetch(adapter_meta, key, opts) do + {:ok, default} end end + @doc """ + Implementation for `c:Nebulex.Cache.get!/3`. + """ + def get!(name, key, default, opts) do + unwrap_or_raise get(name, key, default, opts) + end + @doc """ Implementation for `c:Nebulex.Cache.get_all/2`. """ - def get_all(_name, [], _opts), do: %{} + def get_all(name, keys, opts) + + def get_all(_name, [], _opts) do + {:ok, %{}} + end def get_all(name, keys, opts) do - Adapter.with_meta(name, & &1.get_all(&2, keys, opts)) + Adapter.with_meta(name, & &1.adapter.get_all(&1, keys, opts)) + end + + @doc """ + Implementation for `c:Nebulex.Cache.get_all!/3`. + """ + def get_all!(name, keys, opts) do + unwrap_or_raise get_all(name, keys, opts) end @doc """ Implementation for `c:Nebulex.Cache.put/3`. """ def put(name, key, value, opts) do - true = do_put(name, key, value, :put, opts) + case do_put(name, key, value, :put, opts) do + {:ok, _} -> :ok + {:error, _} = error -> error + end + end + + @doc """ + Implementation for `c:Nebulex.Cache.put!/3`. + """ + def put!(name, key, value, opts) do + _ = unwrap_or_raise do_put(name, key, value, :put, opts) :ok end @@ -54,9 +91,7 @@ defmodule Nebulex.Cache.Entry do Implementation for `c:Nebulex.Cache.put_new!/3`. """ def put_new!(name, key, value, opts) do - with false <- put_new(name, key, value, opts) do - raise Nebulex.KeyAlreadyExistsError, cache: name, key: key - end + unwrap_or_raise put_new(name, key, value, opts) end @doc """ @@ -70,22 +105,29 @@ defmodule Nebulex.Cache.Entry do Implementation for `c:Nebulex.Cache.replace!/3`. """ def replace!(name, key, value, opts) do - with false <- replace(name, key, value, opts) do - raise KeyError, key: key, term: name - end + unwrap_or_raise replace(name, key, value, opts) end - defp do_put(_name, _key, nil, _on_write, _opts), do: true - defp do_put(name, key, value, on_write, opts) do - Adapter.with_meta(name, & &1.put(&2, key, value, get_ttl(opts), on_write, opts)) + Adapter.with_meta(name, & &1.adapter.put(&1, key, value, get_ttl(opts), on_write, opts)) end @doc """ Implementation for `c:Nebulex.Cache.put_all/2`. """ def put_all(name, entries, opts) do - _ = do_put_all(name, entries, :put, opts) + case do_put_all(name, entries, :put, opts) do + {:ok, _} -> :ok + {:error, _} = error -> error + end + end + + @doc """ + Implementation for `c:Nebulex.Cache.put_all!/2`. + """ + def put_all!(name, entries, opts) do + _ = unwrap_or_raise do_put_all(name, entries, :put, opts) + :ok end @@ -96,115 +138,171 @@ defmodule Nebulex.Cache.Entry do do_put_all(name, entries, :put_new, opts) end - def do_put_all(_name, [], _on_write, _opts), do: true - def do_put_all(_name, entries, _on_write, _opts) when map_size(entries) == 0, do: true + @doc """ + Implementation for `c:Nebulex.Cache.put_new_all!/2`. + """ + def put_new_all!(name, entries, opts) do + unwrap_or_raise put_new_all(name, entries, opts) + end + + def do_put_all(_name, [], _on_write, _opts) do + {:ok, true} + end + + def do_put_all(_name, %{} = entries, _on_write, _opts) when map_size(entries) == 0 do + {:ok, true} + end def do_put_all(name, entries, on_write, opts) do - Adapter.with_meta(name, & &1.put_all(&2, entries, get_ttl(opts), on_write, opts)) + Adapter.with_meta(name, & &1.adapter.put_all(&1, entries, get_ttl(opts), on_write, opts)) end @doc """ Implementation for `c:Nebulex.Cache.delete/2`. """ def delete(name, key, opts) do - Adapter.with_meta(name, & &1.delete(&2, key, opts)) + Adapter.with_meta(name, & &1.adapter.delete(&1, key, opts)) end @doc """ - Implementation for `c:Nebulex.Cache.take/2`. + Implementation for `c:Nebulex.Cache.delete!/2`. """ - def take(_name, nil, _opts), do: nil + def delete!(name, key, opts) do + unwrap_or_raise delete(name, key, opts) + end + @doc """ + Implementation for `c:Nebulex.Cache.take/2`. + """ def take(name, key, opts) do - Adapter.with_meta(name, & &1.take(&2, key, opts)) + Adapter.with_meta(name, & &1.adapter.take(&1, key, opts)) end @doc """ Implementation for `c:Nebulex.Cache.take!/2`. """ def take!(name, key, opts) do - if result = take(name, key, opts) do - result - else - raise KeyError, key: key, term: name + case take(name, key, opts) do + {:ok, value} -> value + {:error, reason} -> raise reason end end @doc """ - Implementation for `c:Nebulex.Cache.has_key?/1`. + Implementation for `c:Nebulex.Cache.exists?/1`. """ - def has_key?(name, key) do - Adapter.with_meta(name, & &1.has_key?(&2, key)) + def exists?(name, key, opts) do + Adapter.with_meta(name, & &1.adapter.exists?(&1, key, opts)) end @doc """ Implementation for `c:Nebulex.Cache.get_and_update/3`. """ def get_and_update(name, key, fun, opts) when is_function(fun, 1) do - Adapter.with_meta(name, fn adapter, adapter_meta -> - current = adapter.get(adapter_meta, key, opts) + Adapter.with_meta(name, fn %{adapter: adapter} = adapter_meta -> + with {:ok, current} <- do_get(adapter, adapter_meta, key, nil, opts) do + {:ok, eval_get_and_update_function(current, adapter, adapter_meta, key, opts, fun)} + end + end) + end - case fun.(current) do - {get, nil} -> - {get, get} + defp eval_get_and_update_function(current, adapter, adapter_meta, key, opts, fun) do + case fun.(current) do + {get, nil} -> + {get, get} - {get, update} -> - true = adapter.put(adapter_meta, key, update, get_ttl(opts), :put, opts) - {get, update} + {get, update} -> + {:ok, true} = adapter.put(adapter_meta, key, update, get_ttl(opts), :put, opts) + {get, update} - :pop when is_nil(current) -> - {nil, nil} + :pop when is_nil(current) -> + {nil, nil} - :pop -> - :ok = adapter.delete(adapter_meta, key, opts) - {current, nil} + :pop -> + :ok = adapter.delete(adapter_meta, key, opts) + {current, nil} - other -> - raise ArgumentError, - "the given function must return a two-element tuple or :pop," <> - " got: #{inspect(other)}" - end - end) + other -> + raise ArgumentError, + "the given function must return a two-element tuple or :pop," <> + " got: #{inspect(other)}" + end + end + + @doc """ + Implementation for `c:Nebulex.Cache.get_and_update!/3`. + """ + def get_and_update!(name, key, fun, opts) do + unwrap_or_raise get_and_update(name, key, fun, opts) end @doc """ Implementation for `c:Nebulex.Cache.update/4`. """ def update(name, key, initial, fun, opts) do - Adapter.with_meta(name, fn adapter, adapter_meta -> - adapter_meta - |> adapter.get(key, opts) - |> case do - nil -> {initial, nil} - val -> {fun.(val), val} - end - |> case do - {nil, old} -> - # avoid storing nil values - old - - {new, _} -> - true = adapter.put(adapter_meta, key, new, get_ttl(opts), :put, opts) - new + Adapter.with_meta(name, fn %{adapter: adapter} = adapter_meta -> + value = + case adapter.fetch(adapter_meta, key, opts) do + {:ok, value} -> fun.(value) + {:error, %Nebulex.KeyError{key: ^key}} -> initial + {:error, _} = error -> throw({:return, error}) + end + + with {:ok, true} <- adapter.put(adapter_meta, key, value, get_ttl(opts), :put, opts) do + {:ok, value} end end) + catch + {:return, error} -> error + end + + @doc """ + Implementation for `c:Nebulex.Cache.update!/4`. + """ + def update!(name, key, initial, fun, opts) do + unwrap_or_raise update(name, key, initial, fun, opts) end @doc """ Implementation for `c:Nebulex.Cache.incr/3`. """ + def incr(name, key, amount, opts) + def incr(name, key, amount, opts) when is_integer(amount) do - default = get_option(opts, :default, "an integer", &is_integer/1, 0) - Adapter.with_meta(name, & &1.update_counter(&2, key, amount, get_ttl(opts), default, opts)) + default = + case Keyword.fetch(opts, :default) do + {:ok, value} when is_integer(value) -> + value + + {:ok, value} -> + raise ArgumentError, "expected default: to be an integer, got: #{inspect(value)}" + + :error -> + 0 + end + + Adapter.with_meta( + name, + & &1.adapter.update_counter(&1, key, amount, get_ttl(opts), default, opts) + ) end def incr(_cache, _key, amount, _opts) do raise ArgumentError, "expected amount to be an integer, got: #{inspect(amount)}" end + @doc """ + Implementation for `c:Nebulex.Cache.incr!/3`. + """ + def incr!(name, key, amount, opts) do + unwrap_or_raise incr(name, key, amount, opts) + end + @doc """ Implementation for `c:Nebulex.Cache.decr/3`. """ + def decr(name, key, amount, opts) + def decr(name, key, amount, opts) when is_integer(amount) do incr(name, key, amount * -1, opts) end @@ -213,34 +311,75 @@ defmodule Nebulex.Cache.Entry do raise ArgumentError, "expected amount to be an integer, got: #{inspect(amount)}" end + @doc """ + Implementation for `c:Nebulex.Cache.decr!/3`. + """ + def decr!(name, key, amount, opts) do + unwrap_or_raise decr(name, key, amount, opts) + end + @doc """ Implementation for `c:Nebulex.Cache.ttl/1`. """ - def ttl(name, key) do - Adapter.with_meta(name, & &1.ttl(&2, key)) + def ttl(name, key, opts) do + Adapter.with_meta(name, & &1.adapter.ttl(&1, key, opts)) + end + + @doc """ + Implementation for `c:Nebulex.Cache.ttl!/1`. + """ + def ttl!(name, key, opts) do + case ttl(name, key, opts) do + {:ok, ttl} -> ttl + {:error, reason} -> raise reason + end end @doc """ Implementation for `c:Nebulex.Cache.expire/2`. """ - def expire(name, key, ttl) do + def expire(name, key, ttl, opts) do ttl = (Time.timeout?(ttl) && ttl) || raise ArgumentError, "expected ttl to be a valid timeout, got: #{inspect(ttl)}" - Adapter.with_meta(name, & &1.expire(&2, key, ttl)) + Adapter.with_meta(name, & &1.adapter.expire(&1, key, ttl, opts)) + end + + @doc """ + Implementation for `c:Nebulex.Cache.expire!/2`. + """ + def expire!(name, key, ttl, opts) do + unwrap_or_raise expire(name, key, ttl, opts) end @doc """ Implementation for `c:Nebulex.Cache.touch/1`. """ - def touch(name, key) do - Adapter.with_meta(name, & &1.touch(&2, key)) + def touch(name, key, opts) do + Adapter.with_meta(name, & &1.adapter.touch(&1, key, opts)) + end + + @doc """ + Implementation for `c:Nebulex.Cache.touch!/1`. + """ + def touch!(name, key, opts) do + unwrap_or_raise touch(name, key, opts) end ## Helpers defp get_ttl(opts) do - get_option(opts, :ttl, "a valid timeout", &Time.timeout?/1, :infinity) + case Keyword.fetch(opts, :ttl) do + {:ok, ttl} -> + if not Time.timeout?(ttl) do + raise ArgumentError, "expected ttl: to be a valid timeout, got: #{inspect(ttl)}" + end + + ttl + + :error -> + :infinity + end end end diff --git a/lib/nebulex/cache/options.ex b/lib/nebulex/cache/options.ex new file mode 100644 index 00000000..0d26676e --- /dev/null +++ b/lib/nebulex/cache/options.ex @@ -0,0 +1,102 @@ +defmodule Nebulex.Cache.Options do + @moduledoc """ + Behaviour for option definitions and validation. + """ + + @base_definition [ + otp_app: [ + required: false, + type: :atom, + doc: """ + The OTP app. + """ + ], + adapter: [ + required: false, + type: :atom, + doc: """ + The cache adapter module. + """ + ], + cache: [ + required: false, + type: :atom, + doc: """ + The defined cache module. + """ + ], + name: [ + required: false, + type: :atom, + doc: """ + The name of the Cache supervisor process. + """ + ], + telemetry_prefix: [ + required: false, + type: {:list, :atom}, + doc: """ + The telemetry prefix. + """ + ], + telemetry: [ + required: false, + type: :boolean, + default: true, + doc: """ + An optional flag to tell the adapters whether Telemetry events should be + emitted or not. + """ + ], + stats: [ + required: false, + type: :boolean, + default: false, + doc: """ + Defines whether or not the cache will provide stats. + """ + ] + ] + + @doc false + def base_definition, do: @base_definition + + @doc false + defmacro __using__(_opts) do + quote do + @behaviour Nebulex.Cache.Options + + import unquote(__MODULE__), only: [base_definition: 0] + + @doc false + def definition, do: unquote(@base_definition) + + @doc false + def validate!(opts) do + opts + |> NimbleOptions.validate(__MODULE__.definition()) + |> format_error() + end + + defp format_error({:ok, opts}) do + opts + end + + defp format_error({:error, %NimbleOptions.ValidationError{message: message}}) do + raise ArgumentError, message + end + + defoverridable definition: 0, validate!: 1 + end + end + + @doc """ + Returns the option definitions. + """ + @callback definition() :: NimbleOptions.t() | NimbleOptions.schema() + + @doc """ + Validates the given `opts` with the definition returned by `c:definition/0`. + """ + @callback validate!(opts :: keyword) :: keyword +end diff --git a/lib/nebulex/cache/persistence.ex b/lib/nebulex/cache/persistence.ex index 133cf988..aedda6df 100644 --- a/lib/nebulex/cache/persistence.ex +++ b/lib/nebulex/cache/persistence.ex @@ -1,19 +1,35 @@ defmodule Nebulex.Cache.Persistence do @moduledoc false + import Nebulex.Helpers + alias Nebulex.Adapter @doc """ Implementation for `c:Nebulex.Cache.dump/2`. """ def dump(name, path, opts) do - Adapter.with_meta(name, & &1.dump(&2, path, opts)) + Adapter.with_meta(name, & &1.adapter.dump(&1, path, opts)) + end + + @doc """ + Implementation for `c:Nebulex.Cache.dump!/2`. + """ + def dump!(name, path, opts) do + unwrap_or_raise dump(name, path, opts) end @doc """ Implementation for `c:Nebulex.Cache.load/2`. """ def load(name, path, opts) do - Adapter.with_meta(name, & &1.load(&2, path, opts)) + Adapter.with_meta(name, & &1.adapter.load(&1, path, opts)) + end + + @doc """ + Implementation for `c:Nebulex.Cache.load!/2`. + """ + def load!(name, path, opts) do + unwrap_or_raise load(name, path, opts) end end diff --git a/lib/nebulex/cache/queryable.ex b/lib/nebulex/cache/queryable.ex index 154a9ecc..90324895 100644 --- a/lib/nebulex/cache/queryable.ex +++ b/lib/nebulex/cache/queryable.ex @@ -1,6 +1,8 @@ defmodule Nebulex.Cache.Queryable do @moduledoc false + import Nebulex.Helpers + alias Nebulex.Adapter @default_page_size 20 @@ -9,21 +11,42 @@ defmodule Nebulex.Cache.Queryable do Implementation for `c:Nebulex.Cache.all/2`. """ def all(name, query, opts) do - Adapter.with_meta(name, & &1.execute(&2, :all, query, opts)) + Adapter.with_meta(name, & &1.adapter.execute(&1, :all, query, opts)) + end + + @doc """ + Implementation for `c:Nebulex.Cache.all!/2`. + """ + def all!(name, query, opts) do + unwrap_or_raise all(name, query, opts) end @doc """ Implementation for `c:Nebulex.Cache.count_all/2`. """ def count_all(name, query, opts) do - Adapter.with_meta(name, & &1.execute(&2, :count_all, query, opts)) + Adapter.with_meta(name, & &1.adapter.execute(&1, :count_all, query, opts)) + end + + @doc """ + Implementation for `c:Nebulex.Cache.count_all!/2`. + """ + def count_all!(name, query, opts) do + unwrap_or_raise count_all(name, query, opts) end @doc """ Implementation for `c:Nebulex.Cache.delete_all/2`. """ def delete_all(name, query, opts) do - Adapter.with_meta(name, & &1.execute(&2, :delete_all, query, opts)) + Adapter.with_meta(name, & &1.adapter.execute(&1, :delete_all, query, opts)) + end + + @doc """ + Implementation for `c:Nebulex.Cache.delete_all!/2`. + """ + def delete_all!(name, query, opts) do + unwrap_or_raise delete_all(name, query, opts) end @doc """ @@ -31,6 +54,13 @@ defmodule Nebulex.Cache.Queryable do """ def stream(name, query, opts) do opts = Keyword.put_new(opts, :page_size, @default_page_size) - Adapter.with_meta(name, & &1.stream(&2, query, opts)) + Adapter.with_meta(name, & &1.adapter.stream(&1, query, opts)) + end + + @doc """ + Implementation for `c:Nebulex.Cache.stream!/2`. + """ + def stream!(name, query, opts) do + unwrap_or_raise stream(name, query, opts) end end diff --git a/lib/nebulex/cache/registry.ex b/lib/nebulex/cache/registry.ex index 32a7869a..da2f786d 100644 --- a/lib/nebulex/cache/registry.ex +++ b/lib/nebulex/cache/registry.ex @@ -3,49 +3,73 @@ defmodule Nebulex.Cache.Registry do use GenServer + import Nebulex.Helpers + ## API - @spec start_link(Keyword.t()) :: GenServer.on_start() + @spec start_link(keyword) :: GenServer.on_start() def start_link(_opts) do GenServer.start_link(__MODULE__, :ok, name: __MODULE__) end - @spec register(pid, term) :: :ok - def register(pid, value) when is_pid(pid) do - GenServer.call(__MODULE__, {:register, pid, value}) + @spec register(pid, atom, term) :: :ok + def register(pid, name, value) when is_pid(pid) and is_atom(name) do + GenServer.call(__MODULE__, {:register, pid, name, value}) end - @spec lookup(atom | pid) :: term + @spec lookup(atom | pid) :: {:ok, term} | {:error, Nebulex.Error.t()} + def lookup(name_or_pid) + def lookup(name) when is_atom(name) do - name - |> GenServer.whereis() - |> Kernel.||(raise Nebulex.RegistryLookupError, name: name) - |> lookup() + if pid = GenServer.whereis(name) do + lookup(pid) + else + wrap_error Nebulex.Error, reason: {:registry_lookup_error, name} + end end def lookup(pid) when is_pid(pid) do - {_ref, value} = :persistent_term.get({__MODULE__, pid}) - value + case :persistent_term.get({__MODULE__, pid}, nil) do + {_ref, _name, value} -> {:ok, value} + nil -> wrap_error Nebulex.Error, reason: {:registry_lookup_error, pid} + end + end + + @spec all_running() :: [atom | pid] + def all_running do + for {{__MODULE__, pid}, {_ref, name, _value}} <- :persistent_term.get() do + name || pid + end end ## GenServer Callbacks @impl true def init(:ok) do - {:ok, :ok} + {:ok, nil} end @impl true - def handle_call({:register, pid, value}, _from, state) do + def handle_call({:register, pid, name, value}, _from, state) do + # Monitor the process so that when it is down it can be removed ref = Process.monitor(pid) - :ok = :persistent_term.put({__MODULE__, pid}, {ref, value}) + + # Store the process data + :ok = :persistent_term.put({__MODULE__, pid}, {ref, name, value}) + + # Reply with success {:reply, :ok, state} end @impl true def handle_info({:DOWN, ref, _type, pid, _reason}, state) do - {^ref, _} = :persistent_term.get({__MODULE__, pid}) + # Check the process reference + {^ref, _, _} = :persistent_term.get({__MODULE__, pid}) + + # Remove the process data _ = :persistent_term.erase({__MODULE__, pid}) + + # Continue {:noreply, state} end end diff --git a/lib/nebulex/cache/stats.ex b/lib/nebulex/cache/stats.ex index 1a2b9611..a7b04eb6 100644 --- a/lib/nebulex/cache/stats.ex +++ b/lib/nebulex/cache/stats.ex @@ -1,15 +1,24 @@ defmodule Nebulex.Cache.Stats do @moduledoc false + import Nebulex.Helpers + alias Nebulex.Adapter ## API @doc """ - Implementation for `c:Nebulex.Cache.stats/0`. + Implementation for `c:Nebulex.Cache.stats/1`. """ def stats(name) do - Adapter.with_meta(name, & &1.stats(&2)) + Adapter.with_meta(name, & &1.adapter.stats(&1)) + end + + @doc """ + Implementation for `c:Nebulex.Cache.stats!/0`. + """ + def stats!(name) do + unwrap_or_raise stats(name) end if Code.ensure_loaded?(:telemetry) do @@ -17,15 +26,16 @@ defmodule Nebulex.Cache.Stats do Implementation for `c:Nebulex.Cache.dispatch_stats/1`. """ def dispatch_stats(name, opts \\ []) do - Adapter.with_meta(name, fn adapter, meta -> + Adapter.with_meta(name, fn %{adapter: adapter} = meta -> with true <- is_list(meta.telemetry_prefix), - %Nebulex.Stats{} = info <- adapter.stats(meta) do + {:ok, %Nebulex.Stats{} = info} <- adapter.stats(meta) do :telemetry.execute( meta.telemetry_prefix ++ [:stats], info.measurements, Map.merge(info.metadata, opts[:metadata] || %{}) ) else + {:error, _} = error -> error _ -> :ok end end) diff --git a/lib/nebulex/cache/supervisor.ex b/lib/nebulex/cache/supervisor.ex index 352729ec..05381ee6 100644 --- a/lib/nebulex/cache/supervisor.ex +++ b/lib/nebulex/cache/supervisor.ex @@ -9,14 +9,18 @@ defmodule Nebulex.Cache.Supervisor do @doc """ Starts the cache manager supervisor. """ + @spec start_link(module, atom, module, keyword) :: Supervisor.on_start() def start_link(cache, otp_app, adapter, opts) do - sup_opts = if name = Keyword.get(opts, :name, cache), do: [name: name], else: [] - Supervisor.start_link(__MODULE__, {cache, otp_app, adapter, opts}, sup_opts) + name = Keyword.get(opts, :name, cache) + sup_opts = if name, do: [name: name], else: [] + + Supervisor.start_link(__MODULE__, {name, cache, otp_app, adapter, opts}, sup_opts) end @doc """ Retrieves the runtime configuration. """ + @spec runtime_config(module, atom, keyword) :: {:ok, keyword} | :ignore def runtime_config(cache, otp_app, opts) do config = otp_app @@ -40,6 +44,7 @@ defmodule Nebulex.Cache.Supervisor do @doc """ Retrieves the compile time configuration. """ + @spec compile_config(keyword) :: {atom, module, [module]} def compile_config(opts) do otp_app = opts[:otp_app] || raise ArgumentError, "expected otp_app: to be given as argument" adapter = opts[:adapter] || raise ArgumentError, "expected adapter: to be given as argument" @@ -57,18 +62,27 @@ defmodule Nebulex.Cache.Supervisor do ## Supervisor Callbacks @impl true - def init({cache, otp_app, adapter, opts}) do + def init({name, cache, otp_app, adapter, opts}) do + # Normalize name to atom, ignore via/global names + name = if is_atom(name), do: name, else: nil + case runtime_config(cache, otp_app, opts) do {:ok, opts} -> - Telemetry.execute( - [:nebulex, :cache, :init], - %{system_time: System.system_time()}, - %{cache: cache, opts: opts} - ) - + # Dispatch Telemetry event notifying the cache is started + :ok = + Telemetry.execute( + [:nebulex, :cache, :init], + %{system_time: System.system_time()}, + %{name: name, cache: cache, opts: opts} + ) + + # Init the adapter {:ok, child, meta} = adapter.init([cache: cache] ++ opts) - meta = Map.put(meta, :cache, cache) - child_spec = wrap_child_spec(child, [adapter, meta]) + + # Build child spec + child_spec = wrap_child_spec(child, [name, cache, adapter, meta]) + + # Init the cache supervisor Supervisor.init([child_spec], strategy: :one_for_one, max_restarts: 0) other -> @@ -79,11 +93,15 @@ defmodule Nebulex.Cache.Supervisor do ## Helpers @doc false - def start_child({mod, fun, args}, adapter, meta) do + def start_child({mod, fun, args}, name, cache, adapter, meta) do case apply(mod, fun, args) do {:ok, pid} -> - meta = Map.put(meta, :pid, pid) - :ok = Nebulex.Cache.Registry.register(self(), {adapter, meta}) + # Add the pid and the adapter to the meta + meta = Map.merge(meta, %{pid: pid, cache: cache, adapter: adapter}) + + # Register the started cache's pid + :ok = Nebulex.Cache.Registry.register(self(), name, meta) + {:ok, pid} other -> diff --git a/lib/nebulex/cache/transaction.ex b/lib/nebulex/cache/transaction.ex index c23fbd7d..e050e381 100644 --- a/lib/nebulex/cache/transaction.ex +++ b/lib/nebulex/cache/transaction.ex @@ -7,13 +7,13 @@ defmodule Nebulex.Cache.Transaction do Implementation for `c:Nebulex.Cache.transaction/2`. """ def transaction(name, fun, opts) do - Adapter.with_meta(name, & &1.transaction(&2, fun, opts)) + Adapter.with_meta(name, & &1.adapter.transaction(&1, fun, opts)) end @doc """ Implementation for `c:Nebulex.Cache.in_transaction?/0`. """ def in_transaction?(name) do - Adapter.with_meta(name, & &1.in_transaction?(&2)) + Adapter.with_meta(name, & &1.adapter.in_transaction?(&1)) end end diff --git a/lib/nebulex/caching/decorators.ex b/lib/nebulex/caching/decorators.ex index 5693dd07..eb8d6401 100644 --- a/lib/nebulex/caching/decorators.ex +++ b/lib/nebulex/caching/decorators.ex @@ -30,6 +30,8 @@ if Code.ensure_loaded?(Decorator.Define) do to see whether the invocation has been already executed and does not have to be repeated. + See `cacheable/3` for more information. + ### Default Key Generation Since caches are essentially key-value stores, each invocation of a cached @@ -50,7 +52,7 @@ if Code.ensure_loaded?(Decorator.Define) do The default key generator is provided by the cache via the callback `c:Nebulex.Cache.__default_key_generator__/0` and it is applied only if the option `key:` or `keys:` is not configured. By default it is - `Nebulex.unquote(__MODULE__).SimpleKeyGenerator`. But you can change the default + `Nebulex.Caching.SimpleKeyGenerator`. But you can change the default key generator at compile time with the option `:default_key_generator`. For example, one can define a cache with a default key generator as: @@ -60,18 +62,18 @@ if Code.ensure_loaded?(Decorator.Define) do adapter: Nebulex.Adapters.Local, default_key_generator: __MODULE__ - @behaviour Nebulex.unquote(__MODULE__).KeyGenerator + @behaviour Nebulex.Caching.KeyGenerator @impl true def generate(mod, fun, args), do: :erlang.phash2({mod, fun, args}) end - The key generator module must implement the `Nebulex.unquote(__MODULE__).KeyGenerator` + The key generator module must implement the `Nebulex.Caching.KeyGenerator` behaviour. > **IMPORTANT:** There are some caveats to keep in mind when using the key generator, therefore, it is highly recommended to review - `Nebulex.unquote(__MODULE__).KeyGenerator` behaviour documentation before. + `Nebulex.Caching.KeyGenerator` behaviour documentation before. Also, you can provide a different key generator at any time (overriding the default one) when using any caching annotation @@ -183,6 +185,8 @@ if Code.ensure_loaded?(Decorator.Define) do (such as decorators having conditions that exclude them from each other), such declarations should be avoided. + See `cache_put/3` for more information. + ## `cache_evict` decorator The cache abstraction allows not just the population of a cache store but @@ -218,6 +222,8 @@ if Code.ensure_loaded?(Decorator.Define) do long time since it is inefficient), all the entries are removed in one operation as shown above. + See `cache_evict/3` for more information. + ## Shared Options All three cache annotations explained previously accept the following @@ -302,7 +308,7 @@ if Code.ensure_loaded?(Decorator.Define) do The possible values for the `:key_generator` are: - * A module implementing the `Nebulex.unquote(__MODULE__).KeyGenerator` behaviour. + * A module implementing the `Nebulex.Caching.KeyGenerator` behaviour. * A MFA tuple `{module, function, args}` for a function to call to generate the key before the cache is invoked. A shorthand value of @@ -395,7 +401,8 @@ if Code.ensure_loaded?(Decorator.Define) do end end - See [Cache Usage Patters Guide](http://hexdocs.pm/nebulex/cache-usage-patterns.html). + **NOTE:** It is recommended to see the + [Cache Usage Patters Guide](http://hexdocs.pm/nebulex/cache-usage-patterns.html). """ use Decorator.Define, cacheable: 1, cache_evict: 1, cache_put: 1 @@ -411,16 +418,19 @@ if Code.ensure_loaded?(Decorator.Define) do @typedoc "Type spec for a key reference" @type keyref :: record(:keyref, cache: Nebulex.Cache.t(), key: term) - @typedoc "Type for :on_error option" - @type on_error_opt :: :raise | :nothing + @typedoc "Type for on_error action" + @type on_error :: :nothing | :raise - @typedoc "Match function type" - @type match_fun :: (term -> boolean | {true, term}) + @typedoc "Type for match function" + @type match :: (term -> boolean | {true, term}) + + @typedoc "Type for the key generator" + @type keygen :: module | {module, function_name :: atom, args :: [term]} @typedoc "Type spec for the option :references" - @type references :: (term -> term) | nil | term + @type references :: (term -> keyref | term) | nil | term - ## API + ## Decorator API @doc """ Provides a way of annotating functions to be cached (cacheable aspect). @@ -635,6 +645,7 @@ if Code.ensure_loaded?(Decorator.Define) do [`cache_ref/2`](`Nebulex.Caching.cache_ref/2`) builds the special return type for the external cache reference. """ + @doc group: "Decorator API" def cacheable(attrs, block, context) do caching_action(:cacheable, attrs, block, context) end @@ -702,6 +713,7 @@ if Code.ensure_loaded?(Decorator.Define) do provides the logic to write data to the system-of-record (SoR) and the rest is provided by the decorator under-the-hood. """ + @doc group: "Decorator API" def cache_put(attrs, block, context) do caching_action(:cache_put, attrs, block, context) end @@ -761,6 +773,7 @@ if Code.ensure_loaded?(Decorator.Define) do decorator, when the data is written to the SoR, the key for that value is deleted from cache instead of updated. """ + @doc group: "Decorator API" def cache_evict(attrs, block, context) do caching_action(:cache_evict, attrs, block, context) end @@ -795,9 +808,8 @@ if Code.ensure_loaded?(Decorator.Define) do defp caching_action(action, attrs, block, context) do cache = attrs[:cache] || raise ArgumentError, "expected cache: to be given as argument" - opts_var = attrs[:opts] || [] - on_error_var = on_error_opt(attrs) match_var = attrs[:match] || default_match_fun() + opts_var = attrs[:opts] || [] args = context.args @@ -806,13 +818,20 @@ if Code.ensure_loaded?(Decorator.Define) do cache_block = cache_block(cache, args, context) keygen_block = keygen_block(attrs, args, context) - action_block = action_block(action, block, attrs, keygen_block) + + action_block = + action_block( + action, + block, + attrs, + keygen_block, + on_error_opt(attrs), + match_var + ) quote do cache = unquote(cache_block) opts = unquote(opts_var) - match = unquote(match_var) - on_error = unquote(on_error_var) unquote(action_block) end @@ -906,7 +925,7 @@ if Code.ensure_loaded?(Decorator.Define) do end end - defp action_block(:cacheable, block, attrs, keygen) do + defp action_block(:cacheable, block, attrs, keygen, on_error, match) do references = Keyword.get(attrs, :references) quote do @@ -915,14 +934,14 @@ if Code.ensure_loaded?(Decorator.Define) do unquote(keygen), unquote(references), opts, - on_error, - match, + unquote(match), + unquote(on_error), fn -> unquote(block) end ) end end - defp action_block(:cache_put, block, attrs, keygen) do + defp action_block(:cache_put, block, attrs, keygen, on_error, match) do keys = get_keys(attrs) key = @@ -933,58 +952,34 @@ if Code.ensure_loaded?(Decorator.Define) do quote do result = unquote(block) - unquote(__MODULE__).run_cmd( - unquote(__MODULE__), - :eval_match, - [result, match, cache, unquote(key), opts], - on_error, - result + unquote(__MODULE__).eval_cache_put( + cache, + unquote(key), + result, + opts, + unquote(on_error), + unquote(match) ) result end end - defp action_block(:cache_evict, block, attrs, keygen) do + defp action_block(:cache_evict, block, attrs, keygen, on_error, _match) do before_invocation? = attrs[:before_invocation] || false - - eviction = eviction_block(attrs, keygen) - - if is_boolean(before_invocation?) && before_invocation? do - quote do - unquote(eviction) - unquote(block) - end - else - quote do - result = unquote(block) - - unquote(eviction) - - result - end - end - end - - defp eviction_block(attrs, keygen) do - keys = get_keys(attrs) all_entries? = attrs[:all_entries] || false + keys = get_keys(attrs) - cond do - is_boolean(all_entries?) && all_entries? -> - quote(do: unquote(__MODULE__).run_cmd(cache, :delete_all, [], on_error, 0)) - - is_list(keys) and length(keys) > 0 -> - delete_keys_block(keys) - - true -> - quote(do: unquote(__MODULE__).run_cmd(cache, :delete, [unquote(keygen)], on_error, :ok)) - end - end - - defp delete_keys_block(keys) do quote do - Enum.each(unquote(keys), &unquote(__MODULE__).run_cmd(cache, :delete, [&1], on_error, :ok)) + unquote(__MODULE__).eval_cache_evict( + unquote(before_invocation?), + unquote(all_entries?), + cache, + unquote(keygen), + unquote(keys), + unquote(on_error), + fn -> unquote(block) end + ) end end @@ -1010,76 +1005,42 @@ if Code.ensure_loaded?(Decorator.Define) do ## Helpers @doc """ - Convenience function for evaluating the `cacheable` decorator in runtime. + Convenience function for wrapping and/or encapsulating + the **cacheable** decorator logic. - **NOTE:** For internal purposes only. + **NOTE:** Internal purposes only. """ - @spec eval_cacheable( - module, - term, - references, - Keyword.t(), - on_error_opt, - match_fun, - (() -> term) - ) :: term - def eval_cacheable(cache, key, references, opts, on_error, match, block) - - def eval_cacheable(cache, key, nil, opts, on_error, match, block) do - with nil <- run_cmd(cache, :get, [key, opts], on_error) do - result = block.() - - run_cmd( - __MODULE__, - :eval_match, - [result, match, cache, key, opts], - on_error, - result - ) - - result - end + @doc group: "Internal API" + @spec eval_cacheable(module, term, references, Keyword.t(), match, on_error, fun) :: term + def eval_cacheable(cache, key, references, opts, match, on_error, block_fun) + + def eval_cacheable(cache, key, nil, opts, match, on_error, block_fun) do + key + |> cache.fetch(opts) + |> handle_cacheable( + on_error, + block_fun, + &eval_cache_put(cache, key, &1, opts, on_error, match) + ) end - def eval_cacheable(cache, key, references, opts, on_error, match, block) do - case run_cmd(cache, :get, [key, opts], on_error) do - nil -> - result = block.() - - ref_key = eval_cacheable_ref(references, result) - - with true <- - run_cmd( - __MODULE__, - :eval_match, - [result, match, cache, ref_key, opts], - on_error, - result - ) do - :ok = cache_put(cache, key, ref_key, opts) - end - - result - - keyref(cache: ref_cache, key: ref_key) -> - cache = ref_cache || cache + def eval_cacheable(cache, key, references, opts, match, on_error, block_fun) do + case cache.fetch(key, opts) do + {:ok, keyref(cache: nil, key: ref_key)} -> + eval_cacheable(cache, ref_key, nil, opts, match, on_error, block_fun) - with nil <- run_cmd(cache, :get, [ref_key, opts], on_error) do - result = block.() + {:ok, keyref(cache: ref_cache, key: ref_key)} -> + eval_cacheable(ref_cache, ref_key, nil, opts, match, on_error, block_fun) - run_cmd( - __MODULE__, - :eval_match, - [result, match, cache, ref_key, opts], - on_error, - result - ) - - result - end + other -> + other + |> handle_cacheable(on_error, block_fun, fn result -> + reference = eval_cacheable_ref(references, result) - val -> - val + with true <- eval_cache_put(cache, reference, result, opts, on_error, match) do + :ok = cache_put(cache, key, reference, opts) + end + end) end end @@ -1093,34 +1054,89 @@ if Code.ensure_loaded?(Decorator.Define) do end end + defp handle_cacheable({:ok, value}, _on_error, _block_fun, _key_err_fun) do + value + end + + defp handle_cacheable({:error, %Nebulex.KeyError{}}, _on_error, block_fun, key_err_fun) do + result = block_fun.() + + _ = key_err_fun.(result) + + result + end + + defp handle_cacheable({:error, _}, :nothing, block_fun, _key_err_fun) do + block_fun.() + end + + defp handle_cacheable({:error, reason}, :raise, _block_fun, _key_err_fun) do + raise reason + end + @doc """ - Convenience function for evaluating the `:match` function in runtime. + Convenience function for wrapping and/or encapsulating + the **cache_evict** decorator logic. + + **NOTE:** Internal purposes only. + """ + @doc group: "Internal API" + @spec eval_cache_evict(boolean, boolean, module, keygen, [term] | nil, on_error, fun) :: term + def eval_cache_evict(before_invocation?, all_entries?, cache, keygen, keys, on_error, block_fun) + + def eval_cache_evict(true, all_entries?, cache, keygen, keys, on_error, block_fun) do + _ = do_evict(all_entries?, cache, keygen, keys, on_error) + + block_fun.() + end + + def eval_cache_evict(false, all_entries?, cache, keygen, keys, on_error, block_fun) do + result = block_fun.() + + _ = do_evict(all_entries?, cache, keygen, keys, on_error) + + result + end - **NOTE:** For internal purposes only. + defp do_evict(true, cache, _keygen, _keys, on_error) do + run_cmd(cache, :delete_all, [], on_error) + end + + defp do_evict(false, cache, _keygen, keys, on_error) when is_list(keys) and length(keys) > 0 do + Enum.each(keys, &run_cmd(cache, :delete, [&1], on_error)) + end + + defp do_evict(false, cache, keygen, _keys, on_error) do + run_cmd(cache, :delete, [keygen], on_error) + end - **NOTE:** Workaround to avoid dialyzer warnings when using declarative - annotation-based caching via decorators. + @doc """ + Convenience function for wrapping and/or encapsulating + the **cache_put** decorator logic. + + **NOTE:** Internal purposes only. """ - @spec eval_match(term, match_fun, module, term, Keyword.t()) :: boolean - def eval_match(result, match, cache, key, opts) + @doc group: "Internal API" + @spec eval_cache_put(module, term, term, Keyword.t(), atom, match) :: any + def eval_cache_put(cache, key, value, opts, on_error, match) - def eval_match(result, match, cache, keyref(cache: nil, key: key), opts) do - eval_match(result, match, cache, key, opts) + def eval_cache_put(cache, keyref(cache: nil, key: key), value, opts, on_error, match) do + eval_cache_put(cache, key, value, opts, on_error, match) end - def eval_match(result, match, _cache, keyref(cache: ref_cache, key: key), opts) do - eval_match(result, match, ref_cache, key, opts) + def eval_cache_put(_, keyref(cache: cache, key: key), value, opts, on_error, match) do + eval_cache_put(cache, key, value, opts, on_error, match) end - def eval_match(result, match, cache, key, opts) do - case match.(result) do - {true, value} -> - :ok = cache_put(cache, key, value, opts) + def eval_cache_put(cache, key, value, opts, on_error, match) do + case match.(value) do + {true, cache_value} -> + _ = run_cmd(__MODULE__, :cache_put, [cache, key, cache_value, opts], on_error) true true -> - :ok = cache_put(cache, key, result, opts) + _ = run_cmd(__MODULE__, :cache_put, [cache, key, value, opts], on_error) true @@ -1132,38 +1148,30 @@ if Code.ensure_loaded?(Decorator.Define) do @doc """ Convenience function for cache_put annotation. - **NOTE:** For internal purposes only. + **NOTE:** Internal purposes only. """ + @doc group: "Internal API" @spec cache_put(module, {:"$keys", term} | term, term, Keyword.t()) :: :ok def cache_put(cache, key, value, opts) def cache_put(cache, {:"$keys", keys}, value, opts) do - entries = for k <- keys, do: {k, value} - - cache.put_all(entries, opts) + keys + |> Enum.map(&{&1, value}) + |> cache.put_all(opts) end def cache_put(cache, key, value, opts) do cache.put(key, value, opts) end - @doc """ - Convenience function for ignoring cache errors when `:on_error` option - is set to `:nothing` - - **NOTE:** For internal purposes only. - """ - @spec run_cmd(module, atom, [term], on_error_opt, term) :: term - def run_cmd(mod, fun, args, on_error, default \\ nil) - - def run_cmd(mod, fun, args, :raise, _default) do + defp run_cmd(mod, fun, args, :nothing) do apply(mod, fun, args) end - def run_cmd(mod, fun, args, :nothing, default) do - apply(mod, fun, args) - rescue - _e -> default + defp run_cmd(mod, fun, args, :raise) do + with {:error, reason} <- apply(mod, fun, args) do + raise reason + end end end end diff --git a/lib/nebulex/entry.ex b/lib/nebulex/entry.ex index c52255cb..9fe5d445 100644 --- a/lib/nebulex/entry.ex +++ b/lib/nebulex/entry.ex @@ -9,7 +9,7 @@ defmodule Nebulex.Entry do defstruct key: nil, value: nil, touched: nil, - ttl: :infinity, + exp: :infinity, time_unit: :millisecond @typedoc """ @@ -22,7 +22,7 @@ defmodule Nebulex.Entry do key: any, value: any, touched: integer, - ttl: timeout, + exp: timeout, time_unit: System.time_unit() } @@ -73,18 +73,14 @@ defmodule Nebulex.Entry do iex> Nebulex.Entry.expired?(%Nebulex.Entry{}) false - iex> Nebulex.Entry.expired?( - ...> %Nebulex.Entry{touched: Nebulex.Time.now() - 10, ttl: 1} - ...> ) + iex> now = Nebulex.Time.now() + iex> Nebulex.Entry.expired?(%Nebulex.Entry{touched: now, exp: now - 10}) true """ @spec expired?(t) :: boolean - def expired?(%__MODULE__{ttl: :infinity}), do: false - - def expired?(%__MODULE__{touched: touched, ttl: ttl, time_unit: unit}) do - Time.now(unit) - touched >= ttl - end + def expired?(%__MODULE__{exp: :infinity}), do: false + def expired?(%__MODULE__{exp: exp, time_unit: unit}), do: Time.now(unit) >= exp @doc """ Returns the remaining time-to-live. @@ -94,18 +90,13 @@ defmodule Nebulex.Entry do iex> Nebulex.Entry.ttl(%Nebulex.Entry{}) :infinity - iex> ttl = - ...> Nebulex.Entry.ttl( - ...> %Nebulex.Entry{touched: Nebulex.Time.now(), ttl: 100} - ...> ) + iex> now = Nebulex.Time.now() + iex> ttl = Nebulex.Entry.ttl(%Nebulex.Entry{touched: now, exp: now + 10}) iex> ttl > 0 true """ @spec ttl(t) :: timeout - def ttl(%__MODULE__{ttl: :infinity}), do: :infinity - - def ttl(%__MODULE__{ttl: ttl, touched: touched, time_unit: unit}) do - ttl - (Time.now(unit) - touched) - end + def ttl(%__MODULE__{exp: :infinity}), do: :infinity + def ttl(%__MODULE__{exp: exp, time_unit: unit}), do: exp - Time.now(unit) end diff --git a/lib/nebulex/exceptions.ex b/lib/nebulex/exceptions.ex index 3e5f536e..15c80320 100644 --- a/lib/nebulex/exceptions.ex +++ b/lib/nebulex/exceptions.ex @@ -1,116 +1,107 @@ -defmodule Nebulex.RegistryLookupError do +defmodule Nebulex.Error do @moduledoc """ - Raised at runtime when the cache was not started or it does not exist. + Nebulex error. """ - @type t :: %__MODULE__{message: binary, name: atom} + @type reason :: :atom | {:atom, term} - defexception [:message, :name] + @type t :: %__MODULE__{reason: reason, module: module} + + defexception reason: nil, module: __MODULE__ @doc false def exception(opts) do - name = Keyword.fetch!(opts, :name) - - msg = - "could not lookup Nebulex cache #{inspect(name)} because it was " <> - "not started or it does not exist" + reason = Keyword.fetch!(opts, :reason) + module = Keyword.get(opts, :module, __MODULE__) - %__MODULE__{message: msg, name: name} + %__MODULE__{reason: reason, module: module} end -end - -defmodule Nebulex.KeyAlreadyExistsError do - @moduledoc """ - Raised at runtime when a key already exists in cache. - """ - - @type t :: %__MODULE__{key: term, cache: atom} - - defexception [:key, :cache] @doc false - def message(%{key: key, cache: cache}) do - "key #{inspect(key)} already exists in cache #{inspect(cache)}" + def message(%__MODULE__{reason: reason, module: module}) do + module.format_error(reason) end -end -defmodule Nebulex.QueryError do - @moduledoc """ - Raised at runtime when the query is invalid. - """ + ## Helpers - @type t :: %__MODULE__{message: binary} + def format_error({:registry_lookup_error, name_or_pid}) do + "could not lookup Nebulex cache #{inspect(name_or_pid)} because it was " <> + "not started or it does not exist" + end - defexception [:message] + def format_error({:transaction_aborted, cache, nodes}) do + "Cache #{inspect(cache)} has aborted a transaction on nodes: #{inspect(nodes)}" + end - @doc false - def exception(opts) do - message = Keyword.fetch!(opts, :message) - query = Keyword.fetch!(opts, :query) + def format_error({:stats_error, cache}) do + "stats disabled or not supported by the cache #{inspect(cache)}" + end - message = """ - #{message} in query: + def format_error(exception) when is_exception(exception) do + exception.__struct__.message(exception) + end - #{inspect(query, pretty: true)} + def format_error(reason) do """ + Nebulex error: - %__MODULE__{message: message} + #{inspect(reason, pretty: true)} + """ end end -defmodule Nebulex.RPCMultiCallError do +defmodule Nebulex.KeyError do @moduledoc """ - Raised at runtime when a RPC multi_call error occurs. + Raised at runtime when a key does not exist in cache. """ - @type t :: %__MODULE__{message: binary} + @type t :: %__MODULE__{key: term, cache: atom, reason: atom} - defexception [:message] + defexception [:key, :cache, :reason] @doc false def exception(opts) do - action = Keyword.fetch!(opts, :action) - errors = Keyword.fetch!(opts, :errors) - responses = Keyword.fetch!(opts, :responses) - - message = """ - RPC error while executing action #{inspect(action)} + key = Keyword.fetch!(opts, :key) + cache = Keyword.fetch!(opts, :cache) + reason = Keyword.get(opts, :reason, :not_found) - Successful responses: + %__MODULE__{key: key, cache: cache, reason: reason} + end - #{inspect(responses, pretty: true)} + @doc false + def message(%__MODULE__{key: key, cache: cache, reason: reason}) do + format_reason(reason, key, cache) + end - Remote errors: + ## Helpers - #{inspect(errors, pretty: true)} - """ + defp format_reason(:not_found, key, cache) do + "key #{inspect(key)} not found in cache: #{inspect(cache)}" + end - %__MODULE__{message: message} + defp format_reason(:expired, key, cache) do + "key #{inspect(key)} has expired in cache: #{inspect(cache)}" end end -defmodule Nebulex.RPCError do +defmodule Nebulex.QueryError do @moduledoc """ - Raised at runtime when a RPC error occurs. + Raised at runtime when the query is invalid. """ - @type t :: %__MODULE__{reason: atom, node: node} + @type t :: %__MODULE__{message: binary} - defexception [:reason, :node] + defexception [:message] @doc false - def message(%__MODULE__{reason: reason, node: node}) do - format_reason(reason, node) - end - - # :erpc.call/5 doesn't format error messages. - defp format_reason({:erpc, _} = reason, node) do - """ - The RPC operation failed on node #{inspect(node)} with reason: + def exception(opts) do + query = Keyword.fetch!(opts, :query) - #{inspect(reason)} + message = + Keyword.get_lazy(opts, :message, fn -> + "invalid query #{inspect(query, pretty: true)}" + end) - See :erpc.call/5 for more information about the error reasons. - """ + %__MODULE__{message: message} end end diff --git a/lib/nebulex/helpers.ex b/lib/nebulex/helpers.ex index 4e4b51c5..5891a774 100644 --- a/lib/nebulex/helpers.ex +++ b/lib/nebulex/helpers.ex @@ -4,7 +4,7 @@ defmodule Nebulex.Helpers do ## API - @spec get_option(Keyword.t(), atom, binary, (any -> boolean), term) :: term + @spec get_option(keyword, atom, binary, (any -> boolean), term) :: term def get_option(opts, key, expected, valid?, default \\ nil) when is_list(opts) and is_atom(key) do value = Keyword.get(opts, key, default) @@ -16,18 +16,6 @@ defmodule Nebulex.Helpers do end end - @spec get_boolean_option(Keyword.t(), atom, boolean) :: term - def get_boolean_option(opts, key, default \\ false) - when is_list(opts) and is_atom(key) and is_boolean(default) do - value = Keyword.get(opts, key, default) - - if is_boolean(value) do - value - else - raise ArgumentError, "expected #{key}: to be boolean, got: #{inspect(value)}" - end - end - @spec assert_behaviour(module, module, binary) :: module def assert_behaviour(module, behaviour, msg \\ "module") do if behaviour in module_behaviours(module, msg) do @@ -57,4 +45,35 @@ defmodule Nebulex.Helpers do |> Enum.map(&Macro.camelize("#{&1}")) |> Module.concat() end + + @doc false + defmacro unwrap_or_raise(call) do + quote do + case unquote(call) do + {:ok, value} -> value + {:error, reason} when is_exception(reason) -> raise reason + {:error, reason} -> raise Nebulex.Error, reason: reason + other -> other + end + end + end + + # FIXME: this is because coveralls does not mark this as covered + # coveralls-ignore-start + + @doc false + defmacro wrap_ok(call) do + quote do + {:ok, unquote(call)} + end + end + + @doc false + defmacro wrap_error(exception, opts) do + quote do + {:error, unquote(exception).exception(unquote(opts))} + end + end + + # coveralls-ignore-stop end diff --git a/lib/nebulex/hook.ex b/lib/nebulex/hook.ex deleted file mode 100644 index 4601102b..00000000 --- a/lib/nebulex/hook.ex +++ /dev/null @@ -1,269 +0,0 @@ -if Code.ensure_loaded?(Decorator.Define) do - defmodule Nebulex.Hook do - @moduledoc """ - Pre/Post Hooks - - Since `v2.0.0`, pre/post hooks are not supported and/or handled by `Nebulex` - itself. Hooks feature is not a common use-case and also it is something that - can be be easily implemented on top of the Cache at the application level. - - Nevertheless, to keep backward compatibility somehow, `Nebulex` provides the - next decorators for implementing pre/post hooks very easily. - - ## `before` decorator - - The `before` decorator is declared for performing a hook action or callback - before the annotated function is executed. - - @decorate before(fn %Nebulex.Hook{} = hook -> inspect(hook) end) - def some_fun(var) do - # logic ... - end - - ## `after_return` decorator - - The `after_return` decorator is declared for performing a hook action or - callback after the annotated function is executed and its return is passed - through the `return:` attribute. - - @decorate after_return(&inspect(&1.return)) - def some_fun(var) do - # logic ... - end - - ## `around` decorator - - The final kind of hook is `around` decorator. The `around` decorator runs - "around" the annotated function execution. It has the opportunity to do - work both **before** and **after** the function executes. This means the - given hook function is invoked twice, before and after the code-block is - evaluated. - - @decorate around(&inspect(&1.step)) - def some_fun(var) do - # logic ... - end - - ## Putting all together - - Suppose we want to track all cache calls (before and after they are called) - by logging them (including the execution time). In this case, we need to - provide a pre/post hook to log these calls. - - First of all, we have to create a module implementing the hook function: - - defmodule MyApp.Tracker do - use GenServer - - alias Nebulex.Hook - - require Logger - - @actions [:get, :put] - - ## API - - def start_link(opts \\\\ []) do - GenServer.start_link(__MODULE__, opts, name: __MODULE__) - end - - def track(%Hook{step: :before, name: name}) when name in @actions do - System.system_time(:microsecond) - end - - def track(%Hook{step: :after_return, name: name} = event) when name in @actions do - GenServer.cast(__MODULE__, {:track, event}) - end - - def track(hook), do: hook - - ## GenServer Callbacks - - @impl true - def init(_opts) do - {:ok, %{}} - end - - @impl true - def handle_cast({:track, %Hook{acc: start} = hook}, state) do - diff = System.system_time(:microsecond) - start - Logger.info("#=> #\{hook.module}.#\{hook.name}/#\{hook.arity}, Duration: #\{diff}") - {:noreply, state} - end - end - - And then, in the Cache: - - defmodule MyApp.Cache do - use Nebulex.Hook - @decorate_all around(&MyApp.Tracker.track/1) - - use Nebulex.Cache, - otp_app: :my_app, - adapter: Nebulex.Adapters.Local - end - - Try it out: - - iex> MyApp.Cache.put 1, 1 - 10:19:47.736 [info] Elixir.MyApp.Cache.put/3, Duration: 27 - iex> MyApp.Cache.get 1 - 10:20:14.941 [info] Elixir.MyApp.Cache.get/2, Duration: 11 - - """ - - use Decorator.Define, before: 1, after_return: 1, around: 1 - - @enforce_keys [:step, :module, :name, :arity] - defstruct [:step, :module, :name, :arity, :return, :acc] - - @type t :: %__MODULE__{ - step: :before | :after_return, - module: Nebulex.Cache.t(), - name: atom, - arity: non_neg_integer, - return: term, - acc: term - } - - @type hook_fun :: (t -> term) - - alias Nebulex.Hook - - @doc """ - Before decorator. - - Intercepts any call to the annotated function and calls the given `fun` - before the logic is executed. - - ## Example - - defmodule MyApp.Example do - use Nebulex.Hook - - @decorate before(&inspect(&1)) - def some_fun(var) do - # logic ... - end - end - - """ - @spec before(hook_fun, term, map) :: term - def before(fun, block, context) do - with_hook([:before], fun, block, context) - end - - @doc """ - After-return decorator. - - Intercepts any call to the annotated function and calls the given `fun` - after the logic is executed, and the returned result is passed through - the `return:` attribute. - - ## Example - - defmodule MyApp.Example do - use Nebulex.Hook - - @decorate after_return(&inspect(&1)) - def some_fun(var) do - # logic ... - end - end - - """ - @spec after_return(hook_fun, term, map) :: term - def after_return(fun, block, context) do - with_hook([:after_return], fun, block, context) - end - - @doc """ - Around decorator. - - Intercepts any call to the annotated function and calls the given `fun` - before and after the logic is executed. The result of the first call to - the hook function is passed through the `acc:` attribute, so it can be - used in the next call (after return). Finally, as the `after_return` - decorator, the returned code-block evaluation is passed through the - `return:` attribute. - - ## Example - - defmodule MyApp.Profiling do - alias Nebulex.Hook - - def prof(%Hook{step: :before}) do - System.system_time(:microsecond) - end - - def prof(%Hook{step: :after_return, acc: start} = hook) do - :telemetry.execute( - [:my_app, :profiling], - %{duration: System.system_time(:microsecond) - start}, - %{module: hook.module, name: hook.name} - ) - end - end - - defmodule MyApp.Example do - use Nebulex.Hook - - @decorate around(&MyApp.Profiling.prof/1) - def some_fun(var) do - # logic ... - end - end - - """ - @spec around(hook_fun, term, map) :: term - def around(fun, block, context) do - with_hook([:before, :after_return], fun, block, context) - end - - defp with_hook(hooks, fun, block, context) do - quote do - hooks = unquote(hooks) - fun = unquote(fun) - - hook = %Nebulex.Hook{ - step: :before, - module: unquote(context.module), - name: unquote(context.name), - arity: unquote(context.arity) - } - - # eval before - acc = - if :before in hooks do - Hook.eval_hook(:before, fun, hook) - end - - # eval code-block - return = unquote(block) - - # eval after_return - if :after_return in hooks do - Hook.eval_hook( - :after_return, - fun, - %{hook | step: :after_return, return: return, acc: acc} - ) - end - - return - end - end - - @doc """ - This function is for internal purposes. - """ - @spec eval_hook(:before | :after_return, hook_fun, t) :: term - def eval_hook(step, fun, hook) do - fun.(hook) - rescue - e -> - msg = "hook execution failed on step #{inspect(step)} with error #{inspect(e)}" - reraise RuntimeError, msg, __STACKTRACE__ - end - end -end diff --git a/lib/nebulex/rpc.ex b/lib/nebulex/rpc.ex index a6b5ac88..6d744695 100644 --- a/lib/nebulex/rpc.ex +++ b/lib/nebulex/rpc.ex @@ -8,8 +8,7 @@ defmodule Nebulex.RPC do in the future in favor of `:erpc`. """ - @typedoc "Task supervisor" - @type task_sup :: Supervisor.supervisor() + import Nebulex.Helpers @typedoc "Task callback" @type callback :: {module, atom, [term]} @@ -20,8 +19,11 @@ defmodule Nebulex.RPC do @typedoc "Node group" @type node_group :: %{optional(node) => callback} | [node_callback] + @typedoc "Error kind" + @type error_kind :: :error | :exit | :throw + @typedoc "Reducer function spec" - @type reducer_fun :: ({:ok, term} | {:error, term}, node_callback | node, term -> term) + @type reducer_fun :: ({:ok, term} | {error_kind, term}, node_callback | node, term -> term) @typedoc "Reducer spec" @type reducer :: {acc :: term, reducer_fun} @@ -30,20 +32,28 @@ defmodule Nebulex.RPC do @doc """ Evaluates `apply(mod, fun, args)` on node `node` and returns the corresponding - evaluation result, or `{:badrpc, reason}` if the call fails. + evaluation result, or `{:error, Nebulex.Error.t()}` if the call fails. A timeout, in milliseconds or `:infinity`, can be given with a default value - of `5000`. It uses `Task.await/2` internally. + of `5000`. ## Example - iex> Nebulex.RPC.call(:my_task_sup, :node1, Kernel, :to_string, [1]) + iex> Nebulex.RPC.call(:node1, Kernel, :to_string, [1]) "1" """ - @spec call(task_sup, node, module, atom, [term], timeout) :: term | {:badrpc, term} - def call(supervisor, node, mod, fun, args, timeout \\ 5000) do - rpc_call(supervisor, node, mod, fun, args, timeout) + @spec call(node, module, atom, [term], timeout) :: term | {:error, Nebulex.Error.t()} + def call(node, mod, fun, args, timeout) + + def call(node, mod, fun, args, _timeout) when node == node() do + apply(mod, fun, args) + end + + def call(node, mod, fun, args, timeout) do + with {:badrpc, reason} <- :rpc.call(node, mod, fun, args, timeout) do + wrap_error Nebulex.Error, reason: {:rpc_error, {node, reason}}, module: __MODULE__ + end end @doc """ @@ -52,8 +62,6 @@ defmodule Nebulex.RPC do `apply(mod, fun, args)` on each `node_group` entry and collects the answers. Then, evaluates the `reducer` function (set in the `opts`) on each answer. - This function is similar to `:rpc.multicall/5`. - ## Options * `:timeout` - A timeout, in milliseconds or `:infinity`, can be given with @@ -64,8 +72,7 @@ defmodule Nebulex.RPC do ## Example - iex> Nebulex.RPC.multi_call( - ...> :my_task_sup, + iex> Nebulex.RPC.multicall( ...> %{ ...> node1: {Kernel, :to_string, [1]}, ...> node2: {Kernel, :to_string, [2]} @@ -85,24 +92,39 @@ defmodule Nebulex.RPC do ["1", "2"] """ - @spec multi_call(task_sup, node_group, Keyword.t()) :: term - def multi_call(supervisor, node_group, opts \\ []) do - rpc_multi_call(supervisor, node_group, opts) + @spec multicall(node_group, keyword) :: term + def multicall(node_group, opts \\ []) do + {reducer_acc, reducer_fun} = opts[:reducer] || default_reducer() + timeout = opts[:timeout] || 5000 + + for {node, {mod, fun, args}} = group <- node_group do + {:erpc.send_request(node, mod, fun, args), group} + end + |> Enum.reduce(reducer_acc, fn {req_id, group}, acc -> + try do + res = :erpc.receive_response(req_id, timeout) + + reducer_fun.({:ok, res}, group, acc) + rescue + exception in ErlangError -> + reducer_fun.({:error, exception.original}, group, acc) + catch + :exit, reason -> + reducer_fun.({:exit, reason}, group, acc) + end + end) end @doc """ - Similar to `multi_call/3` but the same `node_callback` (given by `module`, - `fun`, `args`) is executed on all `nodes`; Internally it creates a - `node_group` with the same `node_callback` for each node. + Similar to `multicall/3` but it uses `:erpc.multicall/5` under the hood. ## Options - Same options as `multi_call/3`. + Same options as `multicall/3`. ## Example - iex> Nebulex.RPC.multi_call( - ...> :my_task_sup, + iex> Nebulex.RPC.multicall( ...> [:node1, :node2], ...> Kernel, ...> :to_string, @@ -122,132 +144,58 @@ defmodule Nebulex.RPC do ["1", "1"] """ - @spec multi_call(task_sup, [node], module, atom, [term], Keyword.t()) :: term - def multi_call(supervisor, nodes, mod, fun, args, opts \\ []) do - rpc_multi_call(supervisor, nodes, mod, fun, args, opts) + @spec multicall([node], module, atom, [term], keyword) :: term + def multicall(nodes, mod, fun, args, opts \\ []) do + {reducer_acc, reducer_fun} = opts[:reducer] || default_reducer() + + nodes + |> :erpc.multicall(mod, fun, args, opts[:timeout] || 5000) + |> :lists.zip(nodes) + |> Enum.reduce(reducer_acc, fn {res, node}, acc -> + reducer_fun.(res, node, acc) + end) end ## Helpers - if Code.ensure_loaded?(:erpc) do - defp rpc_call(_supervisor, node, mod, fun, args, _timeout) when node == node() do - apply(mod, fun, args) - end - - defp rpc_call(_supervisor, node, mod, fun, args, timeout) do - :erpc.call(node, mod, fun, args, timeout) - rescue - e in ErlangError -> - case e.original do - {:exception, original, _} when is_struct(original) -> - reraise original, __STACKTRACE__ - - {:exception, original, _} -> - :erlang.raise(:error, original, __STACKTRACE__) - - other -> - reraise %Nebulex.RPCError{reason: other, node: node}, __STACKTRACE__ - end - end - - def rpc_multi_call(_supervisor, node_group, opts) do - {reducer_acc, reducer_fun} = opts[:reducer] || default_reducer() - timeout = opts[:timeout] || 5000 - - node_group - |> Enum.map(fn {node, {mod, fun, args}} = group -> - {:erpc.send_request(node, mod, fun, args), group} - end) - |> Enum.reduce(reducer_acc, fn {req_id, group}, acc -> - try do - res = :erpc.receive_response(req_id, timeout) - reducer_fun.({:ok, res}, group, acc) - rescue - exception -> - reducer_fun.({:error, exception}, group, acc) - catch - :exit, reason -> - reducer_fun.({:error, {:exit, reason}}, group, acc) - end - end) - end - - def rpc_multi_call(_supervisor, nodes, mod, fun, args, opts) do - {reducer_acc, reducer_fun} = opts[:reducer] || default_reducer() - - nodes - |> :erpc.multicall(mod, fun, args, opts[:timeout] || 5000) - |> :lists.zip(nodes) - |> Enum.reduce(reducer_acc, fn {res, node}, acc -> - reducer_fun.(res, node, acc) - end) - end - else - # TODO: This approach by using distributed tasks will be deprecated in the - # future in favor of `:erpc` which is proven to improve performance - # almost by 3x. - - defp rpc_call(_supervisor, node, mod, fun, args, _timeout) when node == node() do - apply(mod, fun, args) - rescue - # FIXME: this is because coveralls does not check this as covered - # coveralls-ignore-start - exception -> - {:badrpc, exception} - # coveralls-ignore-stop - end - - defp rpc_call(supervisor, node, mod, fun, args, timeout) do - {supervisor, node} - |> Task.Supervisor.async_nolink( - __MODULE__, - :call, - [supervisor, node, mod, fun, args, timeout] - ) - |> Task.await(timeout) - end - - defp rpc_multi_call(supervisor, node_group, opts) do - node_group - |> Enum.map(fn {node, {mod, fun, args}} -> - Task.Supervisor.async_nolink({supervisor, node}, mod, fun, args) - end) - |> handle_multi_call(node_group, opts) - end - - defp rpc_multi_call(supervisor, nodes, mod, fun, args, opts) do - rpc_multi_call(supervisor, Enum.map(nodes, &{&1, {mod, fun, args}}), opts) - end - - defp handle_multi_call(tasks, node_group, opts) do - {reducer_acc, reducer_fun} = Keyword.get(opts, :reducer, default_reducer()) + @doc """ + Helper for formatting RPC errors. + """ + @spec format_error(term) :: binary + def format_error(error) - tasks - |> Task.yield_many(opts[:timeout] || 5000) - |> :lists.zip(node_group) - |> Enum.reduce(reducer_acc, fn - {{_task, {:ok, res}}, group}, acc -> - reducer_fun.({:ok, res}, group, acc) + def format_error({:rpc_error, {node, reason}}) do + "RPC call failed on node #{inspect(node)} with reason: #{inspect(reason)}" + end - {{_task, {:exit, reason}}, group}, acc -> - reducer_fun.({:error, {:exit, reason}}, group, acc) + def format_error({:rpc_multicall_error, errors}) when is_list(errors) do + """ + RPC multicall failed with errors ([{node, error}, ...]): - {{task, nil}, group}, acc -> - _ = Task.shutdown(task, :brutal_kill) - reducer_fun.({:error, :timeout}, group, acc) - end) - end + #{inspect(errors, pretty: true)} + """ end + ## Private Functions + defp default_reducer do { {[], []}, fn + {:ok, {:ok, res}}, _node_callback, {ok, err} -> + {[res | ok], err} + + {:ok, {:error, _} = error}, node_callback, {ok, err} -> + {ok, [{node_callback, error} | err]} + {:ok, res}, _node_callback, {ok, err} -> {[res | ok], err} - {kind, _} = error, node_callback, {ok, err} when kind in [:error, :exit, :throw] -> - {ok, [{error, node_callback} | err]} + {kind, _} = error, {node, callback}, {ok, err} when kind in [:error, :exit, :throw] -> + {ok, [{node, {error, callback}} | err]} + + {kind, _} = error, node, {ok, err} when kind in [:error, :exit, :throw] -> + {ok, [{node, error} | err]} end } end diff --git a/lib/nebulex/telemetry.ex b/lib/nebulex/telemetry.ex index 3a48fe8f..2fca8450 100644 --- a/lib/nebulex/telemetry.ex +++ b/lib/nebulex/telemetry.ex @@ -7,12 +7,16 @@ defmodule Nebulex.Telemetry do @compile {:inline, execute: 3, span: 3, attach_many: 4, detach: 1} if Code.ensure_loaded?(:telemetry) do + @doc false defdelegate execute(event, measurements, metadata), to: :telemetry + @doc false defdelegate span(event_prefix, start_meta, span_fn), to: :telemetry + @doc false defdelegate attach_many(handler_id, events, fun, config), to: :telemetry + @doc false defdelegate detach(handler_id), to: :telemetry else @doc false diff --git a/lib/nebulex/telemetry/stats_handler.ex b/lib/nebulex/telemetry/stats_handler.ex index 141c560c..22dfb135 100644 --- a/lib/nebulex/telemetry/stats_handler.ex +++ b/lib/nebulex/telemetry/stats_handler.ex @@ -26,26 +26,30 @@ defmodule Nebulex.Telemetry.StatsHandler do defp update_stats(%{ function_name: action, - result: :"$expired", + result: {:error, %Nebulex.KeyError{reason: :expired}}, adapter_meta: %{stats_counter: ref} }) - when action in [:get, :take, :ttl] do + when action in [:fetch, :take, :ttl] do :ok = Stats.incr(ref, :misses) :ok = Stats.incr(ref, :evictions) :ok = Stats.incr(ref, :expirations) end - defp update_stats(%{function_name: action, result: nil, adapter_meta: %{stats_counter: ref}}) - when action in [:get, :take, :ttl] do + defp update_stats(%{ + function_name: action, + result: {:error, %Nebulex.KeyError{reason: :not_found}}, + adapter_meta: %{stats_counter: ref} + }) + when action in [:fetch, :take, :ttl] do :ok = Stats.incr(ref, :misses) end - defp update_stats(%{function_name: action, result: _, adapter_meta: %{stats_counter: ref}}) - when action in [:get, :ttl] do + defp update_stats(%{function_name: action, result: {:ok, _}, adapter_meta: %{stats_counter: ref}}) + when action in [:fetch, :ttl] do :ok = Stats.incr(ref, :hits) end - defp update_stats(%{function_name: :take, result: _, adapter_meta: %{stats_counter: ref}}) do + defp update_stats(%{function_name: :take, result: {:ok, _}, adapter_meta: %{stats_counter: ref}}) do :ok = Stats.incr(ref, :hits) :ok = Stats.incr(ref, :evictions) end @@ -53,39 +57,47 @@ defmodule Nebulex.Telemetry.StatsHandler do defp update_stats(%{ function_name: :put, args: [_, _, _, :replace, _], - result: true, + result: {:ok, true}, adapter_meta: %{stats_counter: ref} }) do :ok = Stats.incr(ref, :updates) end - defp update_stats(%{function_name: :put, result: true, adapter_meta: %{stats_counter: ref}}) do + defp update_stats(%{function_name: :put, result: {:ok, true}, adapter_meta: %{stats_counter: ref}}) do :ok = Stats.incr(ref, :writes) end defp update_stats(%{ function_name: :put_all, - result: true, + result: {:ok, true}, args: [entries | _], adapter_meta: %{stats_counter: ref} }) do :ok = Stats.incr(ref, :writes, Enum.count(entries)) end - defp update_stats(%{function_name: :delete, result: _, adapter_meta: %{stats_counter: ref}}) do + defp update_stats(%{ + function_name: :delete, + result: :ok, + adapter_meta: %{stats_counter: ref} + }) do :ok = Stats.incr(ref, :evictions) end defp update_stats(%{ function_name: :execute, args: [:delete_all | _], - result: result, + result: {:ok, result}, adapter_meta: %{stats_counter: ref} }) do :ok = Stats.incr(ref, :evictions, result) end - defp update_stats(%{function_name: action, result: true, adapter_meta: %{stats_counter: ref}}) + defp update_stats(%{ + function_name: action, + result: {:ok, true}, + adapter_meta: %{stats_counter: ref} + }) when action in [:expire, :touch] do :ok = Stats.incr(ref, :updates) end @@ -93,7 +105,7 @@ defmodule Nebulex.Telemetry.StatsHandler do defp update_stats(%{ function_name: :update_counter, args: [_, amount, _, default, _], - result: result, + result: {:ok, result}, adapter_meta: %{stats_counter: ref} }) do offset = if amount >= 0, do: -1, else: 1 diff --git a/mix.exs b/mix.exs index 894d84de..0ba50fec 100644 --- a/mix.exs +++ b/mix.exs @@ -2,7 +2,7 @@ defmodule Nebulex.MixProject do use Mix.Project @source_url "https://github.com/cabol/nebulex" - @version "2.4.2" + @version "3.0.0-dev" def project do [ @@ -48,18 +48,19 @@ defmodule Nebulex.MixProject do defp deps do [ + {:nimble_options, "~> 0.4"}, {:shards, "~> 1.0", optional: true}, {:decorator, "~> 1.4", optional: true}, {:telemetry, "~> 0.4 or ~> 1.0", optional: true}, # Test & Code Analysis - {:ex2ms, "~> 1.6", only: :test}, - {:mock, "~> 0.3", only: :test}, {:excoveralls, "~> 0.14", only: :test}, {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.2", only: [:dev, :test], runtime: false}, {:sobelow, "~> 0.11", only: [:dev, :test], runtime: false}, {:stream_data, "~> 0.5", only: [:dev, :test]}, + {:ex2ms, "~> 1.6", only: :test}, + {:mimic, "~> 1.7", only: :test}, # Benchmark Test {:benchee, "~> 1.1", only: [:dev, :test]}, @@ -108,13 +109,28 @@ defmodule Nebulex.MixProject do "guides/telemetry.md", "guides/migrating-to-v2.md", "guides/creating-new-adapter.md" + ], + groups_for_functions: [ + # Caching decorators + group_for_function("Decorator API"), + group_for_function("Internal API"), + # Cache API + group_for_function("User callbacks"), + group_for_function("Runtime API"), + group_for_function("Entry API"), + group_for_function("Query API"), + group_for_function("Persistence API"), + group_for_function("Transaction API"), + group_for_function("Stats API") ] ] end + defp group_for_function(group), do: {String.to_atom(group), &(&1[:group] == group)} + defp dialyzer do [ - plt_add_apps: [:shards, :mix, :telemetry], + plt_add_apps: [:shards, :mix, :telemetry, :ex_unit], plt_file: {:no_warn, "priv/plts/" <> plt_file_name()}, flags: [ :unmatched_returns, diff --git a/mix.lock b/mix.lock index 5e4743c6..7f9f0997 100644 --- a/mix.lock +++ b/mix.lock @@ -21,10 +21,10 @@ "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"}, - "meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, - "mock": {:hex, :mock, "0.3.7", "75b3bbf1466d7e486ea2052a73c6e062c6256fb429d6797999ab02fa32f29e03", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "4da49a4609e41fd99b7836945c26f373623ea968cfb6282742bcb94440cf7e5c"}, + "mimic": {:hex, :mimic, "1.7.4", "cd2772ffbc9edefe964bc668bfd4059487fa639a5b7f1cbdf4fd22946505aa4f", [:mix], [], "hexpm", "437c61041ecf8a7fae35763ce89859e4973bb0666e6ce76d75efc789204447c3"}, + "nimble_options": {:hex, :nimble_options, "0.5.1", "5c166f7669e40333191bea38e3bd3811cc13f459f1e4be49e89128a21b5d8c4d", [:mix], [], "hexpm", "d176cf7baa4fef0ceb301ca3eb8b55bd7de3e45f489c4f8b4f2849f1f114ef3e"}, "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, "shards": {:hex, :shards, "1.0.1", "1bdbbf047db27f3c3eb800a829d4a47062c84d5543cbfebcfc4c14d038bf9220", [:make, :rebar3], [], "hexpm", "2c57788afbf053c4024366772892beee89b8b72e884e764fb0a075dfa7442041"}, diff --git a/test/dialyzer/caching_decorators.ex b/test/dialyzer/caching_decorators.ex index 14ae56ef..0455391d 100644 --- a/test/dialyzer/caching_decorators.ex +++ b/test/dialyzer/caching_decorators.ex @@ -19,7 +19,12 @@ defmodule Nebulex.Dialyzer.CachingDecorators do end @spec get_account_by_username(binary) :: Account.t() - @decorate cacheable(cache: Cache, key: {Account, username}, opts: [ttl: @ttl]) + @decorate cacheable( + cache: Cache, + key: {Account, username}, + references: & &1.id, + opts: [ttl: @ttl] + ) def get_account_by_username(username) do %Account{username: username} end @@ -47,20 +52,20 @@ defmodule Nebulex.Dialyzer.CachingDecorators do acct end - @spec delete_all_accounts(term) :: :ok + @spec delete_all_accounts(term) :: term @decorate cache_evict(cache: Cache, all_entries: true) def delete_all_accounts(filter) do filter end - @spec get_user_key(integer) :: binary + @spec get_user_key(binary) :: binary @decorate cacheable( cache: {__MODULE__, :dynamic_cache, [:dynamic]}, key_generator: {__MODULE__, [id]} ) def get_user_key(id), do: id - @spec update_user_key(integer) :: binary + @spec update_user_key(binary) :: binary @decorate cacheable(cache: Cache, key_generator: {__MODULE__, :generate_key, [id]}) def update_user_key(id), do: id diff --git a/test/mix/nebulex_test.exs b/test/mix/nebulex_test.exs index 505aa21c..b319798d 100644 --- a/test/mix/nebulex_test.exs +++ b/test/mix/nebulex_test.exs @@ -1,14 +1,15 @@ defmodule Mix.NebulexTest do use ExUnit.Case, async: true + use Mimic import Mix.Nebulex - import Mock test "fail because umbrella project" do - with_mock Mix.Project, umbrella?: fn -> true end do - assert_raise Mix.Error, ~r"Cannot run task", fn -> - no_umbrella!("nebulex.gen.cache") - end + Mix.Project + |> expect(:umbrella?, fn -> true end) + + assert_raise Mix.Error, ~r"Cannot run task", fn -> + no_umbrella!("nebulex.gen.cache") end end end diff --git a/test/nebulex/adapters/local/generation_test.exs b/test/nebulex/adapters/local/generation_test.exs index 3503e3d1..21787ae8 100644 --- a/test/nebulex/adapters/local/generation_test.exs +++ b/test/nebulex/adapters/local/generation_test.exs @@ -82,23 +82,23 @@ defmodule Nebulex.Adapters.Local.GenerationTest do test "error: invalid gc_cleanup_min_timeout" do _ = Process.flag(:trap_exit, true) - assert {:error, {:shutdown, {_, _, {:shutdown, {_, _, {%ArgumentError{message: err}, _}}}}}} = + assert {:error, {%ArgumentError{message: err}, _}} = LocalWithSizeLimit.start_link( gc_interval: 3600, gc_cleanup_min_timeout: -1, gc_cleanup_max_timeout: -1 ) - assert err == "expected gc_cleanup_min_timeout: to be an integer > 0, got: -1" + assert Regex.match?(~r/invalid value for :gc_cleanup_min_timeout/, err) end end describe "gc" do - setup_with_dynamic_cache(Cache, :gc_test, - backend: :shards, - gc_interval: 1000, - compressed: true - ) + setup_with_dynamic_cache Cache, + :gc_test, + backend: :shards, + gc_interval: 1000, + compressed: true test "create generations", %{cache: cache, name: name} do assert generations_len(name) == 1 @@ -106,7 +106,7 @@ defmodule Nebulex.Adapters.Local.GenerationTest do :ok = Process.sleep(1020) assert generations_len(name) == 2 - assert cache.delete_all() == 0 + assert cache.delete_all!() == 0 :ok = Process.sleep(1020) assert generations_len(name) == 2 @@ -251,11 +251,10 @@ defmodule Nebulex.Adapters.Local.GenerationTest do ) assert generations_len(LocalWithSizeLimit) == 1 - assert LocalWithSizeLimit.count_all() == 0 + assert LocalWithSizeLimit.count_all!() == 0 _ = cache_put(LocalWithSizeLimit, 1..4) - - assert LocalWithSizeLimit.count_all() == 4 + assert LocalWithSizeLimit.count_all!() == 4 :ok = Process.sleep(1100) @@ -267,17 +266,17 @@ defmodule Nebulex.Adapters.Local.GenerationTest do _ = cache_put(LocalWithSizeLimit, 5..8) - assert LocalWithSizeLimit.count_all() == 4 + assert LocalWithSizeLimit.count_all!() == 4 :ok = Process.sleep(1100) assert generations_len(LocalWithSizeLimit) == 2 - assert LocalWithSizeLimit.count_all() == 4 + assert LocalWithSizeLimit.count_all!() == 4 :ok = Process.sleep(1100) assert generations_len(LocalWithSizeLimit) == 2 - assert LocalWithSizeLimit.count_all() == 0 + assert LocalWithSizeLimit.count_all!() == 0 :ok = LocalWithSizeLimit.stop() end diff --git a/test/nebulex/adapters/local_duplicate_keys_test.exs b/test/nebulex/adapters/local_duplicate_keys_test.exs index d3927b70..f7921214 100644 --- a/test/nebulex/adapters/local_duplicate_keys_test.exs +++ b/test/nebulex/adapters/local_duplicate_keys_test.exs @@ -35,23 +35,23 @@ defmodule Nebulex.Adapters.LocalDuplicateKeysTest do for_all_caches(caches, fn cache -> :ok = cache.put_all(a: 1, a: 2, a: 2, b: 1, b: 2, c: 1) - assert cache.get(:a) == [1, 2, 2] - assert cache.get(:b) == [1, 2] - assert cache.get(:c) == 1 + assert cache.get!(:a) == [1, 2, 2] + assert cache.get!(:b) == [1, 2] + assert cache.get!(:c) == 1 - assert cache.get_all([:a, :b, :c]) == %{a: [1, 2, 2], b: [1, 2], c: 1} + assert cache.get_all!([:a, :b, :c]) == %{a: [1, 2, 2], b: [1, 2], c: 1} end) end - test "take", %{caches: caches} do + test "take!", %{caches: caches} do for_all_caches(caches, fn cache -> :ok = cache.put_all(a: 1, a: 2, a: 2, b: 1, b: 2, c: 1) - assert cache.take(:a) == [1, 2, 2] - assert cache.take(:b) == [1, 2] - assert cache.take(:c) == 1 + assert cache.take!(:a) == [1, 2, 2] + assert cache.take!(:b) == [1, 2] + assert cache.take!(:c) == 1 - assert cache.get_all([:a, :b, :c]) == %{} + assert cache.get_all!([:a, :b, :c]) == %{} end) end @@ -61,29 +61,29 @@ defmodule Nebulex.Adapters.LocalDuplicateKeysTest do :ok = cache.put(:a, 2) :ok = cache.put(:a, 2) - assert cache.get(:a) == [1, 2, 2] - assert cache.delete(:a) - refute cache.get(:a) + assert cache.get!(:a) == [1, 2, 2] + assert cache.delete!(:a) == :ok + refute cache.get!(:a) end) end test "put_new", %{caches: caches} do for_all_caches(caches, fn cache -> - assert cache.put_new(:a, 1) + assert cache.put_new(:a, 1) == {:ok, true} :ok = cache.put(:a, 2) - refute cache.put_new(:a, 3) + assert cache.put_new(:a, 3) == {:ok, false} - assert cache.get(:a) == [1, 2] + assert cache.get!(:a) == [1, 2] end) end - test "has_key?", %{caches: caches} do + test "exists?", %{caches: caches} do for_all_caches(caches, fn cache -> :ok = cache.put(:a, 1) :ok = cache.put(:a, 2) - assert cache.has_key?(:a) - refute cache.has_key?(:b) + assert cache.exists?(:a) == {:ok, true} + assert cache.exists?(:b) == {:ok, false} end) end @@ -93,12 +93,12 @@ defmodule Nebulex.Adapters.LocalDuplicateKeysTest do :ok = cache.put(:a, 2, ttl: 10_000) :ok = cache.put(:a, 3) - [ttl1, ttl2, ttl3] = cache.ttl(:a) + {:ok, [ttl1, ttl2, ttl3]} = cache.ttl(:a) assert ttl1 > 1000 assert ttl2 > 6000 assert ttl3 == :infinity - refute cache.ttl(:b) + assert {:error, %Nebulex.KeyError{key: :b}} = cache.ttl(:b) end) end @@ -106,9 +106,9 @@ defmodule Nebulex.Adapters.LocalDuplicateKeysTest do for_all_caches(caches, fn cache -> :ok = cache.put_all(a: 1, a: 2, a: 2, b: 1, b: 2, c: 1) - assert cache.count_all() == 6 - assert cache.delete_all() == 6 - assert cache.count_all() == 0 + assert cache.count_all!() == 6 + assert cache.delete_all!() == 6 + assert cache.count_all!() == 0 end) end @@ -121,8 +121,8 @@ defmodule Nebulex.Adapters.LocalDuplicateKeysTest do {_, key, value, _, _} when value == 2 -> key end - res_stream = test_ms |> cache.stream() |> Enum.to_list() |> Enum.sort() - res_query = test_ms |> cache.all() |> Enum.sort() + res_stream = test_ms |> cache.stream!() |> Enum.to_list() |> Enum.sort() + res_query = test_ms |> cache.all!() |> Enum.sort() assert res_stream == [:a, :a, :b] assert res_query == res_stream diff --git a/test/nebulex/adapters/local_error_test.exs b/test/nebulex/adapters/local_error_test.exs new file mode 100644 index 00000000..ad68f648 --- /dev/null +++ b/test/nebulex/adapters/local_error_test.exs @@ -0,0 +1,23 @@ +defmodule Nebulex.Adapters.LocalErrorTest do + use ExUnit.Case, async: true + use Mimic + + # Inherit error tests + use Nebulex.Cache.EntryErrorTest + use Nebulex.Cache.EntryExpirationErrorTest + + setup do + Nebulex.Cache.Registry + |> expect(:lookup, fn _ -> {:ok, %{adapter: Nebulex.FakeAdapter}} end) + + {:ok, cache: Nebulex.TestCache.Cache, name: :local_error_cache} + end + + describe "put!/3" do + test "raises an error", %{cache: cache} do + assert_raise RuntimeError, ~r"runtime error", fn -> + cache.put!(:error, %RuntimeError{}) + end + end + end +end diff --git a/test/nebulex/adapters/local_ets_test.exs b/test/nebulex/adapters/local_ets_test.exs index 4619b5bc..fc8da49b 100644 --- a/test/nebulex/adapters/local_ets_test.exs +++ b/test/nebulex/adapters/local_ets_test.exs @@ -1,18 +1,20 @@ defmodule Nebulex.Adapters.LocalEtsTest do use ExUnit.Case, async: true + + # Inherit tests use Nebulex.LocalTest use Nebulex.CacheTest - import Nebulex.CacheCase + import Nebulex.CacheCase, only: [setup_with_dynamic_cache: 3] alias Nebulex.Adapter alias Nebulex.TestCache.Cache - setup_with_dynamic_cache(Cache, :local_with_ets, purge_batch_size: 10) + setup_with_dynamic_cache Cache, :local_with_ets, purge_chunk_size: 10 describe "ets" do test "backend", %{name: name} do - Adapter.with_meta(name, fn _, meta -> + Adapter.with_meta(name, fn meta -> assert meta.backend == :ets end) end diff --git a/test/nebulex/adapters/local_shards_test.exs b/test/nebulex/adapters/local_shards_test.exs index 7e138975..e34a9cec 100644 --- a/test/nebulex/adapters/local_shards_test.exs +++ b/test/nebulex/adapters/local_shards_test.exs @@ -1,18 +1,20 @@ defmodule Nebulex.Adapters.LocalWithShardsTest do use ExUnit.Case, async: true + + # Inherit tests use Nebulex.LocalTest use Nebulex.CacheTest - import Nebulex.CacheCase + import Nebulex.CacheCase, only: [setup_with_dynamic_cache: 3] alias Nebulex.Adapter alias Nebulex.TestCache.Cache - setup_with_dynamic_cache(Cache, :local_with_shards, backend: :shards) + setup_with_dynamic_cache Cache, :local_with_shards, backend: :shards describe "shards" do test "backend", %{name: name} do - Adapter.with_meta(name, fn _, meta -> + Adapter.with_meta(name, fn meta -> assert meta.backend == :shards end) end diff --git a/test/nebulex/adapters/multilevel_concurrency_test.exs b/test/nebulex/adapters/multilevel_concurrency_test.exs index eefcf01c..a4c6cd0b 100644 --- a/test/nebulex/adapters/multilevel_concurrency_test.exs +++ b/test/nebulex/adapters/multilevel_concurrency_test.exs @@ -3,8 +3,6 @@ defmodule Nebulex.Adapters.MultilevelConcurrencyTest do import Nebulex.CacheCase - alias Nebulex.TestCache.Multilevel.L2 - defmodule SleeperMock do @moduledoc false @behaviour Nebulex.Adapter @@ -13,20 +11,16 @@ defmodule Nebulex.Adapters.MultilevelConcurrencyTest do alias Nebulex.Adapters.Local + ## Callbacks + @impl true defmacro __before_compile__(_), do: :ok @impl true defdelegate init(opts), to: Local - def post(opts) do - with f when is_function(f) <- opts[:post] do - f.() - end - end - @impl true - defdelegate get(meta, key, opts), to: Local + defdelegate fetch(meta, key, opts), to: Local @impl true defdelegate put(meta, key, value, ttl, on_write, opts), to: Local @@ -34,7 +28,9 @@ defmodule Nebulex.Adapters.MultilevelConcurrencyTest do @impl true def delete(meta, key, opts) do result = Local.delete(meta, key, opts) + post(opts) + result end @@ -42,16 +38,16 @@ defmodule Nebulex.Adapters.MultilevelConcurrencyTest do defdelegate take(meta, key, opts), to: Local @impl true - defdelegate has_key?(meta, key), to: Local + defdelegate exists?(meta, key, opts), to: Local @impl true - defdelegate ttl(meta, key), to: Local + defdelegate ttl(meta, key, opts), to: Local @impl true - defdelegate expire(meta, key, ttl), to: Local + defdelegate expire(meta, key, ttl, opts), to: Local @impl true - defdelegate touch(meta, key), to: Local + defdelegate touch(meta, key, opts), to: Local @impl true defdelegate update_counter(meta, key, amount, ttl, default, opts), to: Local @@ -65,12 +61,22 @@ defmodule Nebulex.Adapters.MultilevelConcurrencyTest do @impl true def execute(meta, operation, query, opts) do result = Local.execute(meta, operation, query, opts) + post(opts) + result end @impl true defdelegate stream(meta, query, opts), to: Local + + ## Helpers + + def post(opts) do + with f when is_function(f) <- opts[:post] do + f.() + end + end end defmodule L1 do @@ -79,6 +85,12 @@ defmodule Nebulex.Adapters.MultilevelConcurrencyTest do adapter: SleeperMock end + defmodule L2 do + use Nebulex.Cache, + otp_app: :nebulex, + adapter: Nebulex.Adapters.Replicated + end + defmodule Multilevel do use Nebulex.Cache, otp_app: :nebulex, @@ -99,11 +111,11 @@ defmodule Nebulex.Adapters.MultilevelConcurrencyTest do test "deletes in reverse order", %{cache: cache} do test_pid = self() - assert :ok = cache.put("foo", "stale") + :ok = cache.put!("foo", "stale") task = Task.async(fn -> - cache.delete("foo", + cache.delete!("foo", post: fn -> send(test_pid, :deleted_in_l1) @@ -118,11 +130,14 @@ defmodule Nebulex.Adapters.MultilevelConcurrencyTest do end) assert_receive :deleted_in_l1 - refute cache.get("foo") - send(task.pid, :continue) + refute cache.get!("foo") + + _ = send(task.pid, :continue) + assert Task.await(task) == :ok - assert cache.get("foo", level: 1) == nil - assert cache.get("foo", level: 2) == nil + + assert cache.get!("foo", nil, level: 1) == nil + assert cache.get!("foo", nil, level: 2) == nil end end @@ -130,11 +145,11 @@ defmodule Nebulex.Adapters.MultilevelConcurrencyTest do test "deletes in reverse order", %{cache: cache} do test_pid = self() - assert :ok = cache.put_all(%{a: "stale", b: "stale"}) + :ok = cache.put_all!(%{a: "stale", b: "stale"}) task = Task.async(fn -> - cache.delete_all(nil, + cache.delete_all!(nil, post: fn -> send(test_pid, :deleted_in_l1) @@ -149,11 +164,15 @@ defmodule Nebulex.Adapters.MultilevelConcurrencyTest do end) assert_receive :deleted_in_l1 - refute cache.get(:a) - refute cache.get(:b) - send(task.pid, :continue) + + refute cache.get!(:a) + refute cache.get!(:b) + + _ = send(task.pid, :continue) + assert Task.await(task) == 4 - assert cache.get_all([:a, :b]) == %{} + + assert cache.get_all!([:a, :b]) == %{} end end end diff --git a/test/nebulex/adapters/multilevel_error_test.exs b/test/nebulex/adapters/multilevel_error_test.exs new file mode 100644 index 00000000..5fc830cc --- /dev/null +++ b/test/nebulex/adapters/multilevel_error_test.exs @@ -0,0 +1,84 @@ +defmodule Nebulex.Adapters.MultilevelErrorTest do + use ExUnit.Case, async: false + use Mimic + + import Nebulex.CacheCase, only: [setup_with_dynamic_cache: 3] + + alias Nebulex.TestCache.Multilevel + alias Nebulex.TestCache.Multilevel.{L1, L2, L3} + + @gc_interval :timer.hours(1) + + @levels [ + {L1, name: :multilevel_error_cache_l1, gc_interval: @gc_interval}, + {L2, name: :multilevel_error_cache_l2, primary: [gc_interval: @gc_interval]}, + {L3, name: :multilevel_error_cache_l3, primary: [gc_interval: @gc_interval]} + ] + + setup_with_dynamic_cache Multilevel, :multilevel_error_cache, levels: @levels + + describe "cache level error" do + test "fetch/2", %{cache: cache} do + L1 + |> expect(:fetch, fn _, _ -> {:error, %Nebulex.Error{reason: :error}} end) + + assert cache.fetch(1) == {:error, %Nebulex.Error{module: Nebulex.Error, reason: :error}} + end + + test "get_all/2", %{cache: cache} do + L1 + |> expect(:get_all, fn _, _ -> {:error, %Nebulex.Error{reason: :error}} end) + + assert cache.get_all(1) == {:error, %Nebulex.Error{module: Nebulex.Error, reason: :error}} + end + + test "put/3", %{cache: cache} do + L1 + |> expect(:put, fn _, _, _ -> {:error, %Nebulex.Error{reason: :error}} end) + + assert cache.put("hello", "world") == + {:error, %Nebulex.Error{module: Nebulex.Error, reason: :error}} + end + + test "put_all/2", %{cache: cache} do + L1 + |> expect(:put_all, fn _, _ -> {:error, %Nebulex.Error{reason: :error}} end) + + assert cache.put_all(%{"apples" => 1, "bananas" => 3}) == + {:error, %Nebulex.Error{module: Nebulex.Error, reason: :error}} + end + + test "exists?/1", %{cache: cache} do + L1 + |> expect(:exists?, fn _, _ -> {:error, %Nebulex.Error{reason: :error}} end) + + assert cache.exists?("error") == + {:error, %Nebulex.Error{module: Nebulex.Error, reason: :error}} + end + + test "expire!/2", %{cache: cache} do + L1 + |> expect(:expire, fn _, _, _ -> {:error, %Nebulex.Error{reason: :error}} end) + + assert_raise Nebulex.Error, ~r"Nebulex error:\n\n:error", fn -> + cache.expire!(:raise, 100) + end + end + + test "touch!/1", %{cache: cache} do + L1 + |> expect(:touch, fn _, _ -> {:error, %Nebulex.Error{reason: :error}} end) + + assert_raise Nebulex.Error, ~r"Nebulex error:\n\n:error", fn -> + cache.touch!(:raise) + end + end + + test "ttl/1", %{cache: cache} do + L1 + |> expect(:ttl, fn _, _ -> {:error, %Nebulex.Error{reason: :error}} end) + + assert cache.ttl(1) == {:error, %Nebulex.Error{module: Nebulex.Error, reason: :error}} + end + end +end diff --git a/test/nebulex/adapters/multilevel_exclusive_test.exs b/test/nebulex/adapters/multilevel_exclusive_test.exs index 16e4c0e2..ab07aa44 100644 --- a/test/nebulex/adapters/multilevel_exclusive_test.exs +++ b/test/nebulex/adapters/multilevel_exclusive_test.exs @@ -1,11 +1,12 @@ defmodule Nebulex.Adapters.MultilevelExclusiveTest do - use ExUnit.Case, async: true use Nebulex.NodeCase + + # Inherit tests use Nebulex.MultilevelTest use Nebulex.Cache.QueryableTest use Nebulex.Cache.TransactionTest - import Nebulex.CacheCase + import Nebulex.CacheCase, only: [setup_with_dynamic_cache: 3] alias Nebulex.Adapters.Local.Generation alias Nebulex.Cache.Cluster @@ -30,10 +31,10 @@ defmodule Nebulex.Adapters.MultilevelExclusiveTest do } ] - setup_with_dynamic_cache(Multilevel, :multilevel_exclusive, - model: :exclusive, - levels: @levels - ) + setup_with_dynamic_cache Multilevel, + :multilevel_exclusive, + model: :exclusive, + levels: @levels describe "multilevel exclusive" do test "returns partitions for L1 with shards backend", %{name: name} do @@ -48,15 +49,15 @@ defmodule Nebulex.Adapters.MultilevelExclusiveTest do :ok = Multilevel.put(2, 2, level: 2) :ok = Multilevel.put(3, 3, level: 3) - assert Multilevel.get(1) == 1 - assert Multilevel.get(2, return: :key) == 2 - assert Multilevel.get(3) == 3 - refute Multilevel.get(2, level: 1) - refute Multilevel.get(3, level: 1) - refute Multilevel.get(1, level: 2) - refute Multilevel.get(3, level: 2) - refute Multilevel.get(1, level: 3) - refute Multilevel.get(2, level: 3) + assert Multilevel.get!(1) == 1 + assert Multilevel.get!(2, return: :key) == 2 + assert Multilevel.get!(3) == 3 + refute Multilevel.get!(2, nil, level: 1) + refute Multilevel.get!(3, nil, level: 1) + refute Multilevel.get!(1, nil, level: 2) + refute Multilevel.get!(3, nil, level: 2) + refute Multilevel.get!(1, nil, level: 3) + refute Multilevel.get!(2, nil, level: 3) end end @@ -84,7 +85,7 @@ defmodule Nebulex.Adapters.MultilevelExclusiveTest do assert Multilevel.put_all(kv_pairs) == :ok for k <- 1..100 do - assert Multilevel.get(k) == k + assert Multilevel.get!(k) == k end end) diff --git a/test/nebulex/adapters/multilevel_inclusive_test.exs b/test/nebulex/adapters/multilevel_inclusive_test.exs index 97fad3bf..18f4e5ac 100644 --- a/test/nebulex/adapters/multilevel_inclusive_test.exs +++ b/test/nebulex/adapters/multilevel_inclusive_test.exs @@ -1,11 +1,12 @@ defmodule Nebulex.Adapters.MultilevelInclusiveTest do - use ExUnit.Case, async: true use Nebulex.NodeCase + + # Inherit tests use Nebulex.MultilevelTest use Nebulex.Cache.QueryableTest use Nebulex.Cache.TransactionTest - import Nebulex.CacheCase + import Nebulex.CacheCase, only: [setup_with_dynamic_cache: 3] alias Nebulex.Adapters.Local.Generation alias Nebulex.Cache.Cluster @@ -30,10 +31,10 @@ defmodule Nebulex.Adapters.MultilevelInclusiveTest do } ] - setup_with_dynamic_cache(Multilevel, :multilevel_inclusive, - model: :inclusive, - levels: @levels - ) + setup_with_dynamic_cache Multilevel, + :multilevel_inclusive, + model: :inclusive, + levels: @levels describe "multilevel inclusive" do test "returns partitions for L1 with shards backend", %{name: name} do @@ -49,23 +50,23 @@ defmodule Nebulex.Adapters.MultilevelInclusiveTest do :ok = Multilevel.put(2, 2, level: 2) :ok = Multilevel.put(3, 3, level: 3) - assert Multilevel.get(1) == 1 - refute Multilevel.get(1, level: 2) - refute Multilevel.get(1, level: 3) + assert Multilevel.get!(1) == 1 + refute Multilevel.get!(1, nil, level: 2) + refute Multilevel.get!(1, nil, level: 3) - assert Multilevel.get(2) == 2 - assert Multilevel.get(2, level: 1) == 2 - assert Multilevel.get(2, level: 2) == 2 - refute Multilevel.get(2, level: 3) + assert Multilevel.get!(2) == 2 + assert Multilevel.get!(2, nil, level: 1) == 2 + assert Multilevel.get!(2, nil, level: 2) == 2 + refute Multilevel.get!(2, nil, level: 3) - assert Multilevel.get(3, level: 3) == 3 - refute Multilevel.get(3, level: 1) - refute Multilevel.get(3, level: 2) + assert Multilevel.get!(3, nil, level: 3) == 3 + refute Multilevel.get!(3, nil, level: 1) + refute Multilevel.get!(3, nil, level: 2) - assert Multilevel.get(3) == 3 - assert Multilevel.get(3, level: 1) == 3 - assert Multilevel.get(3, level: 2) == 3 - assert Multilevel.get(3, level: 2) == 3 + assert Multilevel.get!(3) == 3 + assert Multilevel.get!(3, nil, level: 1) == 3 + assert Multilevel.get!(3, nil, level: 2) == 3 + assert Multilevel.get!(3, nil, level: 2) == 3 end test "fetched value is replicated with TTL on previous levels" do @@ -73,27 +74,27 @@ defmodule Nebulex.Adapters.MultilevelInclusiveTest do assert Multilevel.ttl(:a) > 0 :ok = Process.sleep(1100) - refute Multilevel.get(:a, level: 1) - refute Multilevel.get(:a, level: 2) - refute Multilevel.get(:a, level: 3) + refute Multilevel.get!(:a, nil, level: 1) + refute Multilevel.get!(:a, nil, level: 2) + refute Multilevel.get!(:a, nil, level: 3) assert Multilevel.put(:b, 1, level: 3) == :ok - assert Multilevel.ttl(:b) == :infinity - assert Multilevel.expire(:b, 1000) - assert Multilevel.ttl(:b) > 0 - refute Multilevel.get(:b, level: 1) - refute Multilevel.get(:b, level: 2) - assert Multilevel.get(:b, level: 3) == 1 - - assert Multilevel.get(:b) == 1 - assert Multilevel.get(:b, level: 1) == 1 - assert Multilevel.get(:b, level: 2) == 1 - assert Multilevel.get(:b, level: 3) == 1 + assert Multilevel.ttl!(:b) == :infinity + assert Multilevel.expire!(:b, 1000) + assert Multilevel.ttl!(:b) > 0 + refute Multilevel.get!(:b, nil, level: 1) + refute Multilevel.get!(:b, nil, level: 2) + assert Multilevel.get!(:b, nil, level: 3) == 1 + + assert Multilevel.get!(:b) == 1 + assert Multilevel.get!(:b, nil, level: 1) == 1 + assert Multilevel.get!(:b, nil, level: 2) == 1 + assert Multilevel.get!(:b, nil, level: 3) == 1 :ok = Process.sleep(1100) - refute Multilevel.get(:b, level: 1) - refute Multilevel.get(:b, level: 2) - refute Multilevel.get(:b, level: 3) + refute Multilevel.get!(:b, nil, level: 1) + refute Multilevel.get!(:b, nil, level: 2) + refute Multilevel.get!(:b, nil, level: 3) end end @@ -122,7 +123,7 @@ defmodule Nebulex.Adapters.MultilevelInclusiveTest do assert Multilevel.put_all(kv_pairs) == :ok for k <- 1..100 do - assert Multilevel.get(k) == k + assert Multilevel.get!(k) == k end end) diff --git a/test/nebulex/adapters/nil_test.exs b/test/nebulex/adapters/nil_test.exs index bbacb002..0ba04b83 100644 --- a/test/nebulex/adapters/nil_test.exs +++ b/test/nebulex/adapters/nil_test.exs @@ -15,117 +15,107 @@ defmodule Nebulex.Adapters.NilTest do describe "entry" do property "put", %{cache: cache} do check all term <- term() do - refute cache.get(term) + assert cache.exists?(term) == {:ok, false} - assert cache.replace(term, term) + assert cache.replace(term, term) == {:ok, true} assert cache.put(term, term) == :ok - assert cache.put_new(term, term) - refute cache.get(term) + assert cache.put_new(term, term) == {:ok, true} + assert cache.exists?(term) == {:ok, false} end end test "put_all", %{cache: cache} do assert cache.put_all(a: 1, b: 2, c: 3) == :ok - refute cache.get(:a) - refute cache.get(:b) - refute cache.get(:c) + assert cache.exists?(:a) == {:ok, false} + assert cache.exists?(:b) == {:ok, false} + assert cache.exists?(:c) == {:ok, false} end - test "get", %{cache: cache} do - assert cache.put("foo", "bar") == :ok - refute cache.get("foo") + test "fetch", %{cache: cache} do + assert {:error, %Nebulex.KeyError{key: "foo"}} = cache.fetch("foo") end test "get_all", %{cache: cache} do - assert cache.put("foo", "bar") == :ok - assert cache.get_all("foo") == %{} + assert cache.get_all("foo") == {:ok, %{}} end test "delete", %{cache: cache} do - assert cache.put("foo", "bar") == :ok assert cache.delete("foo") == :ok end test "take", %{cache: cache} do - assert cache.put("foo", "bar") == :ok - refute cache.take("foo") + assert {:error, %Nebulex.KeyError{key: "foo"}} = cache.take("foo") end - test "has_key?", %{cache: cache} do - assert cache.put("foo", "bar") == :ok - refute cache.has_key?("foo") + test "exists?", %{cache: cache} do + assert cache.exists?("foo") == {:ok, false} end test "ttl", %{cache: cache} do - assert cache.put("foo", "bar") == :ok - refute cache.ttl("foo") + assert {:error, %Nebulex.KeyError{key: "foo"}} = cache.ttl("foo") end test "expire", %{cache: cache} do - assert cache.put("foo", "bar") == :ok - assert cache.expire("foo", 1000) - refute cache.get("foo") + assert cache.expire("foo", 1000) == {:ok, false} end test "touch", %{cache: cache} do - assert cache.put("foo", "bar") == :ok - assert cache.touch("foo") - refute cache.get("foo") + assert cache.touch("foo") == {:ok, false} end test "incr", %{cache: cache} do - assert cache.incr(:counter) == 1 - assert cache.incr(:counter, 10) == 10 - assert cache.incr(:counter, -10) == -10 - assert cache.incr(:counter, 5, default: 10) == 15 - assert cache.incr(:counter, -5, default: 10) == 5 + assert cache.incr!(:counter) == 1 + assert cache.incr!(:counter, 10) == 10 + assert cache.incr!(:counter, -10) == -10 + assert cache.incr!(:counter, 5, default: 10) == 15 + assert cache.incr!(:counter, -5, default: 10) == 5 end test "decr", %{cache: cache} do - assert cache.decr(:counter) == -1 - assert cache.decr(:counter, 10) == -10 - assert cache.decr(:counter, -10) == 10 - assert cache.decr(:counter, 5, default: 10) == 5 - assert cache.decr(:counter, -5, default: 10) == 15 + assert cache.decr!(:counter) == -1 + assert cache.decr!(:counter, 10) == -10 + assert cache.decr!(:counter, -10) == 10 + assert cache.decr!(:counter, 5, default: 10) == 5 + assert cache.decr!(:counter, -5, default: 10) == 15 end end describe "queryable" do test "all", %{cache: cache} do assert cache.put("foo", "bar") == :ok - assert cache.all() == [] + assert cache.all!() == [] end test "stream", %{cache: cache} do assert cache.put("foo", "bar") == :ok - assert cache.stream() |> Enum.to_list() == [] + assert cache.stream!() |> Enum.to_list() == [] end test "count_all", %{cache: cache} do assert cache.put("foo", "bar") == :ok - assert cache.count_all() == 0 + assert cache.count_all!() == 0 end test "delete_all", %{cache: cache} do assert cache.put("foo", "bar") == :ok - assert cache.delete_all() == 0 + assert cache.delete_all!() == 0 end end describe "transaction" do test "single transaction", %{cache: cache} do - refute cache.transaction(fn -> + assert cache.transaction(fn -> :ok = cache.put("foo", "bar") - cache.get("foo") - end) + cache.get!("foo") + end) == {:ok, nil} end test "in_transaction?", %{cache: cache} do - refute cache.in_transaction?() + assert cache.in_transaction?() == {:ok, false} cache.transaction(fn -> :ok = cache.put(1, 11, return: :key) - true = cache.in_transaction?() + {:ok, true} = cache.in_transaction?() end) end end @@ -139,15 +129,15 @@ defmodule Nebulex.Adapters.NilTest do assert cache.dump(path) == :ok assert cache.load(path) == :ok - assert cache.count_all() == 0 + assert cache.count_all!() == 0 end end describe "stats" do test "stats/0", %{cache: cache} do assert cache.put("foo", "bar") == :ok - refute cache.get("foo") - assert cache.stats() == %Nebulex.Stats{} + refute cache.get!("foo") + assert cache.stats() == {:ok, %Nebulex.Stats{}} end end diff --git a/test/nebulex/adapters/partitioned_error_test.exs b/test/nebulex/adapters/partitioned_error_test.exs new file mode 100644 index 00000000..8ba1abfd --- /dev/null +++ b/test/nebulex/adapters/partitioned_error_test.exs @@ -0,0 +1,27 @@ +defmodule Nebulex.Adapters.PartitionedErrorTest do + use ExUnit.Case, async: true + use Mimic + + # Inherit error tests + use Nebulex.Cache.EntryErrorTest + use Nebulex.Cache.EntryExpirationErrorTest + + import Nebulex.CacheCase, only: [setup_with_dynamic_cache: 2] + + setup_with_dynamic_cache Nebulex.TestCache.Partitioned, :partitioned_error_cache + + setup do + Nebulex.RPC + |> stub(:call, fn _, _, _, _, _ -> {:error, %Nebulex.Error{reason: :error}} end) + |> stub(:multicall, fn _ -> {[], [:error]} end) + |> stub(:multicall, fn _, _ -> {[], [:error]} end) + |> stub(:multicall, fn _, _, _, _ -> {[], [:error]} end) + |> stub(:multicall, fn _, _, _, _, _ -> {[], [:error]} end) + + {:ok, + %{ + error_module: &(&1 in [Nebulex.Error, Nebulex.RPC]), + error_reason: &(&1 in [:error, {:rpc_multicall_error, [:error]}]) + }} + end +end diff --git a/test/nebulex/adapters/partitioned_test.exs b/test/nebulex/adapters/partitioned_test.exs index dc0bee95..e5766641 100644 --- a/test/nebulex/adapters/partitioned_test.exs +++ b/test/nebulex/adapters/partitioned_test.exs @@ -1,8 +1,9 @@ defmodule Nebulex.Adapters.PartitionedTest do use Nebulex.NodeCase + + # Inherit tests use Nebulex.CacheTest - import Nebulex.CacheCase import Nebulex.Helpers alias Nebulex.Adapter @@ -11,8 +12,10 @@ defmodule Nebulex.Adapters.PartitionedTest do @primary :"primary@127.0.0.1" @cache_name :partitioned_cache - # Set config - :ok = Application.put_env(:nebulex, Partitioned, primary: [backend: :shards]) + setup_all do + # Set config + :ok = Application.put_env(:nebulex, Partitioned, primary: [backend: :shards]) + end setup do cluster = :lists.usort([@primary | Application.get_env(:nebulex, :nodes, [])]) @@ -31,17 +34,25 @@ defmodule Nebulex.Adapters.PartitionedTest do on_exit(fn -> _ = Partitioned.put_dynamic_cache(default_dynamic_cache) + :ok = Process.sleep(100) + stop_caches(node_pid_list) end) - {:ok, cache: Partitioned, name: @cache_name, cluster: cluster} + {:ok, cache: Partitioned, name: @cache_name, cluster: cluster, on_error: &assert_query_error/1} + end + + defp assert_query_error(%Nebulex.Error{reason: {:rpc_multicall_error, errors}}) do + for {_node, {:error, reason}} <- errors do + assert %Nebulex.QueryError{} = reason + end end describe "c:init/1" do test "initializes the primary store metadata" do - Adapter.with_meta(PartitionedCache.Primary, fn adapter, meta -> - assert adapter == Nebulex.Adapters.Local + Adapter.with_meta(PartitionedCache.Primary, fn meta -> + assert meta.adapter == Nebulex.Adapters.Local assert meta.backend == :shards end) end @@ -86,7 +97,7 @@ defmodule Nebulex.Adapters.PartitionedTest do keyslot: "invalid" ) - assert Regex.match?(~r"expected keyslot: to be an atom, got: \"invalid\"", msg) + assert Regex.match?(~r/invalid value for :keyslot/, msg) end end @@ -104,27 +115,27 @@ defmodule Nebulex.Adapters.PartitionedTest do end test_with_dynamic_cache(Partitioned, [name: :custom_keyslot, keyslot: Keyslot], fn -> - refute Partitioned.get("foo") + refute Partitioned.get!("foo") assert Partitioned.put("foo", "bar") == :ok - assert Partitioned.get("foo") == "bar" + assert Partitioned.get!("foo") == "bar" end) end test "get_and_update" do - assert Partitioned.get_and_update(1, &Partitioned.get_and_update_fun/1) == {nil, 1} - assert Partitioned.get_and_update(1, &Partitioned.get_and_update_fun/1) == {1, 2} - assert Partitioned.get_and_update(1, &Partitioned.get_and_update_fun/1) == {2, 4} + assert Partitioned.get_and_update!(1, &Partitioned.get_and_update_fun/1) == {nil, 1} + assert Partitioned.get_and_update!(1, &Partitioned.get_and_update_fun/1) == {1, 2} + assert Partitioned.get_and_update!(1, &Partitioned.get_and_update_fun/1) == {2, 4} assert_raise ArgumentError, fn -> - Partitioned.get_and_update(1, &Partitioned.get_and_update_bad_fun/1) + Partitioned.get_and_update!(1, &Partitioned.get_and_update_bad_fun/1) end end test "incr raises when the counter is not an integer" do :ok = Partitioned.put(:counter, "string") - assert_raise ArgumentError, fn -> - Partitioned.incr(:counter, 10) + assert_raise Nebulex.Error, ~r"RPC call failed on node", fn -> + Partitioned.incr!(:counter, 10) end end end @@ -137,11 +148,13 @@ defmodule Nebulex.Adapters.PartitionedTest do Partitioned.with_dynamic_cache(name, fn -> :ok = Partitioned.leave_cluster() + assert Partitioned.nodes() == cluster -- [node()] end) Partitioned.with_dynamic_cache(name, fn -> :ok = Partitioned.join_cluster() + assert Partitioned.nodes() == cluster end) end @@ -150,7 +163,7 @@ defmodule Nebulex.Adapters.PartitionedTest do assert Partitioned.nodes() == cluster assert Partitioned.put(1, 1) == :ok - assert Partitioned.get(1) == 1 + assert Partitioned.get!(1) == 1 node = teardown_cache(1) @@ -158,17 +171,18 @@ defmodule Nebulex.Adapters.PartitionedTest do assert Partitioned.nodes() == cluster -- [node] end) - refute Partitioned.get(1) + refute Partitioned.get!(1) assert :ok == Partitioned.put_all([{4, 44}, {2, 2}, {1, 1}]) - assert Partitioned.get(4) == 44 - assert Partitioned.get(2) == 2 - assert Partitioned.get(1) == 1 + assert Partitioned.get!(4) == 44 + assert Partitioned.get!(2) == 2 + assert Partitioned.get!(1) == 1 end - test "bootstrap leaves cache from the cluster when terminated and then rejoins when restarted", - %{name: name} do + test "cache leaves the cluster when terminated and then rejoins when restarted", %{ + name: name + } do prefix = [:nebulex, :test_cache, :partitioned, :bootstrap] started = prefix ++ [:started] stopped = prefix ++ [:stopped] @@ -206,50 +220,48 @@ defmodule Nebulex.Adapters.PartitionedTest do describe "rpc" do test "timeout error" do assert Partitioned.put_all(for(x <- 1..100_000, do: {x, x}), timeout: 60_000) == :ok - assert Partitioned.get(1, timeout: 1000) == 1 + assert Partitioned.get!(1, timeout: 1000) == 1 - msg = ~r"RPC error while executing action :all\n\nSuccessful responses:" + assert_raise Nebulex.Error, ~r"RPC multicall failed with errors", fn -> + Partitioned.all!(nil, timeout: 0) + end + + assert {:error, %Nebulex.Error{reason: {:rpc_multicall_error, errors}}} = + Partitioned.all(nil, timeout: 0) - assert_raise Nebulex.RPCMultiCallError, msg, fn -> - Partitioned.all(nil, timeout: 1) + for {_node, error} <- errors do + assert error == {:error, {:erpc, :timeout}} end end test "runtime error" do _ = Process.flag(:trap_exit, true) - assert [1, 2] |> PartitionedMock.get_all(timeout: 10) |> map_size() == 0 - - assert PartitionedMock.put_all(a: 1, b: 2) == :ok + assert {:error, %Nebulex.Error{reason: {:rpc_multicall_error, errors}}} = + PartitionedMock.get_all([1, 2], timeout: 10) - assert [1, 2] |> PartitionedMock.get_all() |> map_size() == 0 - - assert_raise ArgumentError, fn -> - PartitionedMock.get(1) + for {_node, {error, _call}} <- errors do + assert error == {:error, {:erpc, :timeout}} end - msg = ~r"RPC error while executing action :count_all\n\nSuccessful responses:" + assert {:error, %Nebulex.Error{reason: {:rpc_multicall_error, errors}}} = + PartitionedMock.put_all(a: 1, b: 2) - assert_raise Nebulex.RPCMultiCallError, msg, fn -> - PartitionedMock.count_all() + for {_node, {error, _call}} <- errors do + assert error == {:exit, {:signal, :normal}} end - end - end - if Code.ensure_loaded?(:erpc) do - describe ":erpc" do - test "timeout error" do - assert Partitioned.put(1, 1) == :ok - assert Partitioned.get(1, timeout: 1000) == 1 + assert {:error, %Nebulex.Error{reason: {:rpc_error, {node, {:EXIT, {reason, _}}}}}} = + PartitionedMock.get(1) - node = "#{inspect(Partitioned.get_node(1))}" - reason = "#{inspect({:erpc, :timeout})}" + assert node == :"node3@127.0.0.1" + assert reason == %ArgumentError{message: "Error"} - msg = ~r"The RPC operation failed on node #{node} with reason:\n\n#{reason}" + assert {:error, %Nebulex.Error{reason: {:rpc_multicall_error, errors}}} = + PartitionedMock.count_all() - assert_raise Nebulex.RPCError, msg, fn -> - Partitioned.get(1, timeout: 0) - end + for {_node, error} <- errors do + assert error == {:exit, {:signal, :normal}} end end end @@ -260,6 +272,7 @@ defmodule Nebulex.Adapters.PartitionedTest do node = Partitioned.get_node(key) remote_pid = :rpc.call(node, Process, :whereis, [@cache_name]) :ok = :rpc.call(node, Supervisor, :stop, [remote_pid]) + node end end diff --git a/test/nebulex/adapters/replicated_test.exs b/test/nebulex/adapters/replicated_test.exs index c0629c52..398e7fd7 100644 --- a/test/nebulex/adapters/replicated_test.exs +++ b/test/nebulex/adapters/replicated_test.exs @@ -1,36 +1,31 @@ defmodule Nebulex.Adapters.ReplicatedTest do use Nebulex.NodeCase + use Mimic + + # Inherit tests use Nebulex.CacheTest - import Mock - import Nebulex.CacheCase + import Nebulex.Helpers alias Nebulex.TestCache.{Replicated, ReplicatedMock} @cache_name :replicated_cache - setup_all do + setup do node_pid_list = start_caches(cluster_nodes(), [{Replicated, [name: @cache_name]}]) - on_exit(fn -> - :ok = Process.sleep(100) - stop_caches(node_pid_list) - end) - - {:ok, cache: Replicated, name: @cache_name} - end - - setup do default_dynamic_cache = Replicated.get_dynamic_cache() _ = Replicated.put_dynamic_cache(@cache_name) - _ = Replicated.delete_all() - on_exit(fn -> - Replicated.put_dynamic_cache(default_dynamic_cache) + _ = Replicated.put_dynamic_cache(default_dynamic_cache) + + :ok = Process.sleep(100) + + stop_caches(node_pid_list) end) - :ok + {:ok, cache: Replicated, name: @cache_name} end describe "c:init/1" do @@ -49,44 +44,58 @@ defmodule Nebulex.Adapters.ReplicatedTest do describe "replicated cache:" do test "put/3" do assert Replicated.put(1, 1) == :ok - assert Replicated.get(1) == 1 + assert Replicated.get!(1) == 1 - assert_for_all_replicas(Replicated, :get, [1], 1) + assert_for_all_replicas(Replicated, :get!, [1], 1) assert Replicated.put_all(a: 1, b: 2, c: 3) == :ok - assert_for_all_replicas(Replicated, :get_all, [[:a, :b, :c]], %{a: 1, b: 2, c: 3}) + assert_for_all_replicas(Replicated, :get_all!, [[:a, :b, :c]], %{a: 1, b: 2, c: 3}) end test "delete/2" do assert Replicated.put("foo", "bar") == :ok - assert Replicated.get("foo") == "bar" + assert Replicated.get!("foo") == "bar" - assert_for_all_replicas(Replicated, :get, ["foo"], "bar") + assert_for_all_replicas(Replicated, :get!, ["foo"], "bar") assert Replicated.delete("foo") == :ok - refute Replicated.get("foo") + refute Replicated.get!("foo") - assert_for_all_replicas(Replicated, :get, ["foo"], nil) + assert_for_all_replicas(Replicated, :get!, ["foo"], nil) end test "take/2" do assert Replicated.put("foo", "bar") == :ok - assert Replicated.get("foo") == "bar" + assert Replicated.get!("foo") == "bar" - assert_for_all_replicas(Replicated, :get, ["foo"], "bar") + assert_for_all_replicas(Replicated, :get!, ["foo"], "bar") - assert Replicated.take("foo") == "bar" - refute Replicated.get("foo") + assert Replicated.take!("foo") == "bar" + refute Replicated.get!("foo") - assert_for_all_replicas(Replicated, :take, ["foo"], nil) + assert_for_all_replicas(Replicated, :get!, ["foo"], nil) + end + + test "take/2 (Nebulex.KeyError on remote nodes)" do + Replicated.__primary__().with_dynamic_cache( + normalize_module_name([@cache_name, Primary]), + fn -> + :ok = Replicated.__primary__().put("foo", "bar") + end + ) + + assert Replicated.take!("foo") == "bar" + refute Replicated.get!("foo") + + assert_for_all_replicas(Replicated, :get!, ["foo"], nil) end test "incr/3" do - assert Replicated.incr(:counter, 3) == 3 - assert Replicated.incr(:counter) == 4 + assert Replicated.incr!(:counter, 3) == 3 + assert Replicated.incr!(:counter) == 4 - assert_for_all_replicas(Replicated, :get, [:counter], 4) + assert_for_all_replicas(Replicated, :get!, [:counter], 4) end test "incr/3 raises when the counter is not an integer" do @@ -100,12 +109,12 @@ defmodule Nebulex.Adapters.ReplicatedTest do test "delete_all/2" do assert Replicated.put_all(a: 1, b: 2, c: 3) == :ok - assert_for_all_replicas(Replicated, :get_all, [[:a, :b, :c]], %{a: 1, b: 2, c: 3}) + assert_for_all_replicas(Replicated, :get_all!, [[:a, :b, :c]], %{a: 1, b: 2, c: 3}) - assert Replicated.delete_all() == 3 - assert Replicated.count_all() == 0 + assert Replicated.delete_all!() == 3 + assert Replicated.count_all!() == 0 - assert_for_all_replicas(Replicated, :get_all, [[:a, :b, :c]], %{}) + assert_for_all_replicas(Replicated, :get_all!, [[:a, :b, :c]], %{}) end end @@ -140,10 +149,11 @@ defmodule Nebulex.Adapters.ReplicatedTest do try do _ = Process.flag(:trap_exit, true) - msg = ~r"RPC error while executing action :put_all\n\nSuccessful responses:" + assert {:error, %Nebulex.Error{reason: {:rpc_multicall_error, errors}}} = + ReplicatedMock.put_new_all(a: 1, b: 2) - assert_raise Nebulex.RPCMultiCallError, msg, fn -> - ReplicatedMock.put_all(a: 1, b: 2) + for {_node, error} <- errors do + assert error == {:exit, {:signal, :normal}} end after stop_caches(node_pid_list) @@ -161,7 +171,7 @@ defmodule Nebulex.Adapters.ReplicatedTest do assert_for_all_replicas( Replicated, - :get_all, + :get_all!, [[:a, :b, :c]], %{a: 1, b: 2, c: 3} ) @@ -177,7 +187,7 @@ defmodule Nebulex.Adapters.ReplicatedTest do wait_until(10, 1000, fn -> assert_for_all_replicas( Replicated, - :get_all, + :get_all!, [[:a, :b, :c]], %{a: 1, b: 2, c: 3} ) @@ -188,20 +198,20 @@ defmodule Nebulex.Adapters.ReplicatedTest do if Code.ensure_loaded?(:pg) do # errors on failed nodes should be ignored - with_mock Nebulex.Cache.Cluster, [:passthrough], - get_nodes: fn _ -> [:"node5@127.0.0.1"] ++ nodes end do - assert Replicated.put(:foo, :bar) == :ok - - assert_receive {^event, %{rpc_errors: 2}, meta} - assert meta[:adapter_meta][:cache] == Replicated - assert meta[:adapter_meta][:name] == :replicated_cache - assert meta[:function_name] == :put - - assert [ - "node5@127.0.0.1": :noconnection, - "node3@127.0.0.1": %Nebulex.RegistryLookupError{} - ] = meta[:rpc_errors] - end + Nebulex.Cache.Cluster + |> expect(:get_nodes, fn _ -> [:"node5@127.0.0.1"] ++ nodes end) + + assert Replicated.put(:foo, :bar) == :ok + + assert_receive {^event, %{rpc_errors: 2}, meta} + assert meta[:adapter_meta][:cache] == Replicated + assert meta[:adapter_meta][:name] == :replicated_cache + assert meta[:function_name] == :put + + assert [ + "node5@127.0.0.1": {:error, {:erpc, :noconnection}}, + "node3@127.0.0.1": {:error, %Nebulex.Error{reason: {:registry_lookup_error, _}}} + ] = meta[:rpc_errors] end wait_until(10, 1000, fn -> @@ -211,7 +221,7 @@ defmodule Nebulex.Adapters.ReplicatedTest do assert_for_all_replicas( Replicated, - :get_all, + :get_all!, [[:a, :b, :c]], %{a: 1, b: 2, c: 3} ) @@ -251,82 +261,6 @@ defmodule Nebulex.Adapters.ReplicatedTest do end end - describe "doesn't leave behind EXIT messages after calling, with exits trapped:" do - test "all/0" do - put_all_and_trap_exits(a: 1, b: 2, c: 3) - Replicated.all() - refute_receive {:EXIT, _, :normal} - end - - test "delete/1" do - put_all_and_trap_exits(a: 1) - Replicated.delete(:a) - refute_receive {:EXIT, _, :normal} - end - - test "delete_all/2" do - put_all_and_trap_exits(a: 1, b: 2, c: 3) - Replicated.delete_all() - refute_receive {:EXIT, _, :normal} - end - - test "get/1" do - put_all_and_trap_exits(a: 1) - Replicated.get(:a) - refute_receive {:EXIT, _, :normal} - end - - test "incr/1" do - put_all_and_trap_exits(a: 1) - Replicated.incr(:a) - refute_receive {:EXIT, _, :normal} - end - - test "nodes/0" do - put_all_and_trap_exits([]) - Replicated.nodes() - refute_receive {:EXIT, _, :normal} - end - - test "put/2" do - put_all_and_trap_exits([]) - Replicated.put(:a, 1) - refute_receive {:EXIT, _, :normal} - end - - test "put_all/1" do - put_all_and_trap_exits([]) - Replicated.put_all(a: 1, b: 2, c: 3) - refute_receive {:EXIT, _, :normal} - end - - test "count_all/2" do - put_all_and_trap_exits([]) - Replicated.count_all() - refute_receive {:EXIT, _, :normal} - end - - test "stream/0" do - put_all_and_trap_exits(a: 1, b: 2, c: 3) - Replicated.stream() |> Enum.take(10) - refute_receive {:EXIT, _, :normal} - end - - test "take/1" do - put_all_and_trap_exits(a: 1) - Replicated.take(:a) - refute_receive {:EXIT, _, :normal} - end - - # Put the values, ensure we didn't generate a message before trapping exits, - # then trap exits. - defp put_all_and_trap_exits(kv_pairs) do - Replicated.put_all(kv_pairs, ttl: :infinity) - refute_receive {:EXIT, _, :normal} - Process.flag(:trap_exit, true) - end - end - ## Helpers defp assert_for_all_replicas(cache, action, args, expected) do diff --git a/test/nebulex/adapters/stats_test.exs b/test/nebulex/adapters/stats_test.exs index 3e5b67ca..6095d710 100644 --- a/test/nebulex/adapters/stats_test.exs +++ b/test/nebulex/adapters/stats_test.exs @@ -1,41 +1,10 @@ defmodule Nebulex.Adapters.StatsTest do - use ExUnit.Case + use ExUnit.Case, asyc: true + use Mimic import Nebulex.CacheCase - alias Nebulex.Cache.Stats - - ## Shared cache - - defmodule Cache do - use Nebulex.Cache, - otp_app: :nebulex, - adapter: Nebulex.Adapters.Multilevel - - defmodule L1 do - use Nebulex.Cache, - otp_app: :nebulex, - adapter: Nebulex.Adapters.Local - end - - defmodule L2 do - use Nebulex.Cache, - otp_app: :nebulex, - adapter: Nebulex.Adapters.Replicated - end - - defmodule L3 do - use Nebulex.Cache, - otp_app: :nebulex, - adapter: Nebulex.Adapters.Partitioned - end - - defmodule L4 do - use Nebulex.Cache, - otp_app: :nebulex, - adapter: Nebulex.Adapters.Local - end - end + alias Nebulex.TestCache.StatsCache, as: Cache ## Shared constants @@ -48,23 +17,30 @@ defmodule Nebulex.Adapters.StatsTest do ] ] - @event [:nebulex, :adapters, :stats_test, :cache, :stats] + @event [:nebulex, :test_cache, :stats_cache, :stats] ## Tests describe "(multilevel) stats/0" do - setup_with_cache(Cache, [stats: true] ++ @config) + setup_with_cache Cache, [stats: true] ++ @config + + test "returns an error" do + Cache.L1 + |> Mimic.expect(:stats, fn -> {:error, %Nebulex.Error{reason: :error}} end) + + assert Cache.stats() == {:error, %Nebulex.Error{module: Nebulex.Error, reason: :error}} + end test "hits and misses" do - :ok = Cache.put_all(a: 1, b: 2) + :ok = Cache.put_all!(a: 1, b: 2) - assert Cache.get(:a) == 1 - assert Cache.has_key?(:a) - assert Cache.ttl(:b) == :infinity - refute Cache.get(:c) - refute Cache.get(:d) + assert Cache.get!(:a) == 1 + assert Cache.exists?(:a) + assert Cache.ttl!(:b) == :infinity + refute Cache.get!(:c) + refute Cache.get!(:d) - assert Cache.get_all([:a, :b, :c, :d]) == %{a: 1, b: 2} + assert Cache.get_all!([:a, :b, :c, :d]) == %{a: 1, b: 2} assert_stats_measurements(Cache, l1: [hits: 5, misses: 4, writes: 2], @@ -74,24 +50,24 @@ defmodule Nebulex.Adapters.StatsTest do end test "writes and updates" do - assert Cache.put_all(a: 1, b: 2) == :ok + assert Cache.put_all!(a: 1, b: 2) == :ok assert Cache.put_all(%{a: 1, b: 2}) == :ok - refute Cache.put_new_all(a: 1, b: 2) - assert Cache.put_new_all(c: 3, d: 4, e: 3) - assert Cache.put(1, 1) == :ok - refute Cache.put_new(1, 2) - refute Cache.replace(2, 2) - assert Cache.put_new(2, 2) - assert Cache.replace(2, 22) - assert Cache.incr(:counter) == 1 - assert Cache.incr(:counter) == 2 - refute Cache.expire(:f, 1000) - assert Cache.expire(:a, 1000) - refute Cache.touch(:f) - assert Cache.touch(:b) + refute Cache.put_new_all!(a: 1, b: 2) + assert Cache.put_new_all!(c: 3, d: 4, e: 3) + assert Cache.put!(1, 1) == :ok + refute Cache.put_new!(1, 2) + refute Cache.replace!(2, 2) + assert Cache.put_new!(2, 2) + assert Cache.replace!(2, 22) + assert Cache.incr!(:counter) == 1 + assert Cache.incr!(:counter) == 2 + refute Cache.expire!(:f, 1000) + assert Cache.expire!(:a, 1000) + refute Cache.touch!(:f) + assert Cache.touch!(:b) :ok = Process.sleep(1100) - refute Cache.get(:a) + refute Cache.get!(:a) wait_until(fn -> assert_stats_measurements(Cache, @@ -104,11 +80,14 @@ defmodule Nebulex.Adapters.StatsTest do test "evictions" do entries = for x <- 1..10, do: {x, x} - :ok = Cache.put_all(entries) + :ok = Cache.put_all!(entries) - assert Cache.delete(1) == :ok - assert Cache.take(2) == 2 - refute Cache.take(20) + assert Cache.delete!(1) == :ok + assert Cache.take!(2) == 2 + + assert_raise Nebulex.KeyError, fn -> + Cache.take!(20) + end assert_stats_measurements(Cache, l1: [evictions: 2, misses: 1, writes: 10], @@ -116,7 +95,7 @@ defmodule Nebulex.Adapters.StatsTest do l3: [evictions: 2, misses: 1, writes: 10] ) - assert Cache.delete_all() == 24 + assert Cache.delete_all!() == 24 assert_stats_measurements(Cache, l1: [evictions: 10, misses: 1, writes: 10], @@ -126,13 +105,13 @@ defmodule Nebulex.Adapters.StatsTest do end test "expirations" do - :ok = Cache.put_all(a: 1, b: 2) - :ok = Cache.put_all([c: 3, d: 4], ttl: 1000) + :ok = Cache.put_all!(a: 1, b: 2) + :ok = Cache.put_all!([c: 3, d: 4], ttl: 1000) - assert Cache.get_all([:a, :b, :c, :d]) == %{a: 1, b: 2, c: 3, d: 4} + assert Cache.get_all!([:a, :b, :c, :d]) == %{a: 1, b: 2, c: 3, d: 4} :ok = Process.sleep(1100) - assert Cache.get_all([:a, :b, :c, :d]) == %{a: 1, b: 2} + assert Cache.get_all!([:a, :b, :c, :d]) == %{a: 1, b: 2} wait_until(fn -> assert_stats_measurements(Cache, @@ -147,15 +126,15 @@ defmodule Nebulex.Adapters.StatsTest do describe "(replicated) stats/0" do alias Cache.L2, as: Replicated - setup_with_cache(Replicated, [stats: true] ++ @config) + setup_with_cache Replicated, stats: true test "hits and misses" do - :ok = Replicated.put_all(a: 1, b: 2) + :ok = Replicated.put_all!(a: 1, b: 2) - assert Replicated.get(:a) == 1 - assert Replicated.get_all([:a, :b, :c, :d]) == %{a: 1, b: 2} + assert Replicated.get!(:a) == 1 + assert Replicated.get_all!([:a, :b, :c, :d]) == %{a: 1, b: 2} - assert %Nebulex.Stats{measurements: measurements} = Replicated.stats() + assert %Nebulex.Stats{measurements: measurements} = Replicated.stats!() assert measurements.hits == 3 assert measurements.misses == 2 end @@ -164,33 +143,31 @@ defmodule Nebulex.Adapters.StatsTest do describe "(partitioned) stats/0" do alias Cache.L3, as: Partitioned - setup_with_cache(Partitioned, [stats: true] ++ @config) + setup_with_cache Partitioned, stats: true test "hits and misses" do - :ok = Partitioned.put_all(a: 1, b: 2) + :ok = Partitioned.put_all!(a: 1, b: 2) - assert Partitioned.get(:a) == 1 - assert Partitioned.get_all([:a, :b, :c, :d]) == %{a: 1, b: 2} + assert Partitioned.get!(:a) == 1 + assert Partitioned.get_all!([:a, :b, :c, :d]) == %{a: 1, b: 2} - assert %Nebulex.Stats{measurements: measurements} = Partitioned.stats() + assert %Nebulex.Stats{measurements: measurements} = Partitioned.stats!() assert measurements.hits == 3 assert measurements.misses == 2 end end describe "disabled stats in a cache level" do - setup_with_cache( - Cache, - [stats: true] ++ - Keyword.update!( - @config, - :levels, - &(&1 ++ [{Cache.L4, gc_interval: :timer.hours(1), stats: false}]) - ) - ) + @updated_config Keyword.update!( + @config, + :levels, + &(&1 ++ [{Cache.L4, gc_interval: :timer.hours(1), stats: false}]) + ) + + setup_with_cache Cache, [stats: true] ++ @updated_config test "ignored when returning stats" do - measurements = Cache.stats().measurements + measurements = Cache.stats!().measurements assert Map.get(measurements, :l1) assert Map.get(measurements, :l2) assert Map.get(measurements, :l3) @@ -205,37 +182,37 @@ defmodule Nebulex.Adapters.StatsTest do {:error, {%ArgumentError{message: msg}, _}} = Cache.start_link(stats: 123, levels: [{Cache.L1, []}]) - assert msg == "expected stats: to be boolean, got: 123" + assert Regex.match?(~r/invalid value/, msg) end test "L1: invalid stats option" do _ = Process.flag(:trap_exit, true) - {:error, {:shutdown, {_, _, {:shutdown, {_, Cache.L1, {error, _}}}}}} = + {:error, {:shutdown, {_, _, {:shutdown, {_, Cache.L1, {%ArgumentError{message: msg}, _}}}}}} = Cache.start_link(stats: true, levels: [{Cache.L1, [stats: 123]}]) - assert error == %ArgumentError{message: "expected stats: to be boolean, got: 123"} + assert Regex.match?(~r/invalid value/, msg) end test "L2: invalid stats option" do _ = Process.flag(:trap_exit, true) - {:error, {:shutdown, {_, _, {:shutdown, {_, Cache.L2, {error, _}}}}}} = + {:error, {:shutdown, {_, _, {:shutdown, {_, Cache.L2, {%ArgumentError{message: msg}, _}}}}}} = Cache.start_link(stats: true, levels: [{Cache.L1, []}, {Cache.L2, [stats: 123]}]) - assert error == %ArgumentError{message: "expected stats: to be boolean, got: 123"} + assert Regex.match?(~r/invalid value/, msg) end test "L3: invalid stats option" do _ = Process.flag(:trap_exit, true) - {:error, {:shutdown, {_, _, {:shutdown, {_, Cache.L3, {error, _}}}}}} = + {:error, {:shutdown, {_, _, {:shutdown, {_, Cache.L3, {%ArgumentError{message: msg}, _}}}}}} = Cache.start_link( stats: true, levels: [{Cache.L1, []}, {Cache.L2, []}, {Cache.L3, [stats: 123]}] ) - assert error == %ArgumentError{message: "expected stats: to be boolean, got: 123"} + assert Regex.match?(~r/invalid value/, msg) end end @@ -244,11 +221,11 @@ defmodule Nebulex.Adapters.StatsTest do alias Cache.L2.Primary, as: L2Primary alias Cache.L3.Primary, as: L3Primary - setup_with_cache(Cache, [stats: true] ++ @config) + setup_with_cache Cache, [stats: true] ++ @config test "updates evictions" do - :ok = Cache.put_all(a: 1, b: 2, c: 3) - assert Cache.count_all() == 9 + :ok = Cache.put_all!(a: 1, b: 2, c: 3) + assert Cache.count_all!() == 9 assert_stats_measurements(Cache, l1: [evictions: 0, writes: 3], @@ -257,7 +234,7 @@ defmodule Nebulex.Adapters.StatsTest do ) _ = L1.new_generation() - assert Cache.count_all() == 9 + assert Cache.count_all!() == 9 assert_stats_measurements(Cache, l1: [evictions: 0, writes: 3], @@ -266,7 +243,7 @@ defmodule Nebulex.Adapters.StatsTest do ) _ = L1.new_generation() - assert Cache.count_all() == 6 + assert Cache.count_all!() == 6 assert_stats_measurements(Cache, l1: [evictions: 3, writes: 3], @@ -276,7 +253,7 @@ defmodule Nebulex.Adapters.StatsTest do _ = L2Primary.new_generation() _ = L2Primary.new_generation() - assert Cache.count_all() == 3 + assert Cache.count_all!() == 3 assert_stats_measurements(Cache, l1: [evictions: 3, writes: 3], @@ -286,7 +263,7 @@ defmodule Nebulex.Adapters.StatsTest do _ = L3Primary.new_generation() _ = L3Primary.new_generation() - assert Cache.count_all() == 0 + assert Cache.count_all!() == 0 assert_stats_measurements(Cache, l1: [evictions: 3, writes: 3], @@ -297,31 +274,31 @@ defmodule Nebulex.Adapters.StatsTest do end describe "disabled stats:" do - setup_with_cache(Cache, @config) + setup_with_cache Cache, @config test "stats/0 returns nil" do - refute Cache.stats() + assert_raise Nebulex.Error, ~r"stats disabled or not supported by the cache", fn -> + Cache.stats!() + end end test "dispatch_stats/1 is skipped" do with_telemetry_handler(__MODULE__, [@event], fn -> - :ok = Cache.dispatch_stats() - - refute_receive {@event, _, %{cache: Nebulex.Cache.StatsTest.Cache}} + assert {:error, %Nebulex.Error{reason: {:stats_error, _}}} = Cache.dispatch_stats() end) end end describe "dispatch_stats/1" do - setup_with_cache(Cache, [stats: true] ++ @config) + setup_with_cache Cache, [stats: true] ++ @config test "emits a telemetry event when called" do with_telemetry_handler(__MODULE__, [@event], fn -> :ok = Cache.dispatch_stats(metadata: %{node: node()}) + node = node() - assert_receive {@event, measurements, - %{cache: Nebulex.Adapters.StatsTest.Cache, node: ^node}} + assert_receive {@event, measurements, %{cache: Cache, node: ^node}} assert measurements == %{ l1: %{hits: 0, misses: 0, writes: 0, evictions: 0, expirations: 0, updates: 0}, @@ -330,14 +307,20 @@ defmodule Nebulex.Adapters.StatsTest do } end) end + + test "returns an error" do + Cache.L1 + |> Mimic.expect(:stats, fn -> {:error, %Nebulex.Error{reason: :error}} end) + + assert Cache.dispatch_stats() == + {:error, %Nebulex.Error{module: Nebulex.Error, reason: :error}} + end end describe "dispatch_stats/1 with dynamic cache" do - setup_with_dynamic_cache( - Cache, - :stats_with_dispatch, - [telemetry_prefix: [:my_event], stats: true] ++ @config - ) + setup_with_dynamic_cache Cache, + :stats_with_dispatch, + [telemetry_prefix: [:my_event], stats: true] ++ @config test "emits a telemetry event with custom telemetry_prefix when called" do with_telemetry_handler(__MODULE__, [[:my_event, :stats]], fn -> @@ -358,7 +341,7 @@ defmodule Nebulex.Adapters.StatsTest do ## Helpers defp assert_stats_measurements(cache, levels) do - measurements = cache.stats().measurements + measurements = cache.stats!().measurements for {level, stats} <- levels, {stat, expected} <- stats do assert get_in(measurements, [level, stat]) == expected diff --git a/test/nebulex/cache/registry_test.exs b/test/nebulex/cache/registry_test.exs new file mode 100644 index 00000000..0d05c262 --- /dev/null +++ b/test/nebulex/cache/registry_test.exs @@ -0,0 +1,32 @@ +defmodule Nebulex.Cache.RegistryTest do + use ExUnit.Case, async: true + + import Nebulex.CacheCase, only: [test_with_dynamic_cache: 3] + + alias Nebulex.TestCache.Cache + + describe "lookup/1" do + test "error: returns an error with reason ':registry_lookup_error'" do + assert Nebulex.Cache.Registry.lookup(self()) == + {:error, + %Nebulex.Error{ + module: Nebulex.Error, + reason: {:registry_lookup_error, self()} + }} + end + end + + describe "all_running/0" do + test "ok: returns all running cache names" do + test_with_dynamic_cache(Cache, [name: :registry_test_cache], fn -> + assert :registry_test_cache in Nebulex.Cache.Registry.all_running() + end) + end + + test "ok: returns all running cache pids" do + test_with_dynamic_cache(Cache, [name: nil], fn -> + assert Nebulex.Cache.Registry.all_running() |> Enum.any?(&is_pid/1) + end) + end + end +end diff --git a/test/nebulex/caching_test.exs b/test/nebulex/caching_test.exs index 379550eb..4bb02f9c 100644 --- a/test/nebulex/caching_test.exs +++ b/test/nebulex/caching_test.exs @@ -2,15 +2,21 @@ defmodule Nebulex.CachingTest do use ExUnit.Case, async: true use Nebulex.Caching + import Nebulex.CacheCase + @behaviour Nebulex.Caching.KeyGenerator + ## Internals + defmodule Cache do + @moduledoc false use Nebulex.Cache, otp_app: :nebulex, adapter: Nebulex.Adapters.Local end defmodule CacheWithDefaultKeyGenerator do + @moduledoc false use Nebulex.Cache, otp_app: :nebulex, adapter: Nebulex.Adapters.Local, @@ -23,17 +29,20 @@ defmodule Nebulex.CachingTest do end defmodule YetAnotherCache do + @moduledoc false use Nebulex.Cache, otp_app: :nebulex, adapter: Nebulex.Adapters.Local end defmodule Meta do + @moduledoc false defstruct [:id, :count] @type t :: %__MODULE__{} end defmodule TestKeyGenerator do + @moduledoc false @behaviour Nebulex.Caching.KeyGenerator @impl true @@ -46,11 +55,9 @@ defmodule Nebulex.CachingTest do end end - import Nebulex.CacheCase - - alias Nebulex.CachingTest.{Cache, Meta} + ## Tests - setup_with_cache(Cache) + setup_with_cache Cache describe "decorator" do test "cacheable fails because missing cache" do @@ -99,145 +106,151 @@ defmodule Nebulex.CachingTest do describe "cacheable" do test "with default opts" do - refute Cache.get("x") + refute Cache.get!("x") assert get_by_x("x") == nil - refute Cache.get("x") + refute Cache.get!("x") assert get_by_x(1, 11) == 11 - assert Cache.get(1) == 11 + assert Cache.get!(1) == 11 assert get_by_x(2, {:ok, 22}) == {:ok, 22} - assert Cache.get(2) == {:ok, 22} + assert Cache.get!(2) == {:ok, 22} assert get_by_x(3, :error) == :error - refute Cache.get(3) + refute Cache.get!(3) assert get_by_x(4, {:error, 4}) == {:error, 4} - refute Cache.get(4) + refute Cache.get!(4) - refute Cache.get({:xy, 2}) + refute Cache.get!({:xy, 2}) assert get_by_xy(:xy, 2) == {:xy, 4} - assert Cache.get({:xy, 2}) == {:xy, 4} + assert Cache.get!({:xy, 2}) == {:xy, 4} :ok = Process.sleep(1100) - refute Cache.get("x") - assert Cache.get(1) == 11 - assert Cache.get(2) == {:ok, 22} - refute Cache.get(3) - refute Cache.get(4) - assert Cache.get({:xy, 2}) == {:xy, 4} + refute Cache.get!("x") + assert Cache.get!(1) == 11 + assert Cache.get!(2) == {:ok, 22} + refute Cache.get!(3) + refute Cache.get!(4) + assert Cache.get!({:xy, 2}) == {:xy, 4} end test "with opts" do - refute Cache.get("x") + refute Cache.get!("x") assert get_with_opts(1) == 1 - assert Cache.get(1) == 1 + assert Cache.get!(1) == 1 :ok = Process.sleep(1100) - refute Cache.get(1) + + refute Cache.get!(1) end test "with match function" do - refute Cache.get(:x) + refute Cache.get!(:x) assert get_with_match(:x) == :x - refute Cache.get(:x) + refute Cache.get!(:x) - refute Cache.get(:y) + refute Cache.get!(:y) assert get_with_match(:y) == :y - assert Cache.get(:y) + assert Cache.get!(:y) - refute Cache.get("true") + refute Cache.get!("true") assert get_with_match_fun("true") == {:ok, "true"} - assert Cache.get("true") == {:ok, "true"} + assert Cache.get!("true") == {:ok, "true"} - refute Cache.get(1) + refute Cache.get!(1) assert get_with_match_fun(1) == {:ok, "1"} - assert Cache.get(1) == "1" + assert Cache.get!(1) == "1" - refute Cache.get({:ok, "hello"}) + refute Cache.get!({:ok, "hello"}) assert get_with_match_fun({:ok, "hello"}) == :error - refute Cache.get({:ok, "hello"}) + refute Cache.get!({:ok, "hello"}) end test "with default key" do assert get_with_default_key(123, {:foo, "bar"}) == :ok - assert [123, {:foo, "bar"}] |> :erlang.phash2() |> Cache.get() == :ok + assert [123, {:foo, "bar"}] |> :erlang.phash2() |> Cache.get!() == :ok + assert get_with_default_key(:foo, "bar") == :ok - assert [:foo, "bar"] |> :erlang.phash2() |> Cache.get() == :ok + assert [:foo, "bar"] |> :erlang.phash2() |> Cache.get!() == :ok end test "defining keys using structs and maps" do - refute Cache.get("x") + refute Cache.get!("x") + assert get_meta(%Meta{id: 1, count: 1}) == %Meta{id: 1, count: 1} - assert Cache.get({Meta, 1}) == %Meta{id: 1, count: 1} + assert Cache.get!({Meta, 1}) == %Meta{id: 1, count: 1} + + refute Cache.get!("y") - refute Cache.get("y") assert get_map(%{id: 1}) == %{id: 1} - assert Cache.get(1) == %{id: 1} + assert Cache.get!(1) == %{id: 1} end test "with multiple clauses" do - refute Cache.get(2) + refute Cache.get!(2) + assert multiple_clauses(2, 2) == 4 - assert Cache.get(2) == 4 + assert Cache.get!(2) == 4 + + refute Cache.get!("foo") - refute Cache.get("foo") assert multiple_clauses("foo", "bar") == {"foo", "bar"} - assert Cache.get("foo") == {"foo", "bar"} + assert Cache.get!("foo") == {"foo", "bar"} end test "without args" do - refute Cache.get(0) + refute Cache.get!(0) assert get_without_args() == "hello" - assert Cache.get(0) == "hello" + assert Cache.get!(0) == "hello" end test "with side effects and returning false (issue #111)" do - refute Cache.get("side-effect") + refute Cache.get!("side-effect") assert get_false_with_side_effect(false) == false - assert Cache.get("side-effect") == 1 + assert Cache.get!("side-effect") == 1 assert get_false_with_side_effect(false) == false - assert Cache.get("side-effect") == 1 + assert Cache.get!("side-effect") == 1 end end describe "cachable with references" do - setup_with_cache(YetAnotherCache) + setup_with_cache YetAnotherCache - test "with referenced key" do + test "returns referenced key" do # Expected values - referenced_key = cache_ref("referenced_id") + referenced_key = cache_ref "referenced_id" result = %{id: "referenced_id", name: "referenced_name"} # Nothing is cached yet - refute Cache.get("referenced_id") - refute Cache.get("referenced_name") + refute Cache.get!("referenced_id") + refute Cache.get!("referenced_name") # First run: the function block is executed and its result is cached under # the referenced key, and the referenced key is cached under the given key assert get_with_referenced_key("referenced_name") == result # Assert the key points to the referenced key - assert Cache.get("referenced_name") == referenced_key + assert Cache.get!("referenced_name") == referenced_key # Assert the referenced key points to the cached value - assert Cache.get("referenced_id") == result + assert Cache.get!("referenced_id") == result # Next run: the value should come from the cache assert get_with_referenced_key("referenced_name") == result # Simulate a cache eviction for the referenced key - :ok = Cache.delete("referenced_id") + :ok = Cache.delete!("referenced_id") # The value under the referenced key should not longer exist - refute Cache.get("referenced_id") + refute Cache.get!("referenced_id") # Assert the key still points to the referenced key - assert Cache.get("referenced_name") == referenced_key + assert Cache.get!("referenced_name") == referenced_key # Next run: the key does exist but the referenced key doesn't, then the # function block is executed and the result is cached under the referenced @@ -245,36 +258,36 @@ defmodule Nebulex.CachingTest do assert get_with_referenced_key("referenced_name") == result # Assert the key points to the referenced key - assert Cache.get("referenced_name") == referenced_key + assert Cache.get!("referenced_name") == referenced_key # Assert the referenced key points to the cached value - assert Cache.get("referenced_id") == result + assert Cache.get!("referenced_id") == result # Similate the referenced key is overridden - :ok = Cache.put("referenced_name", "overridden") + :ok = Cache.put!("referenced_name", "overridden") # The referenced key is overridden assert get_with_referenced_key("referenced_name") == "overridden" end - test "with referenced key from args" do + test "returns referenced key from the args" do # Expected values - referenced_key = cache_ref("id") + referenced_key = cache_ref "id" result = %{attrs: %{id: "id"}, name: "name"} # Nothing is cached yet - refute Cache.get("id") - refute Cache.get("name") + refute Cache.get!("id") + refute Cache.get!("name") # First run: the function block is executed and its result is cached under # the referenced key, and the referenced key is cached under the given key assert get_with_referenced_key_from_args("name", %{id: "id"}) == result # Assert the key points to the referenced key - assert Cache.get("name") == referenced_key + assert Cache.get!("name") == referenced_key # Assert the referenced key points to the cached value - assert Cache.get("id") == result + assert Cache.get!("id") == result # Next run: the value should come from the cache assert get_with_referenced_key_from_args("name", %{id: "id"}) == result @@ -282,22 +295,22 @@ defmodule Nebulex.CachingTest do test "returns fixed referenced" do # Expected values - referenced_key = cache_ref("fixed_id") + referenced_key = cache_ref "fixed_id" result = %{id: "fixed_id", name: "name"} # Nothing is cached yet - refute Cache.get("fixed_id") - refute Cache.get("name") + refute Cache.get!("fixed_id") + refute Cache.get!("name") # First run: the function block is executed and its result is cached under # the referenced key, and the referenced key is cached under the given key assert get_with_fixed_referenced_key("name") == result # Assert the key points to the referenced key - assert Cache.get("name") == referenced_key + assert Cache.get!("name") == referenced_key # Assert the referenced key points to the cached value - assert Cache.get("fixed_id") == result + assert Cache.get!("fixed_id") == result # Next run: the value should come from the cache assert get_with_fixed_referenced_key("name") == result @@ -305,34 +318,34 @@ defmodule Nebulex.CachingTest do test "returns referenced key by calling referenced cache" do # Expected values - referenced_key = cache_ref(YetAnotherCache, "referenced_id") + referenced_key = cache_ref YetAnotherCache, "referenced_id" result = %{id: "referenced_id", name: "referenced_name"} # Nothing is cached yet - refute Cache.get("referenced_id") - refute Cache.get("referenced_name") + refute Cache.get!("referenced_id") + refute Cache.get!("referenced_name") # First run: the function block is executed and its result is cached under # the referenced key, and the referenced key is cached under the given key assert get_with_ref_key_with_cache("referenced_name") == result # Assert the key points to the referenced key - assert Cache.get("referenced_name") == referenced_key + assert Cache.get!("referenced_name") == referenced_key # Assert the referenced key points to the cached value - assert YetAnotherCache.get("referenced_id") == result + assert YetAnotherCache.get!("referenced_id") == result # Next run: the value should come from the cache assert get_with_ref_key_with_cache("referenced_name") == result # Simulate a cache eviction for the referenced key - :ok = YetAnotherCache.delete("referenced_id") + :ok = YetAnotherCache.delete!("referenced_id") # The value under the referenced key should not longer exist - refute YetAnotherCache.get("referenced_id") + refute YetAnotherCache.get!("referenced_id") # Assert the key still points to the referenced key - assert Cache.get("referenced_name") == referenced_key + assert Cache.get!("referenced_name") == referenced_key # Next run: the key does exist but the referenced key doesn't, then the # function block is executed and the result is cached under the referenced @@ -340,13 +353,13 @@ defmodule Nebulex.CachingTest do assert get_with_ref_key_with_cache("referenced_name") == result # Assert the key points to the referenced key - assert Cache.get("referenced_name") == referenced_key + assert Cache.get!("referenced_name") == referenced_key # Assert the referenced key points to the cached value - assert YetAnotherCache.get("referenced_id") == result + assert YetAnotherCache.get!("referenced_id") == result # Similate the referenced key is overridden - :ok = Cache.put("referenced_name", "overridden") + :ok = Cache.put!("referenced_name", "overridden") # The referenced key is overridden assert get_with_ref_key_with_cache("referenced_name") == "overridden" @@ -356,28 +369,28 @@ defmodule Nebulex.CachingTest do describe "cache_put" do test "with default opts" do assert update_fun(1) == nil - refute Cache.get(1) + refute Cache.get!(1) assert update_fun(1, :error) == :error - refute Cache.get(1) + refute Cache.get!(1) assert update_fun(1, {:error, :error}) == {:error, :error} - refute Cache.get(1) + refute Cache.get!(1) assert set_keys(x: 1, y: 2, z: 3) == :ok assert update_fun(:x, 2) == 2 assert update_fun(:y, {:ok, 4}) == {:ok, 4} - assert Cache.get(:x) == 2 - assert Cache.get(:y) == {:ok, 4} - assert Cache.get(:z) == 3 + assert Cache.get!(:x) == 2 + assert Cache.get!(:y) == {:ok, 4} + assert Cache.get!(:z) == 3 :ok = Process.sleep(1100) - assert Cache.get(:x) == 2 - assert Cache.get(:y) == {:ok, 4} - assert Cache.get(:z) == 3 + assert Cache.get!(:x) == 2 + assert Cache.get!(:y) == {:ok, 4} + assert Cache.get!(:z) == 3 end test "with opts" do @@ -386,37 +399,41 @@ defmodule Nebulex.CachingTest do assert update_with_opts(:y) == :y :ok = Process.sleep(1100) - refute Cache.get(:x) - refute Cache.get(:y) + + refute Cache.get!(:x) + refute Cache.get!(:y) end test "with match function" do assert update_with_match(:x) == {:ok, "x"} + assert Cache.get!(:x) == "x" + assert update_with_match(true) == {:ok, "true"} + assert Cache.get!(true) == {:ok, "true"} + assert update_with_match({:z, 1}) == :error - assert Cache.get(:x) == "x" - assert Cache.get(true) == {:ok, "true"} - refute Cache.get({:z, 1}) + refute Cache.get!({:z, 1}) end test "without args" do - refute Cache.get(0) + refute Cache.get!(0) assert update_without_args() == "hello" - assert Cache.get(0) == "hello" + assert Cache.get!(0) == "hello" end test "with multiple keys and ttl" do assert set_keys(x: 1, y: 2, z: 3) == :ok assert update_with_multiple_keys(:x, :y) == {:ok, {"x", "y"}} - assert Cache.get(:x) == {"x", "y"} - assert Cache.get(:y) == {"x", "y"} - assert Cache.get(:z) == 3 + assert Cache.get!(:x) == {"x", "y"} + assert Cache.get!(:y) == {"x", "y"} + assert Cache.get!(:z) == 3 :ok = Process.sleep(1100) - refute Cache.get(:x) - refute Cache.get(:y) - assert Cache.get(:z) == 3 + + refute Cache.get!(:x) + refute Cache.get!(:y) + assert Cache.get!(:z) == 3 end end @@ -425,39 +442,43 @@ defmodule Nebulex.CachingTest do assert set_keys(x: 1, y: 2, z: 3) == :ok assert evict_fun(:x) == :x - refute Cache.get(:x) - assert Cache.get(:y) == 2 - assert Cache.get(:z) == 3 + refute Cache.get!(:x) + assert Cache.get!(:y) == 2 + assert Cache.get!(:z) == 3 assert evict_fun(:y) == :y - refute Cache.get(:x) - refute Cache.get(:y) - assert Cache.get(:z) == 3 + refute Cache.get!(:x) + refute Cache.get!(:y) + assert Cache.get!(:z) == 3 end test "with multiple keys" do assert set_keys(x: 1, y: 2, z: 3) == :ok + assert evict_keys_fun(:x, :y) == {:x, :y} - refute Cache.get(:x) - refute Cache.get(:y) - assert Cache.get(:z) == 3 + + refute Cache.get!(:x) + refute Cache.get!(:y) + assert Cache.get!(:z) == 3 end test "all entries" do assert set_keys(x: 1, y: 2, z: 3) == :ok + assert evict_all_fun("hello") == "hello" - refute Cache.get(:x) - refute Cache.get(:y) - refute Cache.get(:z) + + refute Cache.get!(:x) + refute Cache.get!(:y) + refute Cache.get!(:z) end test "without args" do - refute Cache.get(0) + refute Cache.get!(0) assert get_without_args() == "hello" - assert Cache.get(0) == "hello" + assert Cache.get!(0) == "hello" assert evict_without_args() == "hello" - refute Cache.get(0) + refute Cache.get!(0) end end @@ -465,76 +486,76 @@ defmodule Nebulex.CachingTest do test "cacheable annotation" do key = TestKeyGenerator.generate(__MODULE__, :get_with_keygen, [1, 2]) - refute Cache.get(key) + refute Cache.get!(key) assert get_with_keygen(1, 2) == {1, 2} - assert Cache.get(key) == {1, 2} + assert Cache.get!(key) == {1, 2} end test "cache_evict annotation" do key = TestKeyGenerator.generate(__MODULE__, :evict_with_keygen, ["foo", "bar"]) :ok = Cache.put(key, {"foo", "bar"}) - assert Cache.get(key) == {"foo", "bar"} + assert Cache.get!(key) == {"foo", "bar"} assert evict_with_keygen("foo", "bar") == {"foo", "bar"} - refute Cache.get(key) + refute Cache.get!(key) end test "cache_put annotation" do assert multiple_clauses(2, 2) == 4 - assert Cache.get(2) == 4 + assert Cache.get!(2) == 4 assert put_with_keygen(2, 4) == 8 assert multiple_clauses(2, 2) == 8 - assert Cache.get(2) == 8 + assert Cache.get!(2) == 8 assert put_with_keygen(2, 8) == 16 assert multiple_clauses(2, 2) == 16 - assert Cache.get(2) == 16 + assert Cache.get!(2) == 16 end test "cacheable annotation with multiple function clauses and pattern-matching " do key = TestKeyGenerator.generate(__MODULE__, :get_with_keygen2, [1, 2]) - refute Cache.get(key) + refute Cache.get!(key) assert get_with_keygen2(1, 2, %{a: {1, 2}}) == {1, 2} - assert Cache.get(key) == {1, 2} + assert Cache.get!(key) == {1, 2} key = TestKeyGenerator.generate(__MODULE__, :get_with_keygen2, [1, 2, %{b: 3}]) - refute Cache.get(key) + refute Cache.get!(key) assert get_with_keygen2(1, 2, %{b: 3}) == {1, 2, %{b: 3}} - assert Cache.get(key) == {1, 2, %{b: 3}} + assert Cache.get!(key) == {1, 2, %{b: 3}} end test "cacheable annotation with ignored arguments" do key = TestKeyGenerator.generate(__MODULE__, :get_with_keygen3, [1, %{b: 2}]) - refute Cache.get(key) + refute Cache.get!(key) assert get_with_keygen3(1, 2, 3, {1, 2}, [1], %{a: 1}, %{b: 2}) == {1, %{b: 2}} - assert Cache.get(key) == {1, %{b: 2}} + assert Cache.get!(key) == {1, %{b: 2}} end end describe "default key generator on" do - setup_with_cache(CacheWithDefaultKeyGenerator) + setup_with_cache CacheWithDefaultKeyGenerator test "cacheable annotation" do key = CacheWithDefaultKeyGenerator.generate(__MODULE__, :get_with_default_key_generator, [1]) - refute CacheWithDefaultKeyGenerator.get(key) + refute CacheWithDefaultKeyGenerator.get!(key) assert get_with_default_key_generator(1) == 1 - assert CacheWithDefaultKeyGenerator.get(key) == 1 + assert CacheWithDefaultKeyGenerator.get!(key) == 1 end test "cache_evict annotation" do key = CacheWithDefaultKeyGenerator.generate(__MODULE__, :del_with_default_key_generator, [1]) :ok = CacheWithDefaultKeyGenerator.put(key, 1) - assert CacheWithDefaultKeyGenerator.get(key) == 1 + assert CacheWithDefaultKeyGenerator.get!(key) == 1 assert del_with_default_key_generator(1) == 1 - refute CacheWithDefaultKeyGenerator.get(key) + refute CacheWithDefaultKeyGenerator.get!(key) end end @@ -542,40 +563,40 @@ defmodule Nebulex.CachingTest do test "cacheable annotation" do key = generate_key({1, 2}) - refute Cache.get(key) + refute Cache.get!(key) assert get_with_tuple_keygen(1, 2) == {1, 2} - assert Cache.get(key) == {1, 2} + assert Cache.get!(key) == {1, 2} end test "cacheable annotation (with key-generator: TestKeyGenerator)" do key = TestKeyGenerator.generate(:a, :b, [1]) - refute Cache.get(key) + refute Cache.get!(key) assert get_with_tuple_keygen2(1, 2) == {1, 2} - assert Cache.get(key) == {1, 2} + assert Cache.get!(key) == {1, 2} end test "cache_evict annotation" do key = generate_key({"foo", "bar"}) :ok = Cache.put(key, {"foo", "bar"}) - assert Cache.get(key) == {"foo", "bar"} + assert Cache.get!(key) == {"foo", "bar"} assert evict_with_tuple_keygen("foo", "bar") == {"foo", "bar"} - refute Cache.get(key) + refute Cache.get!(key) end test "cache_put annotation" do assert multiple_clauses(2, 2) == 4 - assert Cache.get(2) == 4 + assert Cache.get!(2) == 4 assert put_with_tuple_keygen(2, 4) == 8 assert multiple_clauses(2, 2) == 8 - assert Cache.get(2) == 8 + assert Cache.get!(2) == 8 assert put_with_tuple_keygen(2, 8) == 16 assert multiple_clauses(2, 2) == 16 - assert Cache.get(2) == 16 + assert Cache.get!(2) == 16 end end @@ -583,93 +604,113 @@ defmodule Nebulex.CachingTest do test "cacheable annotation" do key = TestKeyGenerator.generate(__MODULE__, :get_with_shorthand_tuple_keygen, [1]) - refute Cache.get(key) + refute Cache.get!(key) assert get_with_shorthand_tuple_keygen(1, 2, 3) == {1, 2} - assert Cache.get(key) == {1, 2} + assert Cache.get!(key) == {1, 2} end - test "cacheable annotation (with key-generator: __MODULE__)" do + test "cacheable annotation (with key-generator: Nebulex.DecoratedCaching)" do key = generate(__MODULE__, :get_with_shorthand_tuple_keygen2, [1]) - refute Cache.get(key) + refute Cache.get!(key) assert get_with_shorthand_tuple_keygen2(1, 2) == {1, 2} - assert Cache.get(key) == {1, 2} + assert Cache.get!(key) == {1, 2} end test "cache_evict annotation" do key = TestKeyGenerator.generate(__MODULE__, :evict_with_shorthand_tuple_keygen, ["foo"]) :ok = Cache.put(key, {"foo", "bar"}) - assert Cache.get(key) == {"foo", "bar"} + assert Cache.get!(key) == {"foo", "bar"} assert evict_with_shorthand_tuple_keygen("foo", "bar") == {"foo", "bar"} - refute Cache.get(key) + refute Cache.get!(key) end test "cache_put annotation" do key = TestKeyGenerator.generate(__MODULE__, :put_with_shorthand_tuple_keygen, ["foo"]) - refute Cache.get(key) + refute Cache.get!(key) assert put_with_shorthand_tuple_keygen("foo", "bar") == {"foo", "bar"} - assert Cache.get(key) == {"foo", "bar"} + assert Cache.get!(key) == {"foo", "bar"} end end describe "option :on_error on" do - test "cacheable annotation" do - assert get_with_exception("foo") == "foo" + test "cacheable annotation raises a cache error" do + assert_raise Nebulex.Error, ~r"could not lookup", fn -> + get_and_raise_exception(:raise) + end end - test "cache_put annotation" do - assert update_with_exception("foo") == "foo" + test "cacheable annotation ignores the exception" do + assert get_ignoring_exception("foo") == "foo" end - test "cache_evict annotation" do - assert evict_with_exception("foo") == "foo" + test "cache_put annotation raises a cache error" do + assert_raise Nebulex.Error, ~r"could not lookup", fn -> + update_and_raise_exception(:raise) + end + end + + test "cache_put annotation ignores the exception" do + assert update_ignoring_exception("foo") == "foo" + end + + test "cache_evict annotation raises a cache error" do + assert_raise Nebulex.Error, ~r"could not lookup", fn -> + evict_and_raise_exception(:raise) + end + end + + test "cache_evict annotation ignores the exception" do + assert evict_ignoring_exception("foo") == "foo" end end describe "option :cache with MFA" do test "cacheable annotation" do - refute Cache.get("foo") + refute Cache.get!("foo") + assert get_mfa_cache_without_extra_args("foo") == "foo" - assert Cache.get("foo") == "foo" + assert Cache.get!("foo") == "foo" end test "cache_put annotation" do :ok = Cache.put("foo", "bar") assert update_mfa_cache_without_extra_args("bar bar") == "bar bar" - assert Cache.get("foo") == "bar bar" + assert Cache.get!("foo") == "bar bar" end test "cache_evict annotation" do :ok = Cache.put("foo", "bar") assert delete_mfa_cache_without_extra_args("bar bar") == "bar bar" - refute Cache.get("foo") + refute Cache.get!("foo") end end describe "option :cache with MFA and extra args" do test "cacheable annotation" do - refute Cache.get("foo") + refute Cache.get!("foo") + assert get_mfa_cache_with_extra_args("foo") == "foo" - assert Cache.get("foo") == "foo" + assert Cache.get!("foo") == "foo" end test "cache_put annotation" do :ok = Cache.put("foo", "bar") assert update_mfa_cache_with_extra_args("bar bar") == "bar bar" - assert Cache.get("foo") == "bar bar" + assert Cache.get!("foo") == "bar bar" end test "cache_evict annotation" do :ok = Cache.put("foo", "bar") assert delete_mfa_cache_with_extra_args("bar bar") == "bar bar" - refute Cache.get("foo") + refute Cache.get!("foo") end end @@ -698,7 +739,8 @@ defmodule Nebulex.CachingTest do @decorate cacheable(cache: Cache) def get_false_with_side_effect(v) do - Cache.update("side-effect", 1, &(&1 + 1)) + _ = Cache.update!("side-effect", 1, &(&1 + 1)) + v end @@ -866,18 +908,33 @@ defmodule Nebulex.CachingTest do x * y end + @decorate cacheable(cache: YetAnotherCache, key: x) + def get_and_raise_exception(x) do + x + end + + @decorate cache_put(cache: YetAnotherCache, key: x) + def update_and_raise_exception(x) do + x + end + + @decorate cache_evict(cache: YetAnotherCache, key: x) + def evict_and_raise_exception(x) do + x + end + @decorate cacheable(cache: YetAnotherCache, key: x, on_error: :nothing) - def get_with_exception(x) do + def get_ignoring_exception(x) do x end @decorate cache_put(cache: YetAnotherCache, key: x, on_error: :nothing) - def update_with_exception(x) do + def update_ignoring_exception(x) do x end @decorate cache_evict(cache: YetAnotherCache, key: x, on_error: :nothing) - def evict_with_exception(x) do + def evict_ignoring_exception(x) do x end @@ -955,7 +1012,7 @@ defmodule Nebulex.CachingTest do assert :ok == Cache.put_all(entries) Enum.each(entries, fn {k, v} -> - assert v == Cache.get(k) + assert v == Cache.get!(k) end) end end diff --git a/test/nebulex/hook_test.exs b/test/nebulex/hook_test.exs deleted file mode 100644 index 64081d3e..00000000 --- a/test/nebulex/hook_test.exs +++ /dev/null @@ -1,159 +0,0 @@ -defmodule Nebulex.HookTest do - use ExUnit.Case, async: true - - alias Nebulex.Hook - - describe "before" do - defmodule BeforeHookCache do - @moduledoc false - use Nebulex.Hook - @decorate_all before(&Nebulex.HookTest.hook_fun/1) - - use Nebulex.Cache, - otp_app: :nebulex, - adapter: Nebulex.Adapters.Local - end - - test "hook" do - {:ok, _pid} = BeforeHookCache.start_link() - true = Process.register(self(), :hooked_cache) - _ = BeforeHookCache.new_generation() - - refute BeforeHookCache.get("foo") - assert_receive %Hook{} = hook, 200 - assert hook.step == :before - assert hook.module == BeforeHookCache - assert hook.name == :get - assert hook.arity == 2 - refute hook.return - - assert :ok == BeforeHookCache.put("foo", "bar") - assert_receive %Hook{} = hook, 200 - assert hook.step == :before - assert hook.module == BeforeHookCache - assert hook.name == :put - assert hook.arity == 3 - refute hook.return - - :ok = BeforeHookCache.stop() - end - end - - describe "after_return" do - defmodule AfterReturnHookCache do - @moduledoc false - use Nebulex.Hook - @decorate_all after_return(&Nebulex.HookTest.hook_fun/1) - - use Nebulex.Cache, - otp_app: :nebulex, - adapter: Nebulex.Adapters.Local - end - - test "hook" do - {:ok, _pid} = AfterReturnHookCache.start_link() - true = Process.register(self(), :hooked_cache) - _ = AfterReturnHookCache.new_generation() - - refute AfterReturnHookCache.get("foo") - assert_receive %Hook{} = hook, 200 - assert hook.module == AfterReturnHookCache - assert hook.name == :get - assert hook.arity == 2 - assert hook.step == :after_return - refute hook.return - - assert :ok == AfterReturnHookCache.put("foo", "bar") - assert_receive %Hook{} = hook, 200 - assert hook.module == AfterReturnHookCache - assert hook.name == :put - assert hook.arity == 3 - assert hook.step == :after_return - assert hook.return == :ok - - :ok = AfterReturnHookCache.stop() - end - end - - describe "around" do - defmodule AroundHookCache do - @moduledoc false - use Nebulex.Hook - @decorate_all around(&Nebulex.TestCache.TestHook.track/1) - - use Nebulex.Cache, - otp_app: :nebulex, - adapter: Nebulex.Adapters.Local - - alias Nebulex.TestCache.TestHook - - def init(opts) do - {:ok, pid} = TestHook.start_link() - {:ok, Keyword.put(opts, :hook_pid, pid)} - end - end - - test "hook" do - {:ok, _pid} = AroundHookCache.start_link() - true = Process.register(self(), :hooked_cache) - _ = AroundHookCache.new_generation() - - refute AroundHookCache.get("foo") - assert_receive %Hook{module: AroundHookCache, name: :get, arity: 2} = hook, 200 - refute hook.return - assert hook.acc >= 0 - - assert :ok == AroundHookCache.put("foo", "bar") - assert_receive %Hook{module: AroundHookCache, name: :put, arity: 3} = hook, 200 - assert hook.acc >= 0 - assert hook.return == :ok - - assert :ok == AroundHookCache.put("hello", "world") - assert_receive %Hook{module: AroundHookCache, name: :put, arity: 3} = hook, 200 - assert hook.acc >= 0 - assert hook.return == :ok - - assert "bar" == AroundHookCache.get("foo") - assert_receive %Hook{module: AroundHookCache, name: :get, arity: 2} = hook, 200 - assert hook.return == "bar" - assert hook.acc >= 0 - - assert "world" == AroundHookCache.get("hello") - assert_receive %Hook{module: AroundHookCache, name: :get, arity: 2} = hook, 200 - assert hook.return == "world" - assert hook.acc >= 0 - - :ok = AroundHookCache.stop() - end - end - - describe "exception" do - defmodule ErrorCache do - @moduledoc false - use Nebulex.Hook - @decorate_all around(&Nebulex.TestCache.TestHook.hook_error/1) - - use Nebulex.Cache, - otp_app: :nebulex, - adapter: Nebulex.Adapters.Local - end - - test "hook" do - {:ok, _pid} = ErrorCache.start_link() - - assert_raise RuntimeError, ~r"hook execution failed on step :before with error", fn -> - ErrorCache.get("foo") - end - - :ok = ErrorCache.stop() - end - end - - ## Helpers - - def hook_fun(%Hook{name: name} = hook) when name in [:get, :put] do - send(self(), hook) - end - - def hook_fun(hook), do: hook -end diff --git a/test/nebulex/telemetry_test.exs b/test/nebulex/telemetry_test.exs index bd4c4fb5..9e62c0d4 100644 --- a/test/nebulex/telemetry_test.exs +++ b/test/nebulex/telemetry_test.exs @@ -81,7 +81,7 @@ defmodule Nebulex.TelemetryTest do ## Tests describe "span/3" do - setup_with_cache(Cache, @config) + setup_with_cache Cache, @config test "ok: emits start and stop events" do with_telemetry_handler(__MODULE__, @start_events ++ @stop_events, fn -> @@ -98,7 +98,7 @@ defmodule Nebulex.TelemetryTest do assert measurements[:duration] > 0 assert metadata[:adapter_meta][:cache] == cache assert metadata[:args] == ["foo", "bar", :infinity, :put, []] - assert metadata[:result] == true + assert metadata[:result] == {:ok, true} assert metadata[:telemetry_span_context] |> is_reference() end end) @@ -106,7 +106,7 @@ defmodule Nebulex.TelemetryTest do test "raise: emits start and exception events" do with_telemetry_handler(__MODULE__, @exception_events, fn -> - Adapter.with_meta(Cache.L3.Primary, fn _, meta -> + Adapter.with_meta(Cache.L3.Primary, fn meta -> true = :ets.delete(meta.meta_tab) end) @@ -153,11 +153,11 @@ defmodule Nebulex.TelemetryTest do end describe "span/3 bypassed" do - setup_with_cache(Cache, Keyword.put(@config, :telemetry, false)) + setup_with_cache Cache, Keyword.put(@config, :telemetry, false) test "telemetry set to false" do for cache <- @caches do - Adapter.with_meta(cache, fn _, meta -> + Adapter.with_meta(cache, fn meta -> assert meta.telemetry == false end) end @@ -172,7 +172,7 @@ defmodule Nebulex.TelemetryTest do get_all: [["foo", "foo foo"]], delete: ["unknown"], take: ["foo foo"], - has_key?: ["foo foo"], + exists?: ["foo foo"], incr: [:counter], ttl: ["foo"], expire: ["foo", 60_000], diff --git a/test/shared/cache/deprecated_test.exs b/test/shared/cache/deprecated_test.exs deleted file mode 100644 index d8890c66..00000000 --- a/test/shared/cache/deprecated_test.exs +++ /dev/null @@ -1,31 +0,0 @@ -defmodule Nebulex.Cache.DeprecatedTest do - import Nebulex.CacheCase - - deftests do - describe "size/0" do - test "returns the current number of entries in cache", %{cache: cache} do - for x <- 1..100, do: cache.put(x, x) - assert cache.size() == 100 - - for x <- 1..50, do: cache.delete(x) - assert cache.size() == 50 - - for x <- 51..60, do: assert(cache.get(x) == x) - assert cache.size() == 50 - end - end - - describe "flush/0" do - test "evicts all entries from cache", %{cache: cache} do - Enum.each(1..2, fn _ -> - for x <- 1..100, do: cache.put(x, x) - - assert cache.flush() == 100 - :ok = Process.sleep(500) - - for x <- 1..100, do: refute(cache.get(x)) - end) - end - end - end -end diff --git a/test/shared/cache/entry_error_test.exs b/test/shared/cache/entry_error_test.exs new file mode 100644 index 00000000..10b06b02 --- /dev/null +++ b/test/shared/cache/entry_error_test.exs @@ -0,0 +1,213 @@ +defmodule Nebulex.Cache.EntryErrorTest do + import Nebulex.CacheCase + + deftests do + import Nebulex.CacheCase, only: [assert_error_module: 2, assert_error_reason: 2] + + describe "put/3" do + test "returns an error", %{cache: cache} = ctx do + assert {:error, %Nebulex.Error{module: module, reason: reason}} = + cache.put("hello", "world") + + assert_error_module(ctx, module) + assert_error_reason(ctx, reason) + end + end + + describe "put_new/3" do + test "returns an error", %{cache: cache} = ctx do + assert {:error, %Nebulex.Error{module: module, reason: reason}} = + cache.put_new("hello", "world") + + assert_error_module(ctx, module) + assert_error_reason(ctx, reason) + end + end + + describe "put_new!/3" do + test "raises an error", %{cache: cache} do + assert_raise Nebulex.Error, fn -> + cache.put_new!("hello", "world") + end + end + end + + describe "replace/3" do + test "returns an error", %{cache: cache} = ctx do + assert {:error, %Nebulex.Error{module: module, reason: reason}} = + cache.replace("hello", "world") + + assert_error_module(ctx, module) + assert_error_reason(ctx, reason) + end + end + + describe "replace!/3" do + test "raises an error", %{cache: cache} do + assert_raise Nebulex.Error, fn -> + cache.replace!("hello", "world") + end + end + end + + describe "put_all/2" do + test "returns an error", %{cache: cache} = ctx do + assert {:error, %Nebulex.Error{module: module, reason: reason}} = + cache.put_all(%{"apples" => 1, "bananas" => 3}) + + assert_error_module(ctx, module) + assert_error_reason(ctx, reason) + end + end + + describe "put_all!/2" do + test "raises an error", %{cache: cache} do + assert_raise Nebulex.Error, fn -> + cache.put_all!(other: 1) + end + end + end + + describe "put_new_all/2" do + test "returns an error", %{cache: cache} = ctx do + assert {:error, %Nebulex.Error{module: module, reason: reason}} = + cache.put_new_all(%{"apples" => 1, "bananas" => 3}) + + assert_error_module(ctx, module) + assert_error_reason(ctx, reason) + end + end + + describe "put_new_all!/2" do + test "raises an error", %{cache: cache} do + assert_raise Nebulex.Error, fn -> + cache.put_new_all!(other: 1) + end + end + end + + describe "fetch/2" do + test "returns an error", %{cache: cache} = ctx do + assert {:error, %Nebulex.Error{module: module, reason: reason}} = cache.fetch(1) + + assert_error_module(ctx, module) + assert_error_reason(ctx, reason) + end + end + + describe "fetch!/2" do + test "raises an error", %{cache: cache} do + assert_raise Nebulex.Error, fn -> + cache.fetch!("raise") + end + end + end + + describe "get/2" do + test "returns an error", %{cache: cache} = ctx do + assert {:error, %Nebulex.Error{module: module, reason: reason}} = cache.get("error") + + assert_error_module(ctx, module) + assert_error_reason(ctx, reason) + end + end + + describe "get!/2" do + test "raises an error", %{cache: cache} do + assert_raise Nebulex.Error, fn -> + cache.get!("raise") + end + end + end + + describe "get_all/2" do + test "returns an error", %{cache: cache} = ctx do + assert {:error, %Nebulex.Error{module: module, reason: reason}} = cache.get_all([1]) + + assert_error_module(ctx, module) + assert_error_reason(ctx, reason) + end + end + + describe "get_all!/2" do + test "raises an error", %{cache: cache} do + assert_raise Nebulex.Error, fn -> + cache.get_all!([:foo]) + end + end + end + + describe "delete/2" do + test "returns an error", %{cache: cache} = ctx do + assert {:error, %Nebulex.Error{module: module, reason: reason}} = cache.delete("error") + + assert_error_module(ctx, module) + assert_error_reason(ctx, reason) + end + end + + describe "delete!/2" do + test "raises an error", %{cache: cache} do + assert_raise Nebulex.Error, fn -> + cache.delete!("raise") + end + end + end + + describe "take/2" do + test "returns an error", %{cache: cache} = ctx do + assert {:error, %Nebulex.Error{module: module, reason: reason}} = cache.take("error") + + assert_error_module(ctx, module) + assert_error_reason(ctx, reason) + end + end + + describe "take!/2" do + test "raises an error", %{cache: cache} do + assert_raise Nebulex.Error, fn -> + cache.take!("raise") + end + end + end + + describe "exists?/1" do + test "returns an error", %{cache: cache} = ctx do + assert {:error, %Nebulex.Error{module: module, reason: reason}} = cache.exists?("error") + + assert_error_module(ctx, module) + assert_error_reason(ctx, reason) + end + end + + describe "update!/4" do + test "raises because put error", %{cache: cache} do + assert_raise Nebulex.Error, fn -> + cache.update!("error", 1, &String.to_integer/1) + end + end + + test "raises because fetch error", %{cache: cache} do + assert_raise Nebulex.Error, fn -> + cache.update!("error", 1, &String.to_integer/1) + end + end + end + + describe "incr!/3" do + test "raises an error", %{cache: cache} do + assert_raise Nebulex.Error, fn -> + cache.incr!(:raise) + end + end + end + + describe "decr!/3" do + test "raises an error", %{cache: cache} do + assert_raise Nebulex.Error, fn -> + cache.decr!(:raise) + end + end + end + end +end diff --git a/test/shared/cache/entry_expiration_error_test.exs b/test/shared/cache/entry_expiration_error_test.exs new file mode 100644 index 00000000..97dd81dc --- /dev/null +++ b/test/shared/cache/entry_expiration_error_test.exs @@ -0,0 +1,21 @@ +defmodule Nebulex.Cache.EntryExpirationErrorTest do + import Nebulex.CacheCase + + deftests do + describe "expire!/2" do + test "raises an error", %{cache: cache} do + assert_raise Nebulex.Error, fn -> + cache.expire!(:raise, 100) + end + end + end + + describe "touch!/1" do + test "raises an error", %{cache: cache} do + assert_raise Nebulex.Error, fn -> + cache.touch!(:raise) + end + end + end + end +end diff --git a/test/shared/cache/entry_expiration_test.exs b/test/shared/cache/entry_expiration_test.exs index 90f34e15..3d1d22db 100644 --- a/test/shared/cache/entry_expiration_test.exs +++ b/test/shared/cache/entry_expiration_test.exs @@ -4,222 +4,224 @@ defmodule Nebulex.Cache.EntryExpirationTest do deftests do describe "ttl option is given to" do test "put", %{cache: cache} do - assert cache.put("foo", "bar", ttl: 500) == :ok - assert cache.has_key?("foo") + assert cache.put!("foo", "bar", ttl: 500) == :ok + assert cache.exists?("foo") == {:ok, true} - Process.sleep(600) - refute cache.has_key?("foo") + :ok = Process.sleep(600) + assert cache.exists?("foo") == {:ok, false} end test "put_all", %{cache: cache} do entries = [{0, nil} | for(x <- 1..3, do: {x, x})] - assert cache.put_all(entries, ttl: 1000) + assert cache.put_all!(entries, ttl: 1000) == :ok - refute cache.get(0) - for x <- 1..3, do: assert(x == cache.get(x)) + refute cache.get!(0) + for x <- 1..3, do: assert(cache.fetch!(x) == x) :ok = Process.sleep(1200) - for x <- 1..3, do: refute(cache.get(x)) + + for x <- 1..3 do + refute cache.get!(x) + end end test "put_new_all", %{cache: cache} do - assert cache.put_new_all(%{"apples" => 1, "bananas" => 3}, ttl: 1000) - assert cache.get("apples") == 1 - assert cache.get("bananas") == 3 + assert cache.put_new_all!(%{"apples" => 1, "bananas" => 3}, ttl: 1000) == true + assert cache.fetch!("apples") == 1 + assert cache.fetch!("bananas") == 3 - refute cache.put_new_all(%{"apples" => 3, "oranges" => 1}) - assert cache.get("apples") == 1 - assert cache.get("bananas") == 3 - refute cache.get("oranges") + assert cache.put_new_all!(%{"apples" => 3, "oranges" => 1}) == false + assert cache.fetch!("apples") == 1 + assert cache.fetch!("bananas") == 3 + refute cache.get!("oranges") :ok = Process.sleep(1200) - refute cache.get("apples") - refute cache.get("bananas") + refute cache.get!("apples") + refute cache.get!("bananas") end test "take", %{cache: cache} do - :ok = cache.put("foo", "bar", ttl: 500) + :ok = cache.put!("foo", "bar", ttl: 500) :ok = Process.sleep(600) - refute cache.take(1) + assert {:error, %Nebulex.KeyError{key: "foo"}} = cache.take("foo") end test "take!", %{cache: cache} do - :ok = cache.put(1, 1, ttl: 100) + :ok = cache.put!(1, 1, ttl: 100) :ok = Process.sleep(500) - assert_raise KeyError, fn -> + assert_raise Nebulex.KeyError, ~r"key 1", fn -> cache.take!(1) end end - test "incr (initializes default value if ttl is expired)", %{cache: cache} do - assert cache.incr(:counter, 1, ttl: 200) == 1 - assert cache.incr(:counter) == 2 + test "incr! (initializes default value if ttl is expired)", %{cache: cache} do + assert cache.incr!(:counter, 1, ttl: 200) == 1 + assert cache.incr!(:counter) == 2 :ok = Process.sleep(210) - assert cache.incr(:counter, 1, ttl: 200) == 1 - assert cache.incr(:counter) == 2 + assert cache.incr!(:counter, 1, ttl: 200) == 1 + assert cache.incr!(:counter) == 2 end end - describe "ttl" do + describe "ttl!/1" do test "returns the remaining ttl for the given key", %{cache: cache} do - assert cache.put(:a, 1, ttl: 500) == :ok - assert cache.ttl(:a) > 0 - assert cache.put(:b, 2) == :ok + assert cache.put!(:a, 1, ttl: 500) == :ok + assert cache.ttl!(:a) > 0 + assert cache.put!(:b, 2) == :ok :ok = Process.sleep(10) - assert cache.ttl(:a) > 0 - assert cache.ttl(:b) == :infinity + assert cache.ttl!(:a) > 0 + assert cache.ttl!(:b) == :infinity :ok = Process.sleep(600) - refute cache.ttl(:a) - assert cache.ttl(:b) == :infinity + assert {:error, %Nebulex.KeyError{key: :a}} = cache.ttl(:a) + assert cache.ttl!(:b) == :infinity end - test "returns nil if key does not exist", %{cache: cache} do - refute cache.ttl(:non_existent) + test "raises Nebulex.KeyError if key does not exist", %{cache: cache, name: name} do + msg = ~r"key :non_existent not found in cache: #{inspect(name)}" + + assert_raise Nebulex.KeyError, msg, fn -> + cache.ttl!(:non_existent) + end end end - describe "expire" do + describe "expire!/2" do test "alters the expiration time for the given key", %{cache: cache} do - assert cache.put(:a, 1, ttl: 500) == :ok - assert cache.ttl(:a) > 0 - - assert cache.expire(:a, 1000) - assert cache.ttl(:a) > 100 + assert cache.put!(:a, 1, ttl: 500) == :ok + assert cache.ttl!(:a) > 0 - assert cache.expire(:a, :infinity) - assert cache.ttl(:a) == :infinity + assert cache.expire!(:a, 1000) == true + assert cache.ttl!(:a) > 100 - refute cache.expire(:b, 5) + assert cache.expire!(:a, :infinity) == true + assert cache.ttl!(:a) == :infinity end test "returns false if key does not exist", %{cache: cache} do - assert cache.expire(:non_existent, 1000) == false + assert cache.expire!(:non_existent, 100) == false end test "raises when ttl is invalid", %{cache: cache} do assert_raise ArgumentError, ~r"expected ttl to be a valid timeout", fn -> - cache.expire(:a, "hello") + cache.expire!(:a, "hello") end end end - describe "touch" do + describe "touch!/1" do test "updates the last access time for the given entry", %{cache: cache} do - assert cache.put(:touch, 1, ttl: 1000) == :ok + assert cache.put!(:touch, 1, ttl: 1000) == :ok :ok = Process.sleep(100) - assert cache.touch(:touch) + assert cache.touch!(:touch) == true :ok = Process.sleep(200) - assert cache.touch(:touch) - assert cache.get(:touch) == 1 + assert cache.touch!(:touch) == true + assert cache.fetch!(:touch) == 1 :ok = Process.sleep(1100) - refute cache.get(:touch) + refute cache.get!(:touch) end test "returns false if key does not exist", %{cache: cache} do - assert cache.touch(:non_existent) == false + assert cache.touch!(:non_existent) == false end end describe "expiration" do test "single entry put with ttl", %{cache: cache} do - assert cache.put(1, 11, ttl: 1000) == :ok - assert cache.get!(1) == 11 + assert cache.put!(1, 11, ttl: 1000) == :ok + assert cache.fetch!(1) == 11 for _ <- 3..1 do - assert cache.ttl(1) > 0 + assert cache.ttl!(1) > 0 Process.sleep(200) end :ok = Process.sleep(500) - refute cache.ttl(1) - assert cache.put(1, 11, ttl: 1000) == :ok - assert cache.ttl(1) > 0 + assert {:error, %Nebulex.KeyError{key: 1}} = cache.ttl(1) + assert cache.put!(1, 11, ttl: 1000) == :ok + assert cache.ttl!(1) > 0 end test "multiple entries put with ttl", %{cache: cache} do - assert cache.put(1, 11, ttl: 1000) == :ok - assert cache.get!(1) == 11 + assert cache.put!(1, 11, ttl: 1000) == :ok + assert cache.fetch!(1) == 11 :ok = Process.sleep(10) - assert cache.get(1) == 11 + assert cache.fetch!(1) == 11 + :ok = Process.sleep(1100) - refute cache.get(1) + refute cache.get!(1) ops = [ - put: ["foo", "bar", [ttl: 1000]], - put_all: [[{"foo", "bar"}], [ttl: 1000]] + put!: ["foo", "bar", [ttl: 1000]], + put_all!: [[{"foo", "bar"}], [ttl: 1000]] ] for {action, args} <- ops do assert apply(cache, action, args) == :ok - :ok = Process.sleep(10) - assert cache.get("foo") == "bar" - :ok = Process.sleep(1200) - refute cache.get("foo") - assert apply(cache, action, args) == :ok :ok = Process.sleep(10) - assert cache.get("foo") == "bar" + assert cache.fetch!("foo") == "bar" + :ok = Process.sleep(1200) - refute cache.get("foo") + refute cache.get!("foo") end end end describe "get_and_update with ttl" do test "existing entry", %{cache: cache} do - assert cache.put(1, 1, ttl: 1000) == :ok - assert cache.ttl(1) > 0 + assert cache.put!(1, 1, ttl: 1000) == :ok + assert cache.ttl!(1) > 0 :ok = Process.sleep(10) - assert cache.get_and_update(1, &cache.get_and_update_fun/1) == {1, 2} - assert cache.ttl(1) == :infinity + assert cache.get_and_update!(1, &cache.get_and_update_fun/1) == {1, 2} + assert cache.ttl!(1) == :infinity :ok = Process.sleep(1200) - assert cache.get(1) == 2 + assert cache.fetch!(1) == 2 end end describe "update with ttl" do test "existing entry", %{cache: cache} do - assert cache.put(1, 1, ttl: 1000) == :ok - assert cache.ttl(1) > 0 + assert cache.put!(1, 1, ttl: 1000) == :ok + assert cache.ttl!(1) > 0 :ok = Process.sleep(10) - assert cache.update(1, 10, &Integer.to_string/1) == "1" - assert cache.ttl(1) == :infinity + assert cache.update!(1, 10, &Integer.to_string/1) == "1" + assert cache.ttl!(1) == :infinity :ok = Process.sleep(1200) - assert cache.get(1) == "1" + assert cache.fetch!(1) == "1" end end describe "incr with ttl" do test "increments a counter", %{cache: cache} do - assert cache.incr(:counter, 1, ttl: 1000) == 1 - assert cache.ttl(1) > 0 + assert cache.incr!(:counter, 1, ttl: 1000) == 1 + assert cache.ttl!(:counter) > 0 :ok = Process.sleep(1200) - refute cache.get(:counter) + refute cache.get!(:counter) end test "increments a counter and then set ttl", %{cache: cache} do - assert cache.incr(:counter, 1) == 1 - assert cache.ttl(:counter) == :infinity + assert cache.incr!(:counter, 1) == 1 + assert cache.ttl!(:counter) == :infinity - assert cache.expire(:counter, 500) + assert cache.expire!(:counter, 500) == true :ok = Process.sleep(600) - refute cache.get(:counter) + refute cache.get!(:counter) end end end diff --git a/test/shared/cache/entry_prop_test.exs b/test/shared/cache/entry_prop_test.exs index ccdc1f2b..f5547519 100644 --- a/test/shared/cache/entry_prop_test.exs +++ b/test/shared/cache/entry_prop_test.exs @@ -7,24 +7,25 @@ defmodule Nebulex.Cache.EntryPropTest do describe "key/value entries" do property "any term", %{cache: cache} do check all term <- term() do - refute cache.get(term) + refute cache.get!(term) - refute cache.replace(term, term) - assert cache.put(term, term) == :ok - refute cache.put_new(term, term) - assert cache.get(term) == term + assert cache.replace!(term, term) == false + assert cache.put!(term, term) == :ok + assert cache.put_new!(term, term) == false + assert cache.fetch!(term) == term - assert cache.replace(term, "replaced") - assert cache.get(term) == "replaced" + assert cache.replace!(term, "replaced") == true + assert cache.fetch!(term) == "replaced" - assert cache.take(term) == "replaced" - refute cache.take(term) + assert cache.take!(term) == "replaced" + assert {:error, %Nebulex.KeyError{key: key}} = cache.take(term) + assert key == term - assert cache.put_new(term, term) - assert cache.get(term) == term + assert cache.put_new!(term, term) == true + assert cache.fetch!(term) == term - assert cache.delete(term) == :ok - refute cache.get(term) + assert cache.delete!(term) == :ok + refute cache.get!(term) end end end diff --git a/test/shared/cache/entry_test.exs b/test/shared/cache/entry_test.exs index f1e045c2..3df5fd97 100644 --- a/test/shared/cache/entry_test.exs +++ b/test/shared/cache/entry_test.exs @@ -6,17 +6,18 @@ defmodule Nebulex.Cache.EntryTest do test "puts the given entry into the cache", %{cache: cache} do for x <- 1..4, do: assert(cache.put(x, x) == :ok) - assert cache.get(1) == 1 - assert cache.get(2) == 2 + assert cache.fetch!(1) == 1 + assert cache.fetch!(2) == 2 for x <- 3..4, do: assert(cache.put(x, x * x) == :ok) - assert cache.get(3) == 9 - assert cache.get(4) == 16 + + assert cache.fetch!(3) == 9 + assert cache.fetch!(4) == 16 end - test "nil value has not any effect", %{cache: cache} do + test "puts a nil value", %{cache: cache} do assert cache.put("foo", nil) == :ok - refute cache.get("foo") + assert cache.fetch("foo") == {:ok, nil} end test "raises when invalid option is given", %{cache: cache} do @@ -24,24 +25,52 @@ defmodule Nebulex.Cache.EntryTest do cache.put("hello", "world", ttl: "1") end end + + test "with :dynamic_cache option", %{cache: cache} = ctx do + if name = Map.get(ctx, :name) do + assert cache.put("foo", "bar", dynamic_cache: name) == :ok + assert cache.fetch!("foo", dynamic_cache: name) == "bar" + assert cache.delete("foo", dynamic_cache: name) == :ok + end + end + + test "with :dynamic_cache option raise and exception", %{cache: cache} do + assert_raise Nebulex.Error, ~r"could not lookup", fn -> + cache.put!("foo", "bar", dynamic_cache: :invalid) + end + end + end + + describe "put!/3" do + test "puts the given entry into the cache", %{cache: cache} do + for x <- 1..4, do: assert(cache.put!(x, x) == :ok) + + assert cache.fetch!(1) == 1 + assert cache.fetch!(2) == 2 + + for x <- 3..4, do: assert(cache.put!(x, x * x) == :ok) + + assert cache.fetch!(3) == 9 + assert cache.fetch!(4) == 16 + end end describe "put_new/3" do test "puts the given entry into the cache if the key does not exist", %{cache: cache} do - assert cache.put_new("foo", "bar") - assert cache.get("foo") == "bar" + assert cache.put_new("foo", "bar") == {:ok, true} + assert cache.fetch!("foo") == "bar" end test "do nothing if key does exist already", %{cache: cache} do :ok = cache.put("foo", "bar") - refute cache.put_new("foo", "bar bar") - assert cache.get("foo") == "bar" + assert cache.put_new("foo", "bar bar") == {:ok, false} + assert cache.fetch!("foo") == "bar" end - test "nil value has not any effect", %{cache: cache} do - assert cache.put_new(:mykey, nil) - refute cache.get(:mykey) + test "puts a new nil value", %{cache: cache} do + assert cache.put_new(:mykey, nil) == {:ok, true} + assert cache.fetch(:mykey) == {:ok, nil} end test "raises when invalid option is given", %{cache: cache} do @@ -53,37 +82,32 @@ defmodule Nebulex.Cache.EntryTest do describe "put_new!/3" do test "puts the given entry into the cache if the key does not exist", %{cache: cache} do - assert cache.put_new!("hello", "world") - assert cache.get("hello") == "world" + assert cache.put_new!("hello", "world") == true + assert cache.fetch!("hello") == "world" end - test "raises when the key does exist in cache", %{cache: cache} do - :ok = cache.put("hello", "world") - - message = ~r"key \"hello\" already exists in cache" - - assert_raise Nebulex.KeyAlreadyExistsError, message, fn -> - cache.put_new!("hello", "world world") - end + test "raises false if the key does exist already", %{cache: cache} do + assert cache.put_new!("hello", "world") == true + assert cache.put_new!("hello", "world") == false end end describe "replace/3" do test "replaces the cached entry with a new value", %{cache: cache} do - refute cache.replace("foo", "bar") + assert cache.replace("foo", "bar") == {:ok, false} assert cache.put("foo", "bar") == :ok - assert cache.get("foo") == "bar" + assert cache.fetch!("foo") == "bar" - assert cache.replace("foo", "bar bar") - assert cache.get("foo") == "bar bar" + assert cache.replace("foo", "bar bar") == {:ok, true} + assert cache.fetch!("foo") == "bar bar" end - test "nil value has not any effect", %{cache: cache} do + test "existing value with nil", %{cache: cache} do :ok = cache.put("hello", "world") - assert cache.replace("hello", nil) - assert cache.get("hello") == "world" + assert cache.replace("hello", nil) == {:ok, true} + assert cache.fetch("hello") == {:ok, nil} end test "raises when invalid option is given", %{cache: cache} do @@ -95,32 +119,29 @@ defmodule Nebulex.Cache.EntryTest do describe "replace!/3" do test "replaces the cached entry with a new value", %{cache: cache} do - :ok = cache.put("foo", "bar") - - assert cache.replace!("foo", "bar bar") - assert cache.get("foo") == "bar bar" + assert cache.put("foo", "bar") == :ok + assert cache.replace!("foo", "bar bar") == true + assert cache.fetch!("foo") == "bar bar" end - test "raises when the key does not exist in cache", %{cache: cache} do - assert_raise KeyError, fn -> - cache.replace!("foo", "bar") - end + test "returns false when the key is not found", %{cache: cache} do + assert cache.replace!("foo", "bar") == false end end describe "put_all/2" do test "puts the given entries at once", %{cache: cache} do - assert cache.put_all(%{"apples" => 1, "bananas" => 3}) - assert cache.put_all(blueberries: 2, strawberries: 5) - assert cache.get("apples") == 1 - assert cache.get("bananas") == 3 - assert cache.get(:blueberries) == 2 - assert cache.get(:strawberries) == 5 + assert cache.put_all(%{"apples" => 1, "bananas" => 3}) == :ok + assert cache.put_all(blueberries: 2, strawberries: 5) == :ok + assert cache.fetch!("apples") == 1 + assert cache.fetch!("bananas") == 3 + assert cache.fetch!(:blueberries) == 2 + assert cache.fetch!(:strawberries) == 5 end test "empty list or map has not any effect", %{cache: cache} do - assert cache.put_all([]) - assert cache.put_all(%{}) + assert cache.put_all([]) == :ok + assert cache.put_all(%{}) == :ok assert count = cache.count_all() assert cache.delete_all() == count end @@ -141,7 +162,8 @@ defmodule Nebulex.Cache.EntryTest do end) assert cache.put_all(entries) == :ok - for {k, v} <- entries, do: assert(cache.get(k) == v) + + for {k, v} <- entries, do: assert(cache.fetch!(k) == v) end test "raises when invalid option is given", %{cache: cache} do @@ -151,21 +173,77 @@ defmodule Nebulex.Cache.EntryTest do end end + describe "put_all!/2" do + test "puts the given entries at once", %{cache: cache} do + assert cache.put_all!(%{"apples" => 1, "bananas" => 3}) == :ok + assert cache.put_all!(blueberries: 2, strawberries: 5) == :ok + assert cache.fetch!("apples") == 1 + assert cache.fetch!("bananas") == 3 + assert cache.fetch!(:blueberries) == 2 + assert cache.fetch!(:strawberries) == 5 + end + end + describe "put_new_all/2" do test "puts the given entries only if none of the keys does exist already", %{cache: cache} do - assert cache.put_new_all(%{"apples" => 1, "bananas" => 3}) - assert cache.get("apples") == 1 - assert cache.get("bananas") == 3 + assert cache.put_new_all(%{"apples" => 1, "bananas" => 3}) == {:ok, true} + assert cache.fetch!("apples") == 1 + assert cache.fetch!("bananas") == 3 - refute cache.put_new_all(%{"apples" => 3, "oranges" => 1}) - assert cache.get("apples") == 1 - assert cache.get("bananas") == 3 - refute cache.get("oranges") + assert cache.put_new_all(%{"apples" => 3, "oranges" => 1}) == {:ok, false} + assert cache.fetch!("apples") == 1 + assert cache.fetch!("bananas") == 3 + refute cache.get!("oranges") end test "raises when invalid option is given", %{cache: cache} do assert_raise ArgumentError, ~r"expected ttl: to be a valid timeout", fn -> - cache.put_all(%{"apples" => 1, "bananas" => 3}, ttl: "1") + cache.put_new_all(%{"apples" => 1, "bananas" => 3}, ttl: "1") + end + end + end + + describe "put_new_all!/2" do + test "puts the given entries only if none of the keys does exist already", %{cache: cache} do + assert cache.put_new_all!(%{"apples" => 1, "bananas" => 3}) == true + assert cache.fetch!("apples") == 1 + assert cache.fetch!("bananas") == 3 + end + + test "raises an error if any of the keys does exist already", %{cache: cache} do + assert cache.put_new_all!(%{"apples" => 1, "bananas" => 3}) == true + assert cache.put_new_all!(%{"apples" => 3, "oranges" => 1}) == false + end + end + + describe "fetch/2" do + test "retrieves a cached entry", %{cache: cache} do + for x <- 1..5 do + :ok = cache.put(x, x) + + assert cache.fetch(x) == {:ok, x} + end + end + + test "returns {:error, :not_found} if key does not exist in cache", %{cache: cache} do + assert {:error, %Nebulex.KeyError{key: "non-existent"}} = cache.fetch("non-existent") + end + end + + describe "fetch!/2" do + test "retrieves a cached entry", %{cache: cache} do + for x <- 1..5 do + :ok = cache.put(x, x) + + assert cache.fetch!(x) == x + end + end + + test "raises when the key does not exist in cache", %{cache: cache, name: name} do + msg = ~r"key \"non-existent\" not found in cache: #{inspect(name)}" + + assert_raise Nebulex.KeyError, msg, fn -> + cache.fetch!("non-existent") end end end @@ -174,12 +252,14 @@ defmodule Nebulex.Cache.EntryTest do test "retrieves a cached entry", %{cache: cache} do for x <- 1..5 do :ok = cache.put(x, x) - assert cache.get(x) == x + + assert cache.get(x) == {:ok, x} end end - test "returns nil if key does not exist in cache", %{cache: cache} do - refute cache.get("non-existent") + test "returns default if key does not exist in cache", %{cache: cache} do + assert cache.get("non-existent") == {:ok, nil} + assert cache.get("non-existent", "default") == {:ok, "default"} end end @@ -187,30 +267,46 @@ defmodule Nebulex.Cache.EntryTest do test "retrieves a cached entry", %{cache: cache} do for x <- 1..5 do :ok = cache.put(x, x) + assert cache.get!(x) == x end end - test "raises when the key does not exist in cache", %{cache: cache} do - assert_raise KeyError, fn -> - cache.get!("non-existent") - end + test "returns default if key does not exist in cache", %{cache: cache} do + refute cache.get!("non-existent") + assert cache.get!("non-existent", "default") == "default" end end describe "get_all/2" do test "returns a map with the given keys", %{cache: cache} do - assert cache.put_all(a: 1, c: 3) - assert cache.get_all([:a, :b, :c]) == %{a: 1, c: 3} - assert cache.delete_all() == 2 + assert cache.put_all(a: 1, c: 3) == :ok + assert cache.get_all([:a, :b, :c]) == {:ok, %{a: 1, c: 3}} + assert cache.delete_all!() == 2 + end + + test "returns an empty map when none of the given keys is in cache", %{cache: cache} do + assert cache.get_all(["foo", "bar", 1, :a]) == {:ok, %{}} + end + + test "returns an empty map when the given key list is empty", %{cache: cache} do + assert cache.get_all([]) == {:ok, %{}} + end + end + + describe "get_all!/2" do + test "returns a map with the given keys", %{cache: cache} do + assert cache.put_all(a: 1, c: 3) == :ok + assert cache.get_all!([:a, :b, :c]) == %{a: 1, c: 3} + assert cache.delete_all!() == 2 end test "returns an empty map when none of the given keys is in cache", %{cache: cache} do - assert map_size(cache.get_all(["foo", "bar", 1, :a])) == 0 + assert cache.get_all!(["foo", "bar", 1, :a]) == %{} end test "returns an empty map when the given key list is empty", %{cache: cache} do - assert map_size(cache.get_all([])) == 0 + assert cache.get_all!([]) == %{} end end @@ -218,15 +314,25 @@ defmodule Nebulex.Cache.EntryTest do test "deletes the given key", %{cache: cache} do for x <- 1..3, do: cache.put(x, x * 2) - assert cache.get(1) == 2 + assert cache.fetch!(1) == 2 assert cache.delete(1) == :ok - refute cache.get(1) + refute cache.get!(1) - assert cache.get(2) == 4 - assert cache.get(3) == 6 + assert cache.fetch!(2) == 4 + assert cache.fetch!(3) == 6 assert cache.delete(:non_existent) == :ok - refute cache.get(:non_existent) + refute cache.get!(:non_existent) + end + end + + describe "delete!/2" do + test "deletes the given key", %{cache: cache} do + assert cache.put("foo", "bar") == :ok + + assert cache.fetch!("foo") == "bar" + assert cache.delete!("foo") == :ok + refute cache.get!("foo") end end @@ -234,14 +340,15 @@ defmodule Nebulex.Cache.EntryTest do test "returns the given key and removes it from cache", %{cache: cache} do for x <- 1..5 do :ok = cache.put(x, x) - assert cache.take(x) == x - refute cache.take(x) + + assert cache.take(x) == {:ok, x} + assert {:error, %Nebulex.KeyError{key: ^x}} = cache.take(x) end end test "returns nil if the key does not exist in cache", %{cache: cache} do - refute cache.take(:non_existent) - refute cache.take(nil) + assert {:error, %Nebulex.KeyError{key: :non_existent}} = cache.take(:non_existent) + assert {:error, %Nebulex.KeyError{key: nil}} = cache.take(nil) end end @@ -249,78 +356,86 @@ defmodule Nebulex.Cache.EntryTest do test "returns the given key and removes it from cache", %{cache: cache} do assert cache.put(1, 1) == :ok assert cache.take!(1) == 1 + assert cache.get!(1) == nil end - test "raises when the key does not exist in cache", %{cache: cache} do - assert_raise KeyError, fn -> - cache.take!(:non_existent) - end + test "raises when the key does not exist in cache", %{cache: cache, name: name} do + msg = ~r"key \"non-existent\" not found in cache: #{inspect(name)}" - assert_raise KeyError, fn -> - cache.take!(nil) + assert_raise Nebulex.KeyError, msg, fn -> + cache.take!("non-existent") end end end - describe "has_key?/1" do + describe "exists?/1" do test "returns true if key does exist in cache", %{cache: cache} do for x <- 1..5 do :ok = cache.put(x, x) - assert cache.has_key?(x) + + assert cache.exists?(x) == {:ok, true} end end test "returns false if key does not exist in cache", %{cache: cache} do - refute cache.has_key?(:non_existent) - refute cache.has_key?(nil) + assert cache.exists?(:non_existent) == {:ok, false} + assert cache.exists?(nil) == {:ok, false} end end - describe "update/4" do + describe "update!/4" do test "updates an entry under a key applying a function on the value", %{cache: cache} do :ok = cache.put("foo", "123") :ok = cache.put("bar", "foo") - assert cache.update("foo", 1, &String.to_integer/1) == 123 - assert cache.update("bar", "init", &String.to_atom/1) == :foo + assert cache.update!("foo", 1, &String.to_integer/1) == 123 + assert cache.update!("bar", "init", &String.to_atom/1) == :foo end test "creates the entry with the default value if key does not exist", %{cache: cache} do - assert cache.update("foo", "123", &Integer.to_string/1) == "123" + assert cache.update!("foo", "123", &Integer.to_string/1) == "123" end - test "has not any effect if the given value is nil", %{cache: cache} do - refute cache.update("bar", nil, &Integer.to_string/1) - refute cache.get("bar") + test "updates existing value with nil", %{cache: cache} do + assert cache.update!("bar", nil, &Integer.to_string/1) == nil + assert cache.fetch!("bar") == nil + end + + test "raises because the cache is not started", %{cache: cache} do + :ok = cache.stop() + + assert_raise Nebulex.Error, fn -> + cache.update!("error", 1, &String.to_integer/1) + end end end describe "incr/3" do test "increments a counter by the given amount", %{cache: cache} do - assert cache.incr(:counter) == 1 - assert cache.incr(:counter) == 2 - assert cache.incr(:counter, 2) == 4 - assert cache.incr(:counter, 3) == 7 - assert cache.incr(:counter, 0) == 7 + assert cache.incr(:counter) == {:ok, 1} + assert cache.incr(:counter) == {:ok, 2} + assert cache.incr(:counter, 2) == {:ok, 4} + assert cache.incr(:counter, 3) == {:ok, 7} + assert cache.incr(:counter, 0) == {:ok, 7} - assert :counter |> cache.get() |> to_int() == 7 + assert :counter |> cache.fetch!() |> to_int() == 7 - assert cache.incr(:counter, -1) == 6 - assert cache.incr(:counter, -1) == 5 - assert cache.incr(:counter, -2) == 3 - assert cache.incr(:counter, -3) == 0 + assert cache.incr(:counter, -1) == {:ok, 6} + assert cache.incr(:counter, -1) == {:ok, 5} + assert cache.incr(:counter, -2) == {:ok, 3} + assert cache.incr(:counter, -3) == {:ok, 0} end test "increments a counter by the given amount with default", %{cache: cache} do - assert cache.incr(:counter1, 1, default: 10) == 11 - assert cache.incr(:counter2, 2, default: 10) == 12 - assert cache.incr(:counter3, -2, default: 10) == 8 + assert cache.incr(:counter1, 1, default: 10) == {:ok, 11} + assert cache.incr(:counter2, 2, default: 10) == {:ok, 12} + assert cache.incr(:counter3, -2, default: 10) == {:ok, 8} end test "increments a counter by the given amount ignoring the default", %{cache: cache} do - assert cache.incr(:counter) == 1 - assert cache.incr(:counter, 1, default: 10) == 2 - assert cache.incr(:counter, -1, default: 100) == 1 + assert cache.incr(:counter) == {:ok, 1} + assert cache.incr(:counter, 1, default: 10) == {:ok, 2} + assert cache.incr(:counter, -1, default: 100) == {:ok, 1} end test "raises when amount is invalid", %{cache: cache} do @@ -336,32 +451,49 @@ defmodule Nebulex.Cache.EntryTest do end end + describe "incr!/3" do + test "increments a counter by the given amount", %{cache: cache} do + assert cache.incr!(:counter) == 1 + assert cache.incr!(:counter) == 2 + assert cache.incr!(:counter, 2) == 4 + assert cache.incr!(:counter, 3) == 7 + assert cache.incr!(:counter, 0) == 7 + + assert :counter |> cache.fetch!() |> to_int() == 7 + + assert cache.incr!(:counter, -1) == 6 + assert cache.incr!(:counter, -1) == 5 + assert cache.incr!(:counter, -2) == 3 + assert cache.incr!(:counter, -3) == 0 + end + end + describe "decr/3" do test "decrements a counter by the given amount", %{cache: cache} do - assert cache.decr(:counter) == -1 - assert cache.decr(:counter) == -2 - assert cache.decr(:counter, 2) == -4 - assert cache.decr(:counter, 3) == -7 - assert cache.decr(:counter, 0) == -7 + assert cache.decr(:counter) == {:ok, -1} + assert cache.decr(:counter) == {:ok, -2} + assert cache.decr(:counter, 2) == {:ok, -4} + assert cache.decr(:counter, 3) == {:ok, -7} + assert cache.decr(:counter, 0) == {:ok, -7} - assert :counter |> cache.get() |> to_int() == -7 + assert :counter |> cache.fetch!() |> to_int() == -7 - assert cache.decr(:counter, -1) == -6 - assert cache.decr(:counter, -1) == -5 - assert cache.decr(:counter, -2) == -3 - assert cache.decr(:counter, -3) == 0 + assert cache.decr(:counter, -1) == {:ok, -6} + assert cache.decr(:counter, -1) == {:ok, -5} + assert cache.decr(:counter, -2) == {:ok, -3} + assert cache.decr(:counter, -3) == {:ok, 0} end test "decrements a counter by the given amount with default", %{cache: cache} do - assert cache.decr(:counter1, 1, default: 10) == 9 - assert cache.decr(:counter2, 2, default: 10) == 8 - assert cache.decr(:counter3, -2, default: 10) == 12 + assert cache.decr(:counter1, 1, default: 10) == {:ok, 9} + assert cache.decr(:counter2, 2, default: 10) == {:ok, 8} + assert cache.decr(:counter3, -2, default: 10) == {:ok, 12} end test "decrements a counter by the given amount ignoring the default", %{cache: cache} do - assert cache.decr(:counter) == -1 - assert cache.decr(:counter, 1, default: 10) == -2 - assert cache.decr(:counter, -1, default: 100) == -1 + assert cache.decr(:counter) == {:ok, -1} + assert cache.decr(:counter, 1, default: 10) == {:ok, -2} + assert cache.decr(:counter, -1, default: 100) == {:ok, -1} end test "raises when amount is invalid", %{cache: cache} do @@ -377,6 +509,23 @@ defmodule Nebulex.Cache.EntryTest do end end + describe "decr!/3" do + test "decrements a counter by the given amount", %{cache: cache} do + assert cache.decr!(:counter) == -1 + assert cache.decr!(:counter) == -2 + assert cache.decr!(:counter, 2) == -4 + assert cache.decr!(:counter, 3) == -7 + assert cache.decr!(:counter, 0) == -7 + + assert :counter |> cache.fetch!() |> to_int() == -7 + + assert cache.decr!(:counter, -1) == -6 + assert cache.decr!(:counter, -1) == -5 + assert cache.decr!(:counter, -2) == -3 + assert cache.decr!(:counter, -3) == 0 + end + end + ## Helpers defp to_int(data) when is_integer(data), do: data diff --git a/test/shared/cache/persistence_error_test.exs b/test/shared/cache/persistence_error_test.exs index cd246e53..077780e4 100644 --- a/test/shared/cache/persistence_error_test.exs +++ b/test/shared/cache/persistence_error_test.exs @@ -2,12 +2,36 @@ defmodule Nebulex.Cache.PersistenceErrorTest do import Nebulex.CacheCase deftests "persistence error" do - test "dump: invalid path", %{cache: cache} do - assert cache.dump("/invalid/path") == {:error, :enoent} + test "dump/2 fails because invalid path", %{cache: cache} do + assert cache.dump("/invalid/path") == + {:error, + %Nebulex.Error{ + module: Nebulex.Error, + reason: %File.Error{action: "open", path: "/invalid/path", reason: :enoent} + }} end - test "load: invalid path", %{cache: cache} do - assert cache.load("wrong_file") == {:error, :enoent} + test "dump!/2 raises because invalid path", %{cache: cache} do + msg = "could not open \"/invalid/path\": no such file or directory" + + assert_raise Nebulex.Error, msg, fn -> + cache.dump!("/invalid/path") + end + end + + test "load/2 error because invalid path", %{cache: cache} do + assert cache.load("wrong_file") == + {:error, + %Nebulex.Error{ + module: Nebulex.Error, + reason: %File.Error{action: "open", path: "wrong_file", reason: :enoent} + }} + end + + test "load!/2 raises because invalid path", %{cache: cache} do + assert_raise Nebulex.Error, "could not open \"wrong_file\": no such file or directory", fn -> + cache.load!("wrong_file") + end end end end diff --git a/test/shared/cache/persistence_test.exs b/test/shared/cache/persistence_test.exs index 5d77b954..11ec1f12 100644 --- a/test/shared/cache/persistence_test.exs +++ b/test/shared/cache/persistence_test.exs @@ -7,11 +7,11 @@ defmodule Nebulex.Cache.PersistenceTest do path = "#{tmp}/#{cache}" try do - assert cache.count_all() == 0 + assert cache.count_all!() == 0 assert cache.dump(path) == :ok assert File.exists?(path) assert cache.load(path) == :ok - assert cache.count_all() == 0 + assert cache.count_all!() == 0 count = 100 unexpired = for x <- 1..count, into: %{}, do: {x, x} @@ -19,19 +19,19 @@ defmodule Nebulex.Cache.PersistenceTest do assert cache.put_all(unexpired) == :ok assert cache.put_all(%{a: 1, b: 2}, ttl: 10) == :ok assert cache.put_all(%{c: 1, d: 2}, ttl: 3_600_000) == :ok - assert cache.count_all() == count + 4 + assert cache.count_all!() == count + 4 :ok = Process.sleep(1000) assert cache.dump(path) == :ok assert File.exists?(path) - assert cache.delete_all() == count + 4 - assert cache.count_all() == 0 + assert cache.delete_all!() == count + 4 + assert cache.count_all!() == 0 assert cache.load(path) == :ok - assert cache.get_all(1..count) == unexpired - assert cache.get_all([:a, :b, :c, :d]) == %{c: 1, d: 2} - assert cache.count_all() == count + 2 + assert cache.get_all!(1..count) == unexpired + assert cache.get_all!([:a, :b, :c, :d]) == %{c: 1, d: 2} + assert cache.count_all!() == count + 2 after File.rm_rf!(path) end diff --git a/test/shared/cache/queryable_test.exs b/test/shared/cache/queryable_test.exs index 22d9c000..8fad26f1 100644 --- a/test/shared/cache/queryable_test.exs +++ b/test/shared/cache/queryable_test.exs @@ -5,87 +5,108 @@ defmodule Nebulex.Cache.QueryableTest do import Nebulex.CacheCase describe "all/2" do - test "returns all keys in cache", %{cache: cache} do + test "ok: returns all keys in cache", %{cache: cache} do set1 = cache_put(cache, 1..50) set2 = cache_put(cache, 51..100) - for x <- 1..100, do: assert(cache.get(x) == x) + for x <- 1..100, do: assert(cache.fetch!(x) == x) expected = set1 ++ set2 - assert :lists.usort(cache.all()) == expected + assert cache.all!() |> :lists.usort() == expected set3 = Enum.to_list(20..60) - :ok = Enum.each(set3, &cache.delete(&1)) + :ok = Enum.each(set3, &cache.delete!(&1)) expected = :lists.usort(expected -- set3) - assert :lists.usort(cache.all()) == expected + assert cache.all!() |> :lists.usort() == expected + end + + test "error: query error", %{cache: cache} = test_opts do + on_error = test_opts[:on_error] || fn %Nebulex.QueryError{} -> :ok end + + assert {:error, reason} = cache.all(:invalid) + on_error.(reason) end end describe "stream/2" do @entries for x <- 1..10, into: %{}, do: {x, x * 2} - test "returns all keys in cache", %{cache: cache} do + test "ok: returns all keys in cache", %{cache: cache} do :ok = cache.put_all(@entries) assert nil - |> cache.stream() + |> cache.stream!() |> Enum.to_list() |> :lists.usort() == Map.keys(@entries) end - test "returns all values in cache", %{cache: cache} do + test "ok: returns all values in cache", %{cache: cache} do :ok = cache.put_all(@entries) assert nil - |> cache.stream(return: :value, page_size: 3) + |> cache.stream!(return: :value, page_size: 3) |> Enum.to_list() |> :lists.usort() == Map.values(@entries) end - test "returns all key/value pairs in cache", %{cache: cache} do + test "ok: returns all key/value pairs in cache", %{cache: cache} do :ok = cache.put_all(@entries) assert nil - |> cache.stream(return: {:key, :value}, page_size: 3) + |> cache.stream!(return: {:key, :value}, page_size: 3) |> Enum.to_list() |> :lists.usort() == :maps.to_list(@entries) end - test "raises when query is invalid", %{cache: cache} do + test "error: raises when query is invalid", %{cache: cache} do assert_raise Nebulex.QueryError, fn -> :invalid_query - |> cache.stream() + |> cache.stream!() |> Enum.to_list() end end end describe "delete_all/2" do - test "evicts all entries in the cache", %{cache: cache} do + test "ok: evicts all entries in the cache", %{cache: cache} do Enum.each(1..2, fn _ -> entries = cache_put(cache, 1..50) - assert cache.all() |> :lists.usort() |> length() == length(entries) + assert cache.all!() |> :lists.usort() |> length() == length(entries) - cached = cache.count_all() - assert cache.delete_all() == cached - assert cache.count_all() == 0 + cached = cache.count_all!() + assert cache.delete_all!() == cached + assert cache.count_all!() == 0 end) end + + test "error: query error", %{cache: cache} = test_opts do + on_error = test_opts[:on_error] || fn %Nebulex.QueryError{} -> :ok end + + assert {:error, reason} = cache.delete_all(:invalid) + on_error.(reason) + end end describe "count_all/2" do - test "returns the total number of cached entries", %{cache: cache} do + test "ok: returns the total number of cached entries", %{cache: cache} do for x <- 1..100, do: cache.put(x, x) - total = cache.all() |> length() - assert cache.count_all() == total + total = cache.all!() |> length() + assert cache.count_all!() == total + + for x <- 1..50, do: cache.delete!(x) + total = cache.all!() |> length() + assert cache.count_all!() == total + + for x <- 51..60, do: assert(cache.fetch!(x) == x) + end - for x <- 1..50, do: cache.delete(x) - total = cache.all() |> length() - assert cache.count_all() == total + test "error: query error", %{cache: cache} = test_opts do + on_error = test_opts[:on_error] || fn %Nebulex.QueryError{} -> :ok end - for x <- 51..60, do: assert(cache.get(x) == x) + assert {:error, reason} = cache.count_all(:invalid) + on_error.(reason) end end end diff --git a/test/shared/cache/transaction_test.exs b/test/shared/cache/transaction_test.exs index 0475f145..1bed64cc 100644 --- a/test/shared/cache/transaction_test.exs +++ b/test/shared/cache/transaction_test.exs @@ -4,54 +4,54 @@ defmodule Nebulex.Cache.TransactionTest do deftests do describe "transaction" do test "ok: single transaction", %{cache: cache} do - refute cache.transaction(fn -> + assert cache.transaction(fn -> with :ok <- cache.put(1, 11), - 11 <- cache.get!(1), + 11 <- cache.fetch!(1), :ok <- cache.delete(1) do - cache.get(1) + cache.get!(1) end - end) + end) == {:ok, nil} end test "ok: nested transaction", %{cache: cache} do - refute cache.transaction( + assert cache.transaction( [keys: [1]], fn -> cache.transaction( [keys: [2]], fn -> with :ok <- cache.put(1, 11), - 11 <- cache.get!(1), + 11 <- cache.fetch!(1), :ok <- cache.delete(1) do - cache.get(1) + cache.get!(1) end end ) end - ) + ) == {:ok, {:ok, nil}} end test "ok: single transaction with read and write operations", %{cache: cache} do assert cache.put(:test, ["old value"]) == :ok - assert cache.get(:test) == ["old value"] + assert cache.fetch!(:test) == ["old value"] assert cache.transaction( [keys: [:test]], fn -> - ["old value"] = value = cache.get(:test) + ["old value"] = value = cache.fetch!(:test) :ok = cache.put(:test, ["new value" | value]) - cache.get(:test) + cache.fetch!(:test) end - ) == ["new value", "old value"] + ) == {:ok, ["new value", "old value"]} - assert cache.get(:test) == ["new value", "old value"] + assert cache.fetch!(:test) == ["new value", "old value"] end - test "raises exception", %{cache: cache} do + test "error: exception is raised", %{cache: cache} do assert_raise MatchError, fn -> cache.transaction(fn -> with :ok <- cache.put(1, 11), - 11 <- cache.get!(1), + 11 <- cache.fetch!(1), :ok <- cache.delete(1) do :ok = cache.get(1) end @@ -74,24 +74,27 @@ defmodule Nebulex.Cache.TransactionTest do :ok = Process.sleep(200) - assert_raise RuntimeError, "transaction aborted", fn -> - cache.transaction( - [keys: [:aborted], retries: 1], - fn -> - cache.get(:aborted) - end - ) + assert_raise Nebulex.Error, ~r"Cache #{inspect(name)} has aborted a transaction", fn -> + {:error, %Nebulex.Error{} = reason} = + cache.transaction( + [keys: [:aborted], retries: 1], + fn -> + cache.get(:aborted) + end + ) + + raise reason end end end describe "in_transaction?" do test "returns true if calling process is already within a transaction", %{cache: cache} do - refute cache.in_transaction?() + assert cache.in_transaction?() == {:ok, false} cache.transaction(fn -> :ok = cache.put(1, 11, return: :key) - true = cache.in_transaction?() + {:ok, true} = cache.in_transaction?() end) end end diff --git a/test/shared/cache_test.exs b/test/shared/cache_test.exs index 792323ca..f04bec54 100644 --- a/test/shared/cache_test.exs +++ b/test/shared/cache_test.exs @@ -12,7 +12,6 @@ defmodule Nebulex.CacheTest do use Nebulex.Cache.TransactionTest use Nebulex.Cache.PersistenceTest use Nebulex.Cache.PersistenceErrorTest - use Nebulex.Cache.DeprecatedTest end end end diff --git a/test/shared/local_test.exs b/test/shared/local_test.exs index fb4fb9e5..47f597f9 100644 --- a/test/shared/local_test.exs +++ b/test/shared/local_test.exs @@ -3,27 +3,33 @@ defmodule Nebulex.LocalTest do deftests do import Ex2ms - import Nebulex.CacheCase + import Nebulex.CacheCase, only: [cache_put: 2, cache_put: 3, cache_put: 4] alias Nebulex.{Adapter, Entry} describe "error" do test "on init because invalid backend", %{cache: cache} do - assert {:error, {%RuntimeError{message: msg}, _}} = + assert {:error, {%ArgumentError{message: msg}, _}} = cache.start_link(name: :invalid_backend, backend: :xyz) - assert msg == - "expected backend: option to be one of the supported " <> - "backends [:ets, :shards], got: :xyz" + assert Regex.match?(~r/invalid value for :backend/, msg) end - test "because cache is stopped", %{cache: cache} do + test "because cache is stopped", %{cache: cache, name: name} do :ok = cache.stop() + assert cache.put(1, 13) == + {:error, + %Nebulex.Error{ + module: Nebulex.Error, + reason: {:registry_lookup_error, name} + }} + msg = ~r"could not lookup Nebulex cache" - assert_raise Nebulex.RegistryLookupError, msg, fn -> cache.put(1, 13) end - assert_raise Nebulex.RegistryLookupError, msg, fn -> cache.get(1) end - assert_raise Nebulex.RegistryLookupError, msg, fn -> cache.delete(1) end + + assert_raise Nebulex.Error, msg, fn -> cache.put!(1, 13) end + assert_raise Nebulex.Error, msg, fn -> cache.get!(1) end + assert_raise Nebulex.Error, msg, fn -> cache.delete!(1) end end end @@ -34,69 +40,102 @@ defmodule Nebulex.LocalTest do val -> {val, val * 2} end - assert cache.get_and_update(1, fun) == {nil, 1} - - assert cache.get_and_update(1, &{&1, &1 * 2}) == {1, 2} - assert cache.get_and_update(1, &{&1, &1 * 3}) == {2, 6} - assert cache.get_and_update(1, &{&1, nil}) == {6, 6} - assert cache.get(1) == 6 - assert cache.get_and_update(1, fn _ -> :pop end) == {6, nil} - assert cache.get_and_update(1, fn _ -> :pop end) == {nil, nil} - assert cache.get_and_update(3, &{&1, 3}) == {nil, 3} + assert cache.get_and_update!(1, fun) == {nil, 1} + assert cache.get_and_update!(1, &{&1, &1 * 2}) == {1, 2} + assert cache.get_and_update!(1, &{&1, &1 * 3}) == {2, 6} + assert cache.get_and_update!(1, &{&1, nil}) == {6, 6} + assert cache.get!(1) == 6 + assert cache.get_and_update!(1, fn _ -> :pop end) == {6, nil} + assert cache.get_and_update!(1, fn _ -> :pop end) == {nil, nil} + assert cache.get_and_update!(3, &{&1, 3}) == {nil, 3} + end + test "get_and_update fails because function returns invalid value", %{cache: cache} do assert_raise ArgumentError, fn -> cache.get_and_update(1, fn _ -> :other end) end end + test "get_and_update fails because cache is not started", %{cache: cache} do + :ok = cache.stop() + + assert_raise Nebulex.Error, fn -> + assert cache.get_and_update!(1, fn _ -> :pop end) + end + end + test "incr and update", %{cache: cache} do - assert cache.incr(:counter) == 1 - assert cache.incr(:counter) == 2 + assert cache.incr!(:counter) == 1 + assert cache.incr!(:counter) == 2 - assert cache.get_and_update(:counter, &{&1, &1 * 2}) == {2, 4} - assert cache.incr(:counter) == 5 + assert cache.get_and_update!(:counter, &{&1, &1 * 2}) == {2, 4} + assert cache.incr!(:counter) == 5 - assert cache.update(:counter, 1, &(&1 * 2)) == 10 - assert cache.incr(:counter, -10) == 0 + assert cache.update!(:counter, 1, &(&1 * 2)) == 10 + assert cache.incr!(:counter, -10) == 0 assert cache.put("foo", "bar") == :ok assert_raise ArgumentError, fn -> - cache.incr("foo") + cache.incr!("foo") end end test "incr with ttl", %{cache: cache} do - assert cache.incr(:counter_with_ttl, 1, ttl: 1000) == 1 - assert cache.incr(:counter_with_ttl) == 2 - assert cache.get(:counter_with_ttl) == 2 + assert cache.incr!(:counter_with_ttl, 1, ttl: 1000) == 1 + assert cache.incr!(:counter_with_ttl) == 2 + assert cache.fetch!(:counter_with_ttl) == 2 :ok = Process.sleep(1010) - refute cache.get(:counter_with_ttl) - assert cache.incr(:counter_with_ttl, 1, ttl: 5000) == 1 - assert cache.ttl(:counter_with_ttl) > 1000 + assert {:error, %Nebulex.KeyError{key: :counter_with_ttl}} = cache.fetch(:counter_with_ttl) + + assert cache.incr!(:counter_with_ttl, 1, ttl: 5000) == 1 + assert {:ok, ttl} = cache.ttl(:counter_with_ttl) + assert ttl > 1000 + + assert cache.expire(:counter_with_ttl, 500) == {:ok, true} - assert cache.expire(:counter_with_ttl, 500) :ok = Process.sleep(600) - refute cache.get(:counter_with_ttl) + + assert {:error, %Nebulex.KeyError{key: :counter_with_ttl}} = cache.fetch(:counter_with_ttl) end test "incr existing entry", %{cache: cache} do assert cache.put(:counter, 0) == :ok - assert cache.incr(:counter) == 1 - assert cache.incr(:counter, 2) == 3 + assert cache.incr!(:counter) == 1 + assert cache.incr!(:counter, 2) == 3 end end describe "queryable:" do + test "error because invalid query", %{cache: cache} do + for action <- [:all, :stream] do + assert {:error, %Nebulex.QueryError{}} = apply(cache, action, [:invalid]) + end + end + + test "raise exception because invalid query", %{cache: cache} do + for action <- [:all!, :stream!] do + assert_raise Nebulex.QueryError, ~r"expected query to be one of", fn -> + all_or_stream(cache, action, :invalid) + end + end + end + + test "default query error message" do + assert_raise Nebulex.QueryError, "invalid query :invalid", fn -> + raise Nebulex.QueryError, query: :invalid + end + end + test "ETS match_spec queries", %{cache: cache, name: name} do values = cache_put(cache, 1..5, &(&1 * 2)) _ = new_generation(cache, name) values = values ++ cache_put(cache, 6..10, &(&1 * 2)) assert nil - |> cache.stream(page_size: 3, return: :value) + |> cache.stream!(page_size: 3, return: :value) |> Enum.to_list() |> :lists.usort() == values @@ -107,19 +146,13 @@ defmodule Nebulex.LocalTest do {_, _, value, _, _} when value > 10 -> value end - for action <- [:all, :stream] do + for action <- [:all!, :stream!] do assert all_or_stream(cache, action, test_ms, page_size: 3, return: :value) == expected - - msg = ~r"invalid match spec" - - assert_raise Nebulex.QueryError, msg, fn -> - all_or_stream(cache, action, :invalid_query) - end end end test "expired and unexpired queries", %{cache: cache} do - for action <- [:all, :stream] do + for action <- [:all!, :stream!] do expired = cache_put(cache, 1..5, &(&1 * 2), ttl: 1000) unexpired = cache_put(cache, 6..10, &(&1 * 2)) @@ -141,7 +174,7 @@ defmodule Nebulex.LocalTest do test "all entries", %{cache: cache} do assert cache.put_all([a: 1, b: 2, c: 3], ttl: 5000) == :ok - assert all = cache.all(:unexpired, return: :entry) + assert all = cache.all!(:unexpired, return: :entry) assert length(all) == 3 for %Entry{} = entry <- all do @@ -153,18 +186,18 @@ defmodule Nebulex.LocalTest do _ = cache_put(cache, 1..5, & &1, ttl: 1500) _ = cache_put(cache, 6..10) - assert cache.delete_all(:expired) == 0 - assert cache.count_all(:expired) == 0 + assert cache.delete_all!(:expired) == 0 + assert cache.count_all!(:expired) == 0 :ok = Process.sleep(1600) - assert cache.delete_all(:expired) == 5 - assert cache.count_all(:expired) == 0 - assert cache.count_all(:unexpired) == 5 + assert cache.delete_all!(:expired) == 5 + assert cache.count_all!(:expired) == 0 + assert cache.count_all!(:unexpired) == 5 - assert cache.delete_all(:unexpired) == 5 - assert cache.count_all(:unexpired) == 0 - assert cache.count_all() == 0 + assert cache.delete_all!(:unexpired) == 5 + assert cache.count_all!(:unexpired) == 0 + assert cache.count_all!() == 0 end test "delete all matched entries", %{cache: cache, name: name} do @@ -174,7 +207,7 @@ defmodule Nebulex.LocalTest do values = values ++ cache_put(cache, 6..10) - assert cache.count_all() == 10 + assert cache.count_all!() == 10 test_ms = fun do @@ -183,12 +216,12 @@ defmodule Nebulex.LocalTest do {expected, rem} = Enum.split_with(values, &(rem(&1, 2) == 0)) - assert cache.count_all(test_ms) == 5 - assert cache.all(test_ms) |> Enum.sort() == Enum.sort(expected) + assert cache.count_all!(test_ms) == 5 + assert cache.all!(test_ms) |> Enum.sort() == Enum.sort(expected) - assert cache.delete_all(test_ms) == 5 - assert cache.count_all(test_ms) == 0 - assert cache.all() |> Enum.sort() == Enum.sort(rem) + assert cache.delete_all!(test_ms) == 5 + assert cache.count_all!(test_ms) == 0 + assert cache.all!() |> Enum.sort() == Enum.sort(rem) end test "delete all entries given by a list of keys", %{cache: cache} do @@ -196,12 +229,12 @@ defmodule Nebulex.LocalTest do :ok = cache.put_all(entries) - assert cache.count_all() == 10 + assert cache.count_all!() == 10 - assert cache.delete_all({:in, [2, 4, 6, 8, 10, 12]}) == 5 + assert cache.delete_all!({:in, [2, 4, 6, 8, 10, 12]}) == 5 - assert cache.count_all() == 5 - assert cache.all() |> Enum.sort() == [1, 3, 5, 7, 9] + assert cache.count_all!() == 5 + assert cache.all!() |> Enum.sort() == [1, 3, 5, 7, 9] end end @@ -221,28 +254,28 @@ defmodule Nebulex.LocalTest do end test "put_new/3 (fallback to older generation)", %{cache: cache, name: name} do - assert cache.put_new("foo", "bar") == true + assert cache.put_new!("foo", "bar") == true _ = new_generation(cache, name) refute get_from_new(cache, name, "foo") assert get_from_old(cache, name, "foo") == "bar" - assert cache.put_new("foo", "bar") == false + assert cache.put_new!("foo", "bar") == false refute get_from_new(cache, name, "foo") assert get_from_old(cache, name, "foo") == "bar" _ = new_generation(cache, name) - assert cache.put_new("foo", "bar") == true + assert cache.put_new!("foo", "bar") == true assert get_from_new(cache, name, "foo") == "bar" refute get_from_old(cache, name, "foo") end test "replace/3 (fallback to older generation)", %{cache: cache, name: name} do - assert cache.replace("foo", "bar bar") == false + assert cache.replace!("foo", "bar bar") == false :ok = cache.put("foo", "bar") @@ -251,7 +284,7 @@ defmodule Nebulex.LocalTest do refute get_from_new(cache, name, "foo") assert get_from_old(cache, name, "foo") == "bar" - assert cache.replace("foo", "bar bar") == true + assert cache.replace!("foo", "bar bar") == true assert get_from_new(cache, name, "foo") == "bar bar" refute get_from_old(cache, name, "foo") @@ -259,7 +292,7 @@ defmodule Nebulex.LocalTest do _ = new_generation(cache, name) _ = new_generation(cache, name) - assert cache.replace("foo", "bar bar") == false + assert cache.replace!("foo", "bar bar") == false end test "put_all/2 (keys are removed from older generation)", %{cache: cache, name: name} do @@ -285,7 +318,7 @@ defmodule Nebulex.LocalTest do test "put_new_all/2 (fallback to older generation)", %{cache: cache, name: name} do entries = Enum.map(1..100, &{&1, &1}) - assert cache.put_new_all(entries) == true + assert cache.put_new_all!(entries) == true _ = new_generation(cache, name) @@ -294,7 +327,7 @@ defmodule Nebulex.LocalTest do assert get_from_old(cache, name, k) == v end) - assert cache.put_new_all(entries) == false + assert cache.put_new_all!(entries) == false Enum.each(entries, fn {k, v} -> refute get_from_new(cache, name, k) @@ -303,7 +336,7 @@ defmodule Nebulex.LocalTest do _ = new_generation(cache, name) - assert cache.put_new_all(entries) == true + assert cache.put_new_all!(entries) == true Enum.each(entries, fn {k, v} -> assert get_from_new(cache, name, k) == v @@ -319,14 +352,14 @@ defmodule Nebulex.LocalTest do refute get_from_new(cache, name, "foo") assert get_from_old(cache, name, "foo") == "bar" - assert cache.expire("foo", 200) == true + assert cache.expire!("foo", 200) == true assert get_from_new(cache, name, "foo") == "bar" refute get_from_old(cache, name, "foo") :ok = Process.sleep(210) - refute cache.get("foo") + refute cache.get!("foo") end test "incr/3 (fallback to older generation)", %{cache: cache, name: name} do @@ -337,15 +370,15 @@ defmodule Nebulex.LocalTest do refute get_from_new(cache, name, :counter) assert get_from_old(cache, name, :counter) == 0 - assert cache.incr(:counter) == 1 - assert cache.incr(:counter) == 2 + assert cache.incr!(:counter) == 1 + assert cache.incr!(:counter) == 2 assert get_from_new(cache, name, :counter) == 2 refute get_from_old(cache, name, :counter) :ok = Process.sleep(210) - assert cache.incr(:counter) == 1 + assert cache.incr!(:counter) == 1 end test "all/2 (no duplicates)", %{cache: cache, name: name} do @@ -354,15 +387,15 @@ defmodule Nebulex.LocalTest do :ok = cache.put_all(entries) - assert cache.count_all() == 20 - assert cache.all() |> Enum.sort() == keys + assert cache.count_all!() == 20 + assert cache.all!() |> Enum.sort() == keys _ = new_generation(cache, name) :ok = cache.put_all(entries) - assert cache.count_all() == 20 - assert cache.all() |> Enum.sort() == keys + assert cache.count_all!() == 20 + assert cache.all!() |> Enum.sort() == keys _ = new_generation(cache, name) @@ -371,40 +404,40 @@ defmodule Nebulex.LocalTest do :ok = cache.put_all(more_entries) - assert cache.count_all() == 30 - assert cache.all() |> Enum.sort() == (keys ++ more_keys) |> Enum.uniq() + assert cache.count_all!() == 30 + assert cache.all!() |> Enum.sort() == (keys ++ more_keys) |> Enum.uniq() _ = new_generation(cache, name) - assert cache.count_all() == 21 - assert cache.all() |> Enum.sort() == more_keys + assert cache.count_all!() == 21 + assert cache.all!() |> Enum.sort() == more_keys end end describe "generation" do test "created with unexpired entries", %{cache: cache, name: name} do assert cache.put("foo", "bar") == :ok - assert cache.get("foo") == "bar" - assert cache.ttl("foo") == :infinity + assert cache.fetch!("foo") == "bar" + assert cache.ttl("foo") == {:ok, :infinity} _ = new_generation(cache, name) - assert cache.get("foo") == "bar" + assert cache.fetch!("foo") == "bar" end test "lifecycle", %{cache: cache, name: name} do # should be empty - refute cache.get(1) + assert {:error, %Nebulex.KeyError{key: 1}} = cache.fetch(1) # set some entries for x <- 1..2, do: cache.put(x, x) # fetch one entry from new generation - assert cache.get(1) == 1 + assert cache.fetch!(1) == 1 # fetch non-existent entries - refute cache.get(3) - refute cache.get(:non_existent) + assert {:error, %Nebulex.KeyError{key: 3}} = cache.fetch(3) + assert {:error, %Nebulex.KeyError{key: :non_existent}} = cache.fetch(:non_existent) # create a new generation _ = new_generation(cache, name) @@ -416,7 +449,7 @@ defmodule Nebulex.LocalTest do assert get_from_old(cache, name, 2) == 2 # fetch entry 1 and put it into the new generation - assert cache.get(1) == 1 + assert cache.fetch!(1) == 1 assert get_from_new(cache, name, 1) == 1 refute get_from_new(cache, name, 2) refute get_from_old(cache, name, 1) @@ -434,17 +467,17 @@ defmodule Nebulex.LocalTest do test "creation with ttl", %{cache: cache, name: name} do assert cache.put(1, 1, ttl: 1000) == :ok - assert cache.get(1) == 1 + assert cache.fetch!(1) == 1 _ = new_generation(cache, name) refute get_from_new(cache, name, 1) assert get_from_old(cache, name, 1) == 1 - assert cache.get(1) == 1 + assert cache.fetch!(1) == 1 :ok = Process.sleep(1100) - refute cache.get(1) + assert {:error, %Nebulex.KeyError{key: 1}} = cache.fetch(1) refute get_from_new(cache, name, 1) refute get_from_old(cache, name, 1) end @@ -473,7 +506,7 @@ defmodule Nebulex.LocalTest do end defp get_from(gen, name, key) do - Adapter.with_meta(name, fn _, %{backend: backend} -> + Adapter.with_meta(name, fn %{backend: backend} -> case backend.lookup(gen, key) do [] -> nil [{_, ^key, val, _, _}] -> val @@ -483,15 +516,15 @@ defmodule Nebulex.LocalTest do defp all_or_stream(cache, action, ms, opts \\ []) - defp all_or_stream(cache, :all, ms, opts) do + defp all_or_stream(cache, :all!, ms, opts) do ms - |> cache.all(opts) + |> cache.all!(opts) |> handle_query_result() end - defp all_or_stream(cache, :stream, ms, opts) do + defp all_or_stream(cache, :stream!, ms, opts) do ms - |> cache.stream(opts) + |> cache.stream!(opts) |> handle_query_result() end diff --git a/test/shared/multilevel_test.exs b/test/shared/multilevel_test.exs index 8b6a6cb3..642ab3f9 100644 --- a/test/shared/multilevel_test.exs +++ b/test/shared/multilevel_test.exs @@ -6,46 +6,47 @@ defmodule Nebulex.MultilevelTest do test "fails because missing levels config", %{cache: cache} do assert {:error, {%ArgumentError{message: msg}, _}} = cache.start_link(name: :missing_levels) - assert Regex.match?( - ~r"expected levels: to be a list with at least one level definition", - msg - ) + assert Regex.match?(~r"required :levels option not found", msg) end end - describe "entry:" do - test "put/3", %{cache: cache} do + describe "put/3" do + test "ok", %{cache: cache} do assert cache.put(1, 1) == :ok - assert cache.get(1, level: 1) == 1 - assert cache.get(1, level: 2) == 1 - assert cache.get(1, level: 3) == 1 + assert cache.get!(1, nil, level: 1) == 1 + assert cache.get!(1, nil, level: 2) == 1 + assert cache.get!(1, nil, level: 3) == 1 assert cache.put(2, 2, level: 2) == :ok - assert cache.get(2, level: 2) == 2 - refute cache.get(2, level: 1) - refute cache.get(2, level: 3) + assert cache.get!(2, nil, level: 2) == 2 + refute cache.get!(2, nil, level: 1) + refute cache.get!(2, nil, level: 3) assert cache.put("foo", nil) == :ok - refute cache.get("foo") + refute cache.get!("foo") end + end - test "put_new/3", %{cache: cache} do - assert cache.put_new(1, 1) - refute cache.put_new(1, 2) - assert cache.get(1, level: 1) == 1 - assert cache.get(1, level: 2) == 1 - assert cache.get(1, level: 3) == 1 - - assert cache.put_new(2, 2, level: 2) - assert cache.get(2, level: 2) == 2 - refute cache.get(2, level: 1) - refute cache.get(2, level: 3) - - assert cache.put_new("foo", nil) - refute cache.get("foo") + describe "put_new/3" do + test "ok", %{cache: cache} do + assert cache.put_new!(1, 1) + refute cache.put_new!(1, 2) + assert cache.get!(1, nil, level: 1) == 1 + assert cache.get!(1, nil, level: 2) == 1 + assert cache.get!(1, nil, level: 3) == 1 + + assert cache.put_new!(2, 2, level: 2) + assert cache.get!(2, nil, level: 2) == 2 + refute cache.get!(2, nil, level: 1) + refute cache.get!(2, nil, level: 3) + + assert cache.put_new!("foo", nil) + refute cache.get!("foo") end + end - test "put_all/2", %{cache: cache} do + describe "put_all/2" do + test "ok", %{cache: cache} do assert cache.put_all( for x <- 1..3 do {x, x} @@ -53,179 +54,219 @@ defmodule Nebulex.MultilevelTest do ttl: 1000 ) == :ok - for x <- 1..3, do: assert(cache.get(x) == x) + for x <- 1..3, do: assert(cache.get!(x) == x) :ok = Process.sleep(1100) - for x <- 1..3, do: refute(cache.get(x)) + for x <- 1..3, do: refute(cache.get!(x)) assert cache.put_all(%{"apples" => 1, "bananas" => 3}) == :ok assert cache.put_all(blueberries: 2, strawberries: 5) == :ok - assert cache.get("apples") == 1 - assert cache.get("bananas") == 3 - assert cache.get(:blueberries) == 2 - assert cache.get(:strawberries) == 5 + assert cache.get!("apples") == 1 + assert cache.get!("bananas") == 3 + assert cache.get!(:blueberries) == 2 + assert cache.get!(:strawberries) == 5 assert cache.put_all([]) == :ok assert cache.put_all(%{}) == :ok - refute cache.put_new_all(%{"apples" => 100}) - assert cache.get("apples") == 1 + refute cache.put_new_all!(%{"apples" => 100}) + assert cache.get!("apples") == 1 end + end - test "get_all/2", %{cache: cache} do + describe "get_all/2" do + test "ok", %{cache: cache} do assert cache.put_all(a: 1, c: 3) == :ok - assert cache.get_all([:a, :b, :c]) == %{a: 1, c: 3} + assert cache.get_all!([:a, :b, :c]) == %{a: 1, c: 3} end + end - test "delete/2", %{cache: cache} do - assert cache.put(1, 1) - assert cache.put(2, 2, level: 2) + describe "delete/2" do + test "ok", %{cache: cache} do + assert cache.put(1, 1) == :ok + assert cache.put(2, 2, level: 2) == :ok assert cache.delete(1) == :ok - refute cache.get(1, level: 1) - refute cache.get(1, level: 2) - refute cache.get(1, level: 3) + refute cache.get!(1, nil, level: 1) + refute cache.get!(1, nil, level: 2) + refute cache.get!(1, nil, level: 3) assert cache.delete(2, level: 2) == :ok - refute cache.get(2, level: 1) - refute cache.get(2, level: 2) - refute cache.get(2, level: 3) + refute cache.get!(2, nil, level: 1) + refute cache.get!(2, nil, level: 2) + refute cache.get!(2, nil, level: 3) end + end - test "take/2", %{cache: cache} do + describe "take/2" do + test "ok", %{cache: cache} do assert cache.put(1, 1) == :ok assert cache.put(2, 2, level: 2) == :ok assert cache.put(3, 3, level: 3) == :ok - assert cache.take(1) == 1 - assert cache.take(2) == 2 - assert cache.take(3) == 3 + assert cache.take!(1) == 1 + assert cache.take!(2) == 2 + assert cache.take!(3) == 3 - refute cache.get(1, level: 1) - refute cache.get(1, level: 2) - refute cache.get(1, level: 3) - refute cache.get(2, level: 2) - refute cache.get(3, level: 3) + refute cache.get!(1, nil, level: 1) + refute cache.get!(1, nil, level: 2) + refute cache.get!(1, nil, level: 3) + refute cache.get!(2, nil, level: 2) + refute cache.get!(3, nil, level: 3) end + end - test "has_key?/1", %{cache: cache} do + describe "exists?/1" do + test "ok", %{cache: cache} do assert cache.put(1, 1) == :ok assert cache.put(2, 2, level: 2) == :ok assert cache.put(3, 3, level: 3) == :ok - assert cache.has_key?(1) - assert cache.has_key?(2) - assert cache.has_key?(3) - refute cache.has_key?(4) + assert cache.exists?(1) == {:ok, true} + assert cache.exists?(2) == {:ok, true} + assert cache.exists?(3) == {:ok, true} + assert cache.exists?(4) == {:ok, false} end + end - test "ttl/1", %{cache: cache} do + describe "ttl/1" do + test "ok", %{cache: cache} do assert cache.put(:a, 1, ttl: 1000) == :ok - assert cache.ttl(:a) > 0 + assert cache.ttl!(:a) > 0 assert cache.put(:b, 2) == :ok :ok = Process.sleep(10) - assert cache.ttl(:a) > 0 - assert cache.ttl(:b) == :infinity - refute cache.ttl(:c) + assert cache.ttl!(:a) > 0 + assert cache.ttl!(:b) == :infinity + + assert_raise Nebulex.KeyError, fn -> + cache.ttl!(:c) + end :ok = Process.sleep(1100) - refute cache.ttl(:a) + + assert_raise Nebulex.KeyError, fn -> + cache.ttl!(:a) + end end - test "expire/2", %{cache: cache} do + test "raises Nebulex.KeyError if key does not exist", %{cache: cache, name: name} do + msg = ~r"key :non_existent not found in cache: #{inspect(name)}" + + assert_raise Nebulex.KeyError, msg, fn -> + cache.ttl!(:non_existent) + end + end + end + + describe "expire/2" do + test "ok", %{cache: cache} do assert cache.put(:a, 1) == :ok - assert cache.ttl(:a) == :infinity + assert cache.ttl!(:a) == :infinity - assert cache.expire(:a, 1000) - ttl = cache.ttl(:a) + assert cache.expire!(:a, 1000) + ttl = cache.ttl!(:a) assert ttl > 0 and ttl <= 1000 - assert cache.get(:a, level: 1) == 1 - assert cache.get(:a, level: 2) == 1 - assert cache.get(:a, level: 3) == 1 + assert cache.get!(:a, nil, level: 1) == 1 + assert cache.get!(:a, nil, level: 2) == 1 + assert cache.get!(:a, nil, level: 3) == 1 :ok = Process.sleep(1100) - refute cache.get(:a) - refute cache.get(:a, level: 1) - refute cache.get(:a, level: 2) - refute cache.get(:a, level: 3) + refute cache.get!(:a) + refute cache.get!(:a, nil, level: 1) + refute cache.get!(:a, nil, level: 2) + refute cache.get!(:a, nil, level: 3) end - test "touch/1", %{cache: cache} do + test "raises when ttl is invalid", %{cache: cache} do + assert_raise ArgumentError, ~r"expected ttl to be a valid timeout", fn -> + cache.expire!(:a, "hello") + end + end + end + + describe "touch/1" do + test "ok", %{cache: cache} do assert cache.put(:touch, 1, ttl: 1000, level: 2) == :ok :ok = Process.sleep(10) - assert cache.touch(:touch) + assert cache.touch!(:touch) :ok = Process.sleep(200) - assert cache.touch(:touch) - assert cache.get(:touch) == 1 + assert cache.touch!(:touch) + assert cache.get!(:touch) == 1 :ok = Process.sleep(1100) - refute cache.get(:touch) + refute cache.get!(:touch) - refute cache.touch(:non_existent) + refute cache.touch!(:non_existent) end + end - test "get_and_update/3", %{cache: cache} do + describe "get_and_update/3" do + test "ok", %{cache: cache} do assert cache.put(1, 1, level: 1) == :ok assert cache.put(2, 2) == :ok - assert cache.get_and_update(1, &{&1, &1 * 2}, level: 1) == {1, 2} - assert cache.get(1, level: 1) == 2 - refute cache.get(1, level: 3) - refute cache.get(1, level: 3) + assert cache.get_and_update!(1, &{&1, &1 * 2}, level: 1) == {1, 2} + assert cache.get!(1, nil, level: 1) == 2 + refute cache.get!(1, nil, level: 3) + refute cache.get!(1, nil, level: 3) - assert cache.get_and_update(2, &{&1, &1 * 2}) == {2, 4} - assert cache.get(2, level: 1) == 4 - assert cache.get(2, level: 2) == 4 - assert cache.get(2, level: 3) == 4 + assert cache.get_and_update!(2, &{&1, &1 * 2}) == {2, 4} + assert cache.get!(2, nil, level: 1) == 4 + assert cache.get!(2, nil, level: 2) == 4 + assert cache.get!(2, nil, level: 3) == 4 - assert cache.get_and_update(1, fn _ -> :pop end, level: 1) == {2, nil} - refute cache.get(1, level: 1) + assert cache.get_and_update!(1, fn _ -> :pop end, level: 1) == {2, nil} + refute cache.get!(1, nil, level: 1) - assert cache.get_and_update(2, fn _ -> :pop end) == {4, nil} - refute cache.get(2, level: 1) - refute cache.get(2, level: 2) - refute cache.get(2, level: 3) + assert cache.get_and_update!(2, fn _ -> :pop end) == {4, nil} + refute cache.get!(2, nil, level: 1) + refute cache.get!(2, nil, level: 2) + refute cache.get!(2, nil, level: 3) end + end - test "update/4", %{cache: cache} do + describe "update/4" do + test "ok", %{cache: cache} do assert cache.put(1, 1, level: 1) == :ok assert cache.put(2, 2) == :ok - assert cache.update(1, 1, &(&1 * 2), level: 1) == 2 - assert cache.get(1, level: 1) == 2 - refute cache.get(1, level: 2) - refute cache.get(1, level: 3) + assert cache.update!(1, 1, &(&1 * 2), level: 1) == 2 + assert cache.get!(1, nil, level: 1) == 2 + refute cache.get!(1, nil, level: 2) + refute cache.get!(1, nil, level: 3) - assert cache.update(2, 1, &(&1 * 2)) == 4 - assert cache.get(2, level: 1) == 4 - assert cache.get(2, level: 2) == 4 - assert cache.get(2, level: 3) == 4 + assert cache.update!(2, 1, &(&1 * 2)) == 4 + assert cache.get!(2, nil, level: 1) == 4 + assert cache.get!(2, nil, level: 2) == 4 + assert cache.get!(2, nil, level: 3) == 4 end + end - test "incr/3", %{cache: cache} do - assert cache.incr(1) == 1 - assert cache.get(1, level: 1) == 1 - assert cache.get(1, level: 2) == 1 - assert cache.get(1, level: 3) == 1 - - assert cache.incr(2, 2, level: 2) == 2 - assert cache.get(2, level: 2) == 2 - refute cache.get(2, level: 1) - refute cache.get(2, level: 3) - - assert cache.incr(3, 3) == 3 - assert cache.get(3, level: 1) == 3 - assert cache.get(3, level: 2) == 3 - assert cache.get(3, level: 3) == 3 - - assert cache.incr(4, 5) == 5 - assert cache.incr(4, -5) == 0 - assert cache.get(4, level: 1) == 0 - assert cache.get(4, level: 2) == 0 - assert cache.get(4, level: 3) == 0 + describe "incr/3" do + test "ok", %{cache: cache} do + assert cache.incr!(1) == 1 + assert cache.get!(1, nil, level: 1) == 1 + assert cache.get!(1, nil, level: 2) == 1 + assert cache.get!(1, nil, level: 3) == 1 + + assert cache.incr!(2, 2, level: 2) == 2 + assert cache.get!(2, nil, level: 2) == 2 + refute cache.get!(2, nil, level: 1) + refute cache.get!(2, nil, level: 3) + + assert cache.incr!(3, 3) == 3 + assert cache.get!(3, nil, level: 1) == 3 + assert cache.get!(3, nil, level: 2) == 3 + assert cache.get!(3, nil, level: 3) == 3 + + assert cache.incr!(4, 5) == 5 + assert cache.incr!(4, -5) == 0 + assert cache.get!(4, nil, level: 1) == 0 + assert cache.get!(4, nil, level: 2) == 0 + assert cache.get!(4, nil, level: 3) == 0 end end @@ -236,9 +277,9 @@ defmodule Nebulex.MultilevelTest do for x <- 50..100, do: cache.put(x, x, level: 3) expected = :lists.usort(for x <- 1..100, do: x) - assert :lists.usort(cache.all()) == expected + assert cache.all!() |> :lists.usort() == expected - stream = cache.stream() + stream = cache.stream!() assert stream |> Enum.to_list() @@ -251,7 +292,7 @@ defmodule Nebulex.MultilevelTest do end expected = :lists.usort(expected -- del) - assert :lists.usort(cache.all()) == expected + assert cache.all!() |> :lists.usort() == expected end test "delete_all/2", %{cache: cache} do @@ -259,25 +300,28 @@ defmodule Nebulex.MultilevelTest do for x <- 21..60, do: cache.put(x, x, level: 2) for x <- 51..100, do: cache.put(x, x, level: 3) - assert count = cache.count_all() - assert cache.delete_all() == count - assert cache.all() == [] + assert count = cache.count_all!() + assert cache.delete_all!() == count + assert cache.all!() == [] end test "count_all/2", %{cache: cache} do - assert cache.count_all() == 0 + assert cache.count_all!() == 0 + for x <- 1..10, do: cache.put(x, x, level: 1) for x <- 11..20, do: cache.put(x, x, level: 2) for x <- 21..30, do: cache.put(x, x, level: 3) - assert cache.count_all() == 30 + + assert cache.count_all!() == 30 for x <- [1, 11, 21], do: cache.delete(x, level: 1) - assert cache.count_all() == 29 + + assert cache.count_all!() == 29 assert cache.delete(1, level: 1) == :ok assert cache.delete(11, level: 2) == :ok assert cache.delete(21, level: 3) == :ok - assert cache.count_all() == 27 + assert cache.count_all!() == 27 end end end diff --git a/test/support/cache_case.ex b/test/support/cache_case.ex index 3455069d..59e7796e 100644 --- a/test/support/cache_case.ex +++ b/test/support/cache_case.ex @@ -1,6 +1,8 @@ defmodule Nebulex.CacheCase do @moduledoc false + use ExUnit.CaseTemplate + alias Nebulex.Telemetry @doc false @@ -44,6 +46,7 @@ defmodule Nebulex.CacheCase do on_exit(fn -> try do :ok = Process.sleep(20) + if Process.alive?(pid), do: Supervisor.stop(pid, :normal, 5000) catch :exit, _ -> :noop @@ -65,11 +68,13 @@ defmodule Nebulex.CacheCase do default_dynamic_cache = cache.get_dynamic_cache() {:ok, pid} = cache.start_link([name: name] ++ opts) + _ = cache.put_dynamic_cache(name) on_exit(fn -> try do :ok = Process.sleep(20) + if Process.alive?(pid), do: Supervisor.stop(pid, :normal, 5000) catch :exit, _ -> :noop @@ -86,13 +91,16 @@ defmodule Nebulex.CacheCase do @doc false def test_with_dynamic_cache(cache, opts \\ [], callback) do default_dynamic_cache = cache.get_dynamic_cache() + {:ok, pid} = cache.start_link(opts) try do _ = cache.put_dynamic_cache(pid) + callback.() after _ = cache.put_dynamic_cache(default_dynamic_cache) + Supervisor.stop(pid) end end @@ -107,6 +115,7 @@ defmodule Nebulex.CacheCase do rescue _ -> :ok = Process.sleep(delay) + wait_until(retries - 1, delay, fun) end @@ -114,7 +123,9 @@ defmodule Nebulex.CacheCase do def cache_put(cache, lst, fun \\ & &1, opts \\ []) do for key <- lst do value = fun.(key) + :ok = cache.put(key, value, opts) + value end end @@ -138,4 +149,18 @@ defmodule Nebulex.CacheCase do def handle_event(event, measurements, metadata, %{pid: pid}) do send(pid, {event, measurements, metadata}) end + + @doc false + def assert_error_module(ctx, error_module) do + fun = Map.get(ctx, :error_module, fn m -> assert m == Nebulex.Error end) + + fun.(error_module) + end + + @doc false + def assert_error_reason(ctx, error_reason) do + fun = Map.get(ctx, :error_reason, fn r -> assert r == :error end) + + fun.(error_reason) + end end diff --git a/test/support/cluster.ex b/test/support/cluster.ex index a8125f96..b9fe6dfb 100644 --- a/test/support/cluster.ex +++ b/test/support/cluster.ex @@ -54,6 +54,7 @@ defmodule Nebulex.Cluster do defp allow_boot(host) do {:ok, ipv4} = :inet.parse_ipv4_address(host) + :erl_boot_server.add_slave(ipv4) end diff --git a/test/support/fake_adapter.ex b/test/support/fake_adapter.ex new file mode 100644 index 00000000..0dd19718 --- /dev/null +++ b/test/support/fake_adapter.ex @@ -0,0 +1,80 @@ +defmodule Nebulex.FakeAdapter do + @moduledoc false + + ## Nebulex.Adapter + + @doc false + defmacro __before_compile__(_), do: :ok + + @doc false + def init(_opts) do + child_spec = Supervisor.child_spec({Agent, fn -> :ok end}, id: {Agent, 1}) + + {:ok, child_spec, %{}} + end + + ## Nebulex.Adapter.Entry + + @doc false + def fetch(_, _, _), do: {:error, %Nebulex.Error{reason: :error}} + + @doc false + def put(_, :error, reason, _, _, _), do: {:error, reason} + def put(_, _, _, _, _, _), do: {:error, %Nebulex.Error{reason: :error}} + + @doc false + def delete(_, _, _), do: {:error, %Nebulex.Error{reason: :error}} + + @doc false + def take(_, _, _), do: {:error, %Nebulex.Error{reason: :error}} + + @doc false + def exists?(_, _, _), do: {:error, %Nebulex.Error{reason: :error}} + + @doc false + def ttl(_, _, _), do: {:error, %Nebulex.Error{reason: :error}} + + @doc false + def expire(_, _, _, _), do: {:error, %Nebulex.Error{reason: :error}} + + @doc false + def touch(_, _, _), do: {:error, %Nebulex.Error{reason: :error}} + + @doc false + def update_counter(_, _, _, _, _, _), do: {:error, %Nebulex.Error{reason: :error}} + + @doc false + def get_all(_, _, _), do: {:error, %Nebulex.Error{reason: :error}} + + @doc false + def put_all(_, _, _, _, _), do: {:error, %Nebulex.Error{reason: :error}} + + ## Nebulex.Adapter.Queryable + + @doc false + def execute(_, _, _, _), do: {:error, %Nebulex.Error{reason: :error}} + + @doc false + def stream(_, _, _), do: {:error, %Nebulex.Error{reason: :error}} + + ## Nebulex.Adapter.Persistence + + @doc false + def dump(_, _, _), do: {:error, %Nebulex.Error{reason: :error}} + + @doc false + def load(_, _, _), do: {:error, %Nebulex.Error{reason: :error}} + + ## Nebulex.Adapter.Stats + + @doc false + def stats(_), do: {:error, %Nebulex.Error{reason: :error}} + + ## Nebulex.Adapter.Transaction + + @doc false + def transaction(_, _, _), do: {:error, %Nebulex.Error{reason: :error}} + + @doc false + def in_transaction?(_), do: {:error, %Nebulex.Error{reason: :error}} +end diff --git a/test/support/node_case.ex b/test/support/node_case.ex index 179cc4a5..7a1644dd 100644 --- a/test/support/node_case.ex +++ b/test/support/node_case.ex @@ -10,6 +10,7 @@ defmodule Nebulex.NodeCase do quote do use ExUnit.Case, async: true import unquote(__MODULE__) + @moduletag :clustered @timeout unquote(@timeout) @@ -19,6 +20,7 @@ defmodule Nebulex.NodeCase do def start_caches(nodes, caches) do for node <- nodes, {cache, opts} <- caches do {:ok, pid} = start_cache(node, cache, opts) + {node, cache, pid} end end diff --git a/test/support/test_cache.ex b/test/support/test_cache.ex index d0761c80..f3afdbca 100644 --- a/test/support/test_cache.ex +++ b/test/support/test_cache.ex @@ -14,50 +14,6 @@ defmodule Nebulex.TestCache do end end - defmodule TestHook do - @moduledoc false - use GenServer - - alias Nebulex.Hook - - @actions [:get, :put] - - def start_link(opts \\ []) do - GenServer.start_link(__MODULE__, opts, name: __MODULE__) - end - - ## Hook Function - - def track(%Hook{step: :before, name: name}) when name in @actions do - System.system_time(:microsecond) - end - - def track(%Hook{step: :after_return, name: name} = event) when name in @actions do - GenServer.cast(__MODULE__, {:track, event}) - end - - def track(hook), do: hook - - ## Error Hook Function - - def hook_error(%Hook{name: :get}), do: raise(ArgumentError, "error") - - def hook_error(hook), do: hook - - ## GenServer - - @impl GenServer - def init(_opts) do - {:ok, %{}} - end - - @impl GenServer - def handle_cast({:track, %Hook{acc: start} = hook}, state) do - _ = send(:hooked_cache, %{hook | acc: System.system_time(:microsecond) - start}) - {:noreply, state} - end - end - defmodule Cache do @moduledoc false use Nebulex.Cache, @@ -98,6 +54,34 @@ defmodule Nebulex.TestCache do adapter: Nebulex.Adapters.Local end + defmodule L2 do + @moduledoc false + use Nebulex.Cache, + otp_app: :nebulex, + adapter: Nebulex.Adapters.Partitioned + end + + defmodule L3 do + @moduledoc false + use Nebulex.Cache, + otp_app: :nebulex, + adapter: Nebulex.Adapters.Replicated + end + end + + defmodule StatsCache do + @moduledoc false + use Nebulex.Cache, + otp_app: :nebulex, + adapter: Nebulex.Adapters.Multilevel + + defmodule L1 do + @moduledoc false + use Nebulex.Cache, + otp_app: :nebulex, + adapter: Nebulex.Adapters.Local + end + defmodule L2 do @moduledoc false use Nebulex.Cache, @@ -111,6 +95,13 @@ defmodule Nebulex.TestCache do otp_app: :nebulex, adapter: Nebulex.Adapters.Partitioned end + + defmodule L4 do + @moduledoc false + use Nebulex.Cache, + otp_app: :nebulex, + adapter: Nebulex.Adapters.Local + end end ## Mocks @@ -139,63 +130,69 @@ defmodule Nebulex.TestCache do end @impl true - def get(_, key, _) do + def fetch(_, key, _) do if is_integer(key) do raise ArgumentError, "Error" else - :ok + {:ok, :ok} end end @impl true def put(_, _, _, _, _, _) do :ok = Process.sleep(1000) - true + + {:ok, true} end @impl true def delete(_, _, _), do: :ok @impl true - def take(_, _, _), do: nil + def take(_, _, _), do: {:ok, nil} @impl true - def has_key?(_, _), do: true + def exists?(_, _, _), do: {:ok, true} @impl true - def ttl(_, _), do: nil + def ttl(_, _, _), do: {:ok, nil} @impl true - def expire(_, _, _), do: true + def expire(_, _, _, _), do: {:ok, true} @impl true - def touch(_, _), do: true + def touch(_, _, _), do: {:ok, true} @impl true - def update_counter(_, _, _, _, _, _), do: 1 + def update_counter(_, _, _, _, _, _), do: {:ok, 1} @impl true def get_all(_, _, _) do :ok = Process.sleep(1000) - %{} + + {:ok, %{}} end @impl true - def put_all(_, _, _, _, _), do: Process.exit(self(), :normal) + def put_all(_, _, _, _, _) do + {:ok, Process.exit(self(), :normal)} + end @impl true def execute(_, :count_all, _, _) do _ = Process.exit(self(), :normal) - 0 + + {:ok, 0} end def execute(_, :delete_all, _, _) do - Process.sleep(2000) - 0 + :ok = Process.sleep(2000) + + {:ok, 0} end @impl true - def stream(_, _, _), do: 1..10 + def stream(_, _, _), do: {:ok, 1..10} end defmodule PartitionedMock do diff --git a/test/test_helper.exs b/test/test_helper.exs index 0b1736e8..46ce2590 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,3 +1,14 @@ +# Mocks +[ + Nebulex.TestCache.Multilevel.L1, + Nebulex.TestCache.StatsCache.L1, + Nebulex.Cache.Registry, + Nebulex.Cache.Cluster, + Nebulex.RPC, + Mix.Project +] +|> Enum.each(&Mimic.copy/1) + # Start Telemetry _ = Application.start(:telemetry)