├── .formatter.exs ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── config ├── config.exs ├── dev.exs ├── prod.exs └── test.exs ├── lib ├── simpler_cache.ex └── simpler_cache │ ├── application.ex │ └── table_worker.ex ├── mix.exs ├── mix.lock └── test ├── cache_model_test.exs ├── cache_statem_test.exs ├── simple_cache_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | simpler_cache-*.tar 24 | 25 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | elixir: 3 | - 1.7.3 4 | - 1.8.2 5 | - 1.9.4 6 | otp_release: 7 | - 20.3 8 | - 21.1 9 | - 22.1 10 | dist: trusty 11 | sudo: false 12 | services: true 13 | 14 | cache: 15 | directories: 16 | - _build 17 | - deps 18 | 19 | before_install: true 20 | 21 | before_script: 22 | - mix local.hex --force 23 | - mix deps.get --only test 24 | - mix compile --warnings-as-errors 25 | - travis_wait mix dialyzer --plt 26 | 27 | script: 28 | - MIX_ENV=test mix test 29 | - mix dialyzer --halt-exit-status -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2018 Ivy Rogatko 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SimplerCache 2 | 3 | [![Build Status](https://travis-ci.com/IRog/simpler_cache.svg?branch=master)](https://travis-ci.com/IRog/simpler_cache) 4 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 5 | [![Hex pm](http://img.shields.io/hexpm/v/simpler_cache.svg?style=flat)](https://hex.pm/packages/simpler_cache) 6 | [![hexdocs.pm](https://img.shields.io/badge/docs-latest-green.svg?style=flat)](https://hexdocs.pm/simpler_cache/) 7 | 8 | ## Description 9 | 10 | A very simple cache. It uses timers for the ttl and ets for the storage. No locks are used and there is a fix to prevent thundering herd issues when the cache is warm and pre-emptively refresh the cache (it triggers in low ttl left of item situations). There is also a thundering herd fix for when the cache is cold using a sentinel key and a sleep. 11 | Mostly wrapper around ets and kept very simple by using newer apis and recent erlang improvements. 12 | 13 | Using property model testing and property tests to verify the cache via propcheck. 14 | 15 | ## Installation 16 | 17 | [available in Hex](https://hex.pm/packages/simpler_cache), the package can be installed 18 | by adding `simpler_cache` to your list of dependencies in `mix.exs`: 19 | 20 | ```elixir 21 | def deps do 22 | [ 23 | {:simpler_cache, "~> 0.1.8"} 24 | ] 25 | end 26 | ``` 27 | 28 | - Sample configs 29 | ``` 30 | config :simpler_cache, 31 | cache_name: :simpler_cache_test, 32 | global_ttl_ms: 100_000 33 | ``` 34 | 35 | ### 100 processes concurrently hitting cache on 1 key with get_or_store 36 | ![iterations](https://github.com/IRog/simpler_cache/blob/benchmark/images/100_iterations.png) 37 | 38 | ![runtime](https://github.com/IRog/simpler_cache/blob/benchmark/images/100_runtime.png) 39 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | import_config "#{Mix.env()}.exs" 4 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | -------------------------------------------------------------------------------- /config/prod.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :simpler_cache, 4 | cache_name: :simpler_cache_test, 5 | global_ttl_ms: 100_000_000_000 6 | -------------------------------------------------------------------------------- /lib/simpler_cache.ex: -------------------------------------------------------------------------------- 1 | defmodule SimplerCache do 2 | @moduledoc """ 3 | Simple cache implementation with no complicated features or locks. 4 | """ 5 | @table_name Application.get_env(:simpler_cache, :cache_name, :simpler_cache) 6 | @global_ttl_ms Application.get_env(:simpler_cache, :global_ttl_ms, 10_000) 7 | 8 | @type update_function :: (any -> any) 9 | @type fallback_function :: (() -> any) 10 | 11 | @compile {:inline, 12 | get: 1, put: 2, insert_new: 2, delete: 1, size: 0, set_ttl_ms: 2, expiry_buffer_ms: 1} 13 | 14 | @doc "Returns an item from cache or nil if not found" 15 | @spec get(any) :: nil | any 16 | def get(key) do 17 | maybe_tuple = 18 | :ets.lookup(@table_name, key) 19 | |> List.first() 20 | 21 | # the schema for items is {key, value, timer_reference, expiry_ms, ttl_ms} 22 | case maybe_tuple do 23 | item when is_tuple(item) -> 24 | elem(item, 1) 25 | 26 | _ -> 27 | nil 28 | end 29 | end 30 | 31 | @doc "Inserts new item or overwrites old item's value" 32 | @spec put(any, any, pos_integer) :: {:ok, :inserted} | {:error, any} 33 | def put(key, value, ttl_ms \\ @global_ttl_ms) when is_integer(ttl_ms) and ttl_ms > 0 do 34 | with {:ok, t_ref} <- :timer.apply_after(ttl_ms, :ets, :delete, [@table_name, key]), 35 | expiry = :erlang.monotonic_time(:millisecond) + ttl_ms - expiry_buffer_ms(ttl_ms), 36 | true <- :ets.insert(@table_name, {key, value, t_ref, expiry, ttl_ms}) do 37 | {:ok, :inserted} 38 | else 39 | {:error, err} -> {:error, err} 40 | end 41 | end 42 | 43 | @doc "Inserts new item into cache" 44 | @spec insert_new(any, any, pos_integer) :: 45 | {:ok, :inserted} | {:error, :item_is_in_cache} | {:error, any} 46 | def insert_new(key, value, ttl_ms \\ @global_ttl_ms) when is_integer(ttl_ms) and ttl_ms > 0 do 47 | case :timer.apply_after(ttl_ms, :ets, :delete, [@table_name, key]) do 48 | {:ok, t_ref} -> 49 | expiry = :erlang.monotonic_time(:millisecond) + ttl_ms - expiry_buffer_ms(ttl_ms) 50 | 51 | case :ets.insert_new(@table_name, {key, value, t_ref, expiry, ttl_ms}) do 52 | true -> 53 | {:ok, :inserted} 54 | 55 | false -> 56 | :timer.cancel(t_ref) 57 | {:error, :item_is_in_cache} 58 | end 59 | 60 | {:error, err} -> 61 | {:error, err} 62 | end 63 | end 64 | 65 | @doc "Deletes item from cache or does no-op" 66 | @spec delete(any) :: {:ok, :deleted} | {:ok, :not_found} 67 | def delete(key) do 68 | case :ets.take(@table_name, key) do 69 | [] -> 70 | {:ok, :not_found} 71 | 72 | [{_k, _v, t_ref, _expiry, _ttl_ms} | _] -> 73 | :timer.cancel(t_ref) 74 | {:ok, :deleted} 75 | end 76 | end 77 | 78 | @doc """ 79 | Updates existing value in cache based on old value and resets the timer 80 | Warning the below may retry a bit on heavy contention 81 | """ 82 | @spec update_existing(any, update_function) :: {:ok, :updated} | {:error, :failed_to_find_entry} 83 | def update_existing(key, passed_fn) when is_function(passed_fn, 1) do 84 | with [{key, old_val, t_ref, _expiry, _ttl_ms} | _] <- :ets.take(@table_name, key), 85 | :timer.cancel(t_ref), 86 | {:ok, :inserted} <- SimplerCache.insert_new(key, passed_fn.(old_val)) do 87 | {:ok, :updated} 88 | else 89 | [] -> {:error, :failed_to_find_entry} 90 | _ -> update_existing(key, passed_fn) 91 | end 92 | end 93 | 94 | @doc """ 95 | Gets or stores an item based on a passed in function 96 | if the item is near expiry it will also update the cache and ttl to avoid thundering herd issues 97 | """ 98 | @warming_key "__SIMPLER_CACHE_WARMING_SENTINEL_KEY__" 99 | @spec get_or_store(any, fallback_function, pos_integer) :: any 100 | def get_or_store(key, fallback_fn, ttl_ms \\ @global_ttl_ms) 101 | when is_integer(ttl_ms) and ttl_ms > 0 and is_function(fallback_fn, 0) do 102 | with [] <- :ets.lookup(@table_name, key), 103 | {:ok, :inserted} <- SimplerCache.insert_new(@warming_key, "", round(ttl_ms / 2)), 104 | new_val = fallback_fn.(), 105 | {:ok, _any} <- SimplerCache.delete(@warming_key), 106 | {:ok, :inserted} <- SimplerCache.insert_new(key, new_val, ttl_ms) do 107 | new_val 108 | else 109 | [{@warming_key, _val, _t_ref, _expiry, _found_ttl_ms} | _] -> 110 | sleep_time = round(ttl_ms / 10) 111 | Process.sleep(sleep_time) 112 | get_or_store(key, fallback_fn, ttl_ms) 113 | 114 | [{key, val, t_ref, expiry, found_ttl_ms} | _] -> 115 | expires_in = expiry - :erlang.monotonic_time(:millisecond) 116 | 117 | if expires_in <= 0 do 118 | case SimplerCache.set_ttl_ms(key, 2 * expiry_buffer_ms(found_ttl_ms)) do 119 | {:ok, :updated} -> 120 | new_val = fallback_fn.() 121 | {:ok, :inserted} = SimplerCache.put(key, new_val, ttl_ms) 122 | :timer.cancel(t_ref) 123 | new_val 124 | 125 | {:error, _} -> 126 | val 127 | end 128 | else 129 | val 130 | end 131 | 132 | {:error, _reason} -> 133 | get_or_store(key, fallback_fn, ttl_ms) 134 | end 135 | end 136 | 137 | @doc "Returns the number of elements in the cache" 138 | @spec size() :: non_neg_integer 139 | def size() do 140 | :ets.info(@table_name, :size) 141 | end 142 | 143 | @doc "Sets the ttl to a specific value in ms greater than 0 for an item" 144 | @spec set_ttl_ms(any, pos_integer) :: 145 | {:ok, :updated} 146 | | {:error, :failed_to_update_element} 147 | | {:error, :element_not_found} 148 | | {:error, any} 149 | def set_ttl_ms(key, time_ms) when is_integer(time_ms) and time_ms > 0 do 150 | try do 151 | t_ref = :ets.lookup_element(@table_name, key, 3) 152 | :timer.cancel(t_ref) 153 | 154 | case :timer.apply_after(time_ms, :ets, :delete, [@table_name, key]) do 155 | {:ok, new_t_ref} -> 156 | with expiry = 157 | :erlang.monotonic_time(:millisecond) + time_ms - expiry_buffer_ms(time_ms), 158 | true <- :ets.update_element(@table_name, key, {4, expiry}), 159 | true <- :ets.update_element(@table_name, key, {3, new_t_ref}), 160 | true <- :ets.update_element(@table_name, key, {5, time_ms}) do 161 | {:ok, :updated} 162 | else 163 | false -> 164 | :timer.cancel(new_t_ref) 165 | {:error, :failed_to_update_element} 166 | end 167 | 168 | {:error, reason} -> 169 | {:error, reason} 170 | end 171 | rescue 172 | ArgumentError -> 173 | {:error, :element_not_found} 174 | end 175 | end 176 | 177 | defp expiry_buffer_ms(ttl), do: round(ttl / 5) 178 | end 179 | -------------------------------------------------------------------------------- /lib/simpler_cache/application.ex: -------------------------------------------------------------------------------- 1 | defmodule SimplerCache.Application do 2 | @moduledoc false 3 | 4 | use Application 5 | 6 | def start(_type, _args) do 7 | children = [ 8 | {SimplerCache.TableWorker, []} 9 | ] 10 | 11 | opts = [strategy: :one_for_one, name: SimplerCache.Supervisor] 12 | Supervisor.start_link(children, opts) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/simpler_cache/table_worker.ex: -------------------------------------------------------------------------------- 1 | defmodule SimplerCache.TableWorker do 2 | use GenServer 3 | @table_name Application.get_env(:simpler_cache, :cache_name, :simpler_cache) 4 | 5 | def start_link(_arg) do 6 | GenServer.start_link(__MODULE__, %{}) 7 | end 8 | 9 | @impl true 10 | def init(_state) do 11 | table = 12 | :ets.new(@table_name, [ 13 | :set, 14 | :public, 15 | :named_table, 16 | read_concurrency: true, 17 | write_concurrency: true 18 | ]) 19 | 20 | {:ok, table} 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule SimplerCache.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :simpler_cache, 7 | version: "0.1.8", 8 | elixir: "~> 1.7", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps(), 11 | description: description(), 12 | package: package(), 13 | test_coverage: [tool: ExCoveralls], 14 | dialyzer: [ 15 | flags: [:error_handling, :underspecs] 16 | ], 17 | # Docs 18 | name: "Simpler Cache", 19 | source_url: "https://github.com/IRog/simpler_cache", 20 | homepage_url: "https://github.com/IRog/simpler_cache", 21 | docs: [ 22 | # The main page in the docs 23 | main: "SimplerCache", 24 | # logo: "path/to/logo.png", 25 | extras: ["README.md"] 26 | ] 27 | ] 28 | end 29 | 30 | # Run "mix help compile.app" to learn about applications. 31 | def application do 32 | [ 33 | extra_applications: [:logger], 34 | mod: {SimplerCache.Application, []} 35 | ] 36 | end 37 | 38 | # Run "mix help deps" to learn about dependencies. 39 | defp deps do 40 | [ 41 | {:propcheck, "~> 1.1.4", only: :test}, 42 | {:excoveralls, "~> 0.10", only: :test}, 43 | {:dialyxir, "~> 1.0.0-rc.3", only: :dev, runtime: false}, 44 | {:ex_doc, "~> 0.19", only: :dev, runtime: false} 45 | ] 46 | end 47 | 48 | defp description() do 49 | "A simple cache with ttl based on ets and timers. Tested with property model testing. Thundering herd fix, as well" 50 | end 51 | 52 | defp package() do 53 | [ 54 | name: "simpler_cache", 55 | files: ~w(lib doc .formatter.exs mix.exs README* LICENSE*), 56 | licenses: ["MIT"], 57 | links: %{"GitHub" => "https://github.com/IRog/simpler_cache"} 58 | ] 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "certifi": {:hex, :certifi, "2.4.2", "75424ff0f3baaccfd34b1214184b6ef616d89e420b258bb0a5ea7d7bc628f7f0", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, optional: false]}]}, 3 | "dialyxir": {:hex, :dialyxir, "1.0.0-rc.3", "774306f84973fc3f1e2e8743eeaa5f5d29b117f3916e5de74c075c02f1b8ef55", [:mix], []}, 4 | "earmark": {:hex, :earmark, "1.2.6", "b6da42b3831458d3ecc57314dff3051b080b9b2be88c2e5aa41cd642a5b044ed", [:mix], []}, 5 | "ex_doc": {:hex, :ex_doc, "0.19.1", "519bb9c19526ca51d326c060cb1778d4a9056b190086a8c6c115828eaccea6cf", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, optional: false]}, {:makeup_elixir, "~> 0.7", [hex: :makeup_elixir, optional: false]}]}, 6 | "excoveralls": {:hex, :excoveralls, "0.10.0", "a4508bdd408829f38e7b2519f234b7fd5c83846099cda348efcb5291b081200c", [:mix], [{:hackney, "~> 1.13", [hex: :hackney, optional: false]}, {:jason, "~> 1.0", [hex: :jason, optional: false]}]}, 7 | "hackney": {:hex, :hackney, "1.14.0", "66e29e78feba52176c3a4213d42b29bdc4baff93a18cfe480f73b04677139dee", [:rebar3], [{:certifi, "2.4.2", [hex: :certifi, optional: false]}, {:idna, "6.0.0", [hex: :idna, optional: false]}, {:metrics, "1.0.1", [hex: :metrics, optional: false]}, {:mimerl, "1.0.2", [hex: :mimerl, optional: false]}, {:ssl_verify_fun, "1.1.4", [hex: :ssl_verify_fun, optional: false]}]}, 8 | "idna": {:hex, :idna, "6.0.0", "689c46cbcdf3524c44d5f3dde8001f364cd7608a99556d8fbd8239a5798d4c10", [:rebar3], [{:unicode_util_compat, "0.4.1", [hex: :unicode_util_compat, optional: false]}]}, 9 | "jason": {:hex, :jason, "1.1.1", "d3ccb840dfb06f2f90a6d335b536dd074db748b3e7f5b11ab61d239506585eb2", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, optional: true]}]}, 10 | "makeup": {:hex, :makeup, "0.5.5", "9e08dfc45280c5684d771ad58159f718a7b5788596099bdfb0284597d368a882", [:mix], [{:nimble_parsec, "~> 0.4", [hex: :nimble_parsec, optional: false]}]}, 11 | "makeup_elixir": {:hex, :makeup_elixir, "0.10.0", "0f09c2ddf352887a956d84f8f7e702111122ca32fbbc84c2f0569b8b65cbf7fa", [:mix], [{:makeup, "~> 0.5.5", [hex: :makeup, optional: false]}]}, 12 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], []}, 13 | "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], []}, 14 | "nimble_parsec": {:hex, :nimble_parsec, "0.4.0", "ee261bb53214943679422be70f1658fff573c5d0b0a1ecd0f18738944f818efe", [:mix], []}, 15 | "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], []}, 16 | "propcheck": {:hex, :propcheck, "1.1.4", "95852a3f050cc3ee1ef5c9ade14a3bd34e29b54cb8db7ae8bc7f716d904d5120", [:mix], [{:proper, "~> 1.3", [hex: :proper, optional: false]}]}, 17 | "proper": {:hex, :proper, "1.3.0", "c1acd51c51da17a2fe91d7a6fc6a0c25a6a9849d8dc77093533109d1218d8457", [:make, :mix, :rebar3], []}, 18 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.4", "f0eafff810d2041e93f915ef59899c923f4568f4585904d010387ed74988e77b", [:make, :mix, :rebar3], []}, 19 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.4.1", "d869e4c68901dd9531385bb0c8c40444ebf624e60b6962d95952775cac5e90cd", [:rebar3], []}, 20 | } 21 | -------------------------------------------------------------------------------- /test/cache_model_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PropCheck.Test.CacheModel do 2 | @moduledoc """ 3 | This is a model test with the proper dsl for stateful property testing. 4 | """ 5 | use ExUnit.Case 6 | use PropCheck 7 | use PropCheck.StateM.DSL 8 | 9 | @table_name Application.get_env(:simpler_cache, :cache_name, :simpler_cache) 10 | 11 | ######################################################################### 12 | ### The properties 13 | ######################################################################### 14 | 15 | @tag timeout: 240_000 16 | property "run the cache commands", [:verbose, numtests: 100, max_size: 60] do 17 | forall cmds <- commands(__MODULE__) do 18 | trap_exit do 19 | :ets.delete_all_objects(@table_name) 20 | execution = run_commands(cmds) 21 | :ets.delete_all_objects(@table_name) 22 | 23 | (execution.result == :ok) 24 | |> when_fail( 25 | IO.puts(""" 26 | History: #{inspect(execution.history, pretty: true)} 27 | State: #{inspect(execution.state, pretty: true)} 28 | Env: #{inspect(execution.env, pretty: true)} 29 | Result: #{inspect(execution.result, pretty: true)} 30 | """) 31 | ) 32 | |> aggregate(command_names(cmds)) 33 | |> measure("length of commands", length(cmds)) 34 | end 35 | end 36 | end 37 | 38 | ######################################################################### 39 | ### Generators 40 | ######################################################################### 41 | 42 | defp key(), do: term() 43 | 44 | defp val(), do: term() 45 | 46 | defp update_function(), do: function(1, term()) 47 | 48 | defp fallback_function(), do: function(0, term()) 49 | 50 | ######################################################################### 51 | ### The model 52 | ######################################################################### 53 | 54 | def initial_state(), do: %{} 55 | 56 | def weight(_), 57 | do: %{ 58 | get: 2, 59 | put: 2, 60 | insert_new: 2, 61 | delete: 2, 62 | update_existing: 1, 63 | get_or_store: 3, 64 | size: 1 65 | } 66 | 67 | defcommand :get do 68 | def impl(key), do: SimplerCache.get(key) 69 | def args(_state), do: [key()] 70 | 71 | def post(entries, [key], call_result) do 72 | call_result == Map.get(entries, key) 73 | end 74 | end 75 | 76 | defcommand :put do 77 | def impl(key, val), do: SimplerCache.put(key, val) 78 | def args(_state), do: [key(), val()] 79 | def next(old_state, _args, {:error, _any}), do: old_state 80 | def next(old_state, [key, val], _any), do: Map.put(old_state, key, val) 81 | 82 | def post(entries, [key, _val], call_result) do 83 | case Map.get(entries, key) do 84 | _any -> 85 | call_result == {:ok, :inserted} 86 | end 87 | end 88 | end 89 | 90 | defcommand :insert_new do 91 | def impl(key, val), do: SimplerCache.insert_new(key, val) 92 | def args(_state), do: [key(), val()] 93 | def next(old_state, _args, {:error, _any}), do: old_state 94 | def next(old_state, [key, val], _any), do: Map.put_new(old_state, key, val) 95 | 96 | def post(entries, [key, _new_val], call_result) do 97 | case Map.has_key?(entries, key) do 98 | true -> 99 | call_result == {:error, :item_is_in_cache} 100 | 101 | false -> 102 | call_result == {:ok, :inserted} 103 | end 104 | end 105 | end 106 | 107 | defcommand :delete do 108 | def impl(key), do: SimplerCache.delete(key) 109 | def args(_state), do: [key()] 110 | 111 | def next(old_state, [key], _call_result), do: Map.delete(old_state, key) 112 | 113 | def post(entries, [key], call_result) do 114 | case Map.get(entries, key) do 115 | nil -> 116 | call_result == {:ok, :not_found} 117 | 118 | _any -> 119 | call_result == {:ok, :deleted} 120 | end 121 | end 122 | end 123 | 124 | defcommand :update_existing do 125 | def impl(key, passed_fn), do: SimplerCache.update_existing(key, passed_fn) 126 | def args(_state), do: [key(), update_function()] 127 | 128 | def next(old_state, [key, update_fn], _call_result) do 129 | case Map.get(old_state, key) do 130 | nil -> 131 | old_state 132 | 133 | _ -> 134 | Map.update!(old_state, key, update_fn) 135 | end 136 | end 137 | 138 | def post(entries, [key, _fn], call_result) do 139 | case Map.get(entries, key) do 140 | nil -> call_result == {:error, :failed_to_find_entry} 141 | _ -> call_result == {:ok, :updated} 142 | end 143 | end 144 | end 145 | 146 | defcommand :get_or_store do 147 | def impl(key, fallback_fn), do: SimplerCache.get_or_store(key, fallback_fn) 148 | def args(_state), do: [key(), fallback_function()] 149 | 150 | def next(old_state, [key, fallback_fn], _call_result) do 151 | case Map.get(old_state, key) do 152 | nil -> 153 | Map.put(old_state, key, fallback_fn.()) 154 | 155 | _val -> 156 | old_state 157 | end 158 | end 159 | 160 | def post(entries, [key, fallback_fn], call_result) do 161 | call_result == Map.get(entries, key, fallback_fn.()) 162 | end 163 | end 164 | 165 | defcommand :size do 166 | def impl(), do: SimplerCache.size() 167 | def args(_state), do: [] 168 | 169 | def post(entries, [], call_result) do 170 | Enum.count(entries) == call_result 171 | end 172 | end 173 | end 174 | -------------------------------------------------------------------------------- /test/cache_statem_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PropCheck.Test.CacheStateM do 2 | @moduledoc """ 3 | This is a model test with the proper statem for stateful parallel property testing. 4 | """ 5 | use ExUnit.Case 6 | use PropCheck 7 | use PropCheck.StateM 8 | 9 | @table_name Application.get_env(:simpler_cache, :cache_name, :simpler_cache) 10 | 11 | ######################################################################### 12 | ### The properties 13 | ######################################################################### 14 | 15 | @tag timeout: 240_000 16 | property "run the cache commands in parallel", [:verbose, numtests: 300, max_size: 40] do 17 | forall cmds in parallel_commands(__MODULE__) do 18 | trap_exit do 19 | :ets.delete_all_objects(@table_name) 20 | {history, state, result} = run_parallel_commands(__MODULE__, cmds) 21 | :ets.delete_all_objects(@table_name) 22 | 23 | # no_possible_interleaving is possible due to lack of locks 24 | # this isn't always serializable 25 | (result == :ok || result == :no_possible_interleaving) 26 | |> when_fail( 27 | IO.puts(""" 28 | History: #{inspect(history, pretty: true)} 29 | State: #{inspect(state, pretty: true)} 30 | Result: #{inspect(result, pretty: true)} 31 | """) 32 | ) 33 | |> aggregate(command_names(cmds)) 34 | end 35 | end 36 | end 37 | 38 | ######################################################################### 39 | ### Generators 40 | ######################################################################### 41 | 42 | defp key(), do: term() 43 | 44 | defp val(), do: term() 45 | 46 | defp update_function(), do: function(1, term()) 47 | 48 | defp fallback_function(), do: function(0, term()) 49 | 50 | def command(_state) do 51 | frequency([ 52 | {2, {:call, SimplerCache, :get, [key()]}}, 53 | {2, {:call, SimplerCache, :put, [key(), val()]}}, 54 | {2, {:call, SimplerCache, :insert_new, [key(), val()]}}, 55 | {2, {:call, SimplerCache, :delete, [key()]}}, 56 | {1, {:call, SimplerCache, :update_existing, [key(), update_function()]}}, 57 | {3, {:call, SimplerCache, :get_or_store, [key(), fallback_function()]}} 58 | ]) 59 | end 60 | 61 | ######################################################################### 62 | ### The model 63 | ######################################################################### 64 | 65 | def initial_state(), do: %{} 66 | 67 | def next_state(old_state, _any, {:call, SimplerCache, :get, [_args]}), do: old_state 68 | 69 | def next_state(old_state, _any, {:call, SimplerCache, :put, [_args]}), do: old_state 70 | 71 | def next_state(old_state, {:error, _any}, {:call, SimplerCache, :put, [_args]}), do: old_state 72 | 73 | def next_state(old_state, _any, {:call, SimplerCache, :put, [key, val]}), 74 | do: Map.put(old_state, key, val) 75 | 76 | def next_state(old_state, {:error, _any}, {:call, SimplerCache, :insert_new, [_args]}), 77 | do: old_state 78 | 79 | def next_state(old_state, _any, {:call, SimplerCache, :insert_new, [key, val]}), 80 | do: Map.put_new(old_state, key, val) 81 | 82 | def next_state(old_state, _any, {:call, SimplerCache, :insert_new, [_args]}), 83 | do: old_state 84 | 85 | def next_state(old_state, _any, {:call, SimplerCache, :delete, [key]}), 86 | do: Map.delete(old_state, key) 87 | 88 | def next_state(old_state, _any, {:call, SimplerCache, :update_existing, [key, update_fn]}) do 89 | case Map.get(old_state, key) do 90 | nil -> 91 | old_state 92 | 93 | _ -> 94 | Map.update!(old_state, key, update_fn) 95 | end 96 | end 97 | 98 | def next_state(old_state, _any, {:call, SimplerCache, :get_or_store, [key, fallback_fn]}) do 99 | case Map.get(old_state, key) do 100 | nil -> 101 | Map.put(old_state, key, fallback_fn.()) 102 | 103 | _val -> 104 | old_state 105 | end 106 | end 107 | 108 | def precondition(_state, _call), do: true 109 | 110 | def postcondition(entries, {:call, SimplerCache, :get, [key]}, call_result), 111 | do: call_result == Map.get(entries, key) 112 | 113 | def postcondition(entries, {:call, SimplerCache, :put, [key, _val]}, call_result) do 114 | case Map.get(entries, key) do 115 | _any -> 116 | call_result == {:ok, :inserted} 117 | end 118 | end 119 | 120 | def postcondition(entries, {:call, SimplerCache, :insert_new, [key, _new_val]}, call_result) do 121 | case Map.has_key?(entries, key) do 122 | true -> 123 | call_result == {:error, :item_is_in_cache} 124 | 125 | false -> 126 | call_result == {:ok, :inserted} 127 | end 128 | end 129 | 130 | def postcondition(entries, {:call, SimplerCache, :delete, [key]}, call_result) do 131 | case Map.get(entries, key) do 132 | nil -> 133 | call_result == {:ok, :not_found} 134 | 135 | _any -> 136 | call_result == {:ok, :deleted} 137 | end 138 | end 139 | 140 | def postcondition(entries, {:call, SimplerCache, :update_existing, [key, _fn]}, call_result) do 141 | case Map.get(entries, key) do 142 | nil -> call_result == {:error, :failed_to_find_entry} 143 | _ -> call_result == {:ok, :updated} 144 | end 145 | end 146 | 147 | def postcondition( 148 | entries, 149 | {:call, SimplerCache, :get_or_store, [key, fallback_fn]}, 150 | call_result 151 | ), 152 | do: call_result == Map.get(entries, key, fallback_fn.()) 153 | end 154 | -------------------------------------------------------------------------------- /test/simple_cache_test.exs: -------------------------------------------------------------------------------- 1 | defmodule SimplerCacheTest do 2 | use ExUnit.Case 3 | use PropCheck 4 | 5 | @table_name Application.get_env(:simpler_cache, :cache_name, :simpler_cache) 6 | 7 | setup do 8 | :ets.delete_all_objects(@table_name) 9 | :ok 10 | end 11 | 12 | @tag timeout: 105_000 13 | property "Set ttl guarantees key dies after x time", numtests: 120 do 14 | forall {key, val, timer_ttl_ms} <- {term(), term(), integer(1, 100_000)} do 15 | {:ok, :inserted} = SimplerCache.insert_new(key, val) 16 | {:ok, :updated} = SimplerCache.set_ttl_ms(key, timer_ttl_ms) 17 | :timer.sleep(timer_ttl_ms + 10) 18 | equals(SimplerCache.get(key), nil) 19 | end 20 | end 21 | 22 | property "Set ttl and expires always equal", numtests: 20 do 23 | forall {key, val, timer_ttl_ms} <- {term(), term(), integer(5_000, 100_000_000)} do 24 | {:ok, :inserted} = SimplerCache.put(key, val) 25 | {:ok, :updated} = SimplerCache.set_ttl_ms(key, timer_ttl_ms) 26 | expire_at = :ets.lookup_element(@table_name, key, 4) 27 | expiry_buffer_ms = round(timer_ttl_ms / 5) 28 | 29 | equals( 30 | round((expire_at - :erlang.monotonic_time(:millisecond) + expiry_buffer_ms) / 5_000), 31 | round(timer_ttl_ms / 5_000) 32 | ) 33 | end 34 | end 35 | 36 | property "doesnt explode on ttl set with missing item", numtests: 5 do 37 | forall {key, timer_ttl_ms} <- {term(), integer(101, :inf)} do 38 | equals({:error, :element_not_found}, SimplerCache.set_ttl_ms(key, timer_ttl_ms)) 39 | end 40 | end 41 | 42 | property "insert new doesnt insert if item exists already", numtests: 5 do 43 | forall {key, val} <- {term(), term()} do 44 | :ets.delete_all_objects(@table_name) 45 | {:ok, :inserted} = SimplerCache.insert_new(key, val) 46 | equals({:error, :item_is_in_cache}, SimplerCache.insert_new(key, :new_val)) 47 | end 48 | end 49 | 50 | property "update_existing eventually updates with high contention", numtests: 20 do 51 | forall {key, update_fn} <- {term(), function(1, term())} do 52 | contender_val = :something 53 | delay = 1 54 | {:ok, :inserted} = SimplerCache.put(key, contender_val) 55 | {:ok, contender_1} = :timer.apply_interval(delay, SimplerCache, :put, [key, contender_val]) 56 | 57 | {:ok, contender_2} = 58 | :timer.apply_interval(delay + 1, SimplerCache, :put, [key, contender_val]) 59 | 60 | :timer.sleep(delay) 61 | 62 | equals({:ok, :updated}, SimplerCache.update_existing(key, update_fn)) 63 | equals({:ok, :cancel}, :timer.cancel(contender_1)) 64 | equals({:ok, :cancel}, :timer.cancel(contender_2)) 65 | end 66 | end 67 | 68 | property "get_or_store eventually updates with high contention", numtests: 20 do 69 | forall {key, fallback_fn} <- {term(), function(0, term())} do 70 | contender_val = :something 71 | delay = 1 72 | {:ok, contender_1} = :timer.apply_interval(delay, SimplerCache, :put, [key, contender_val]) 73 | 74 | {:ok, contender_2} = 75 | :timer.apply_interval(delay + 1, SimplerCache, :put, [key, contender_val]) 76 | 77 | :timer.sleep(delay) 78 | 79 | new_val = fallback_fn.() 80 | equals(new_val, SimplerCache.get_or_store(key, fallback_fn)) 81 | equals({:ok, :cancel}, :timer.cancel(contender_1)) 82 | equals({:ok, :cancel}, :timer.cancel(contender_2)) 83 | end 84 | end 85 | 86 | property "get for not inserted keys works", numtests: 5 do 87 | forall {key} <- {term()} do 88 | equals(nil, SimplerCache.get(key)) 89 | end 90 | end 91 | 92 | @tag timeout: 105_000 93 | @final_value "I took awhile" 94 | property "get_or_store warmings works", numtests: 5 do 95 | forall {key, val, timer_ttl_ms} <- {term(), term(), integer(1000, 100_000)} do 96 | sleep_time = round(timer_ttl_ms / 5) 97 | 98 | SimplerCache.get_or_store( 99 | key, 100 | fn -> 101 | Process.sleep(sleep_time) 102 | @final_value 103 | end, 104 | timer_ttl_ms 105 | ) 106 | 107 | new_val = SimplerCache.get_or_store(key, fn -> val end, timer_ttl_ms) 108 | equals(new_val, @final_value) 109 | end 110 | end 111 | 112 | property "get_or_store fix works correctly", numtests: 5 do 113 | forall {key, val, fallback_fn} <- {term(), term(), function(0, term())} do 114 | {:ok, :inserted} = SimplerCache.put(key, val) 115 | new_tll_ms = 100 116 | SimplerCache.set_ttl_ms(key, new_tll_ms) 117 | :timer.sleep(new_tll_ms - 10) 118 | equals(fallback_fn.(), SimplerCache.get_or_store(key, fallback_fn)) 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------