├── .formatter.exs ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── config ├── config.exs ├── dev.exs └── test.exs ├── lib ├── cache │ ├── behaviour.ex │ ├── cache.ex │ ├── gen_cache.ex │ └── janitor.ex ├── defaults.ex ├── partitions │ ├── behaviour.ex │ └── gen_partitionable.ex ├── periodic │ └── tasks_executor.ex ├── prerender │ ├── behaviour.ex │ ├── gen_prerender.ex │ ├── gen_prerender_sup.ex │ ├── periodic │ │ └── tasks_executor.ex │ └── server.ex └── store │ ├── ets │ ├── ets.ex │ └── ets_sup.ex │ └── gen_store.ex ├── mix.exs ├── mix.lock └── test ├── cache ├── gen_cache_test.exs └── stress_test.exs ├── periodic └── tasks_executor_test.exs ├── prerender ├── gen_prerender_sup_test.exs ├── gen_prerender_test.exs └── stress_test.exs ├── store └── ets_test.exs ├── test_helper.exs └── test_macros.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | [ 2 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] 3 | ] 4 | -------------------------------------------------------------------------------- /.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 | gen_spoxy-*.tar 24 | 25 | *.swp 26 | 27 | tags 28 | 29 | .DS_Store 30 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Changelog 2 | 3 | ## v0.0.14-beta.3 4 | `GenCache` - adding a new method: `get_and_trigger_async_fetch` 5 | this method lookups the cache and returns immediately. 6 | 7 | in case there's a cache miss or stale data 8 | it enqueues a refresh task in the backgrund before returning 9 | 10 | ## v0.0.14-beta.2 11 | prerender periodic tasks executor optimization 12 | 13 | ## v0.0.14-beta.1 14 | * renaming `Constants` to `Defaults` 15 | * changing the defaults to suit most applications out-of-the-box 16 | * `GenSpoxy.Cache` and `GenSpoxy.Prerender` expect configuations override under `config` 17 | 18 | for example: 19 | ```elixir 20 | defmodule SamplePrerender do 21 | use GenSpoxy.Prerender, 22 | config: [prerender_timeout: 3000] 23 | 24 | @impl true 25 | def do_req(req) do 26 | # slow calculation of `req` 27 | end 28 | 29 | @impl true 30 | def calc_req_key(req) do 31 | Enum.join(req, "-") 32 | end 33 | end 34 | 35 | defmodule SampleCache do 36 | use GenSpoxy.Cache, 37 | store_module: Ets, 38 | prerender_module: SamplePrerender, 39 | config: [periodic_sampling_interval: 100] 40 | end 41 | ``` 42 | 43 | * `GenSpoxy.Prerender` settings are: 44 | * `prerender_timeout` (defaults to `Defaults.prerender_timeout()`) 45 | * `prerender_total_partitions` (defaults to `Defaults.total_partitions()`) 46 | * `prerender_sampling_interval` (defaults to `Defaults.prerender_sampling_interval()`) 47 | 48 | * `GenSpoxy.Cache` settings for its underlying `TasksExecutor` are: 49 | * `periodic_sampling_interval` (defaults to `Defaults.periodic_sampling_interval()`) 50 | * `periodic_total_partitions (defaults to `Defaults.total_partitions()`) 51 | 52 | 53 | ## v0.0.12 54 | first release 55 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2018 Spot.IM. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GenSpoxy 2 | 3 | ## DEPRECATION WARNING 4 | 5 | **This package is now deprecated in favor of [Shielded Cache](https://github.com/SpotIM/shielded-cache). It is no longer being used in production nor is it being maintained.** 6 | 7 | ## Package Information 8 | 9 | the `GenSpoxy` package consist of battle-tested abstractions that help creating in-memory caching 10 | 11 | ### Advantages of `GenSpoxy`: 12 | 1. Makes it very easy to create from scratch highly-concurrent applicative reverse-proxy 13 | that holds an internal short-lived (configurable) cache. 14 | 1. CDN like Origin Shielding - when multiple clients ask for the same request and experience a cache miss, 15 | the calculation will be done only once 16 | 1. Supports non-blocking mode for requests that are willing to receive stale cached data 17 | 1. Eases the time-to-market of features that require some caching 18 | 19 | ### notes: 20 | 1. The default cache storage used is `ETS` 21 | 1. The default behaviour is `non-blocking` 22 | 1. Each request should be transformed to a signature deterministically (a.k.a. `req_key`) 23 | 24 | 25 | ### usage example: 26 | ```elixir 27 | defmodule SampleCache do 28 | use GenSpoxy.Cache, prerender_module: SamplePrerender 29 | end 30 | 31 | defmodule SamplePrerender do 32 | use GenSpoxy.Prerender 33 | 34 | @impl true 35 | def do_req(req) do 36 | # slow calculation of `req` 37 | end 38 | 39 | @impl true 40 | def calc_req_key(req) do 41 | Enum.join(req, "-") 42 | end 43 | end 44 | 45 | # usage 46 | opts = [ 47 | table_name: "sample-table", 48 | do_janitor_work: true, # whether we garbage collect expired data 49 | ttl_ms: 5_000 # the data is considered non-stale for 5 seconds 50 | ] 51 | 52 | # `req` is application dependant 53 | req = %{url: "https://www.very-slow-server.com", platform: "mobile"} 54 | 55 | SampleCache.get_or_fetch(req, opts) # blocking manner 56 | 57 | SampleCache.async_get_or_fetch(req, opts) # async manner (we're OK with accepting a stale response) 58 | ``` 59 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config_file = Path.expand(".", "config/#{Mix.env()}.exs") 4 | 5 | if File.exists?(config_file) do 6 | import_config(config_file) 7 | end 8 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :logger, level: :info 4 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :logger, level: :debug 4 | -------------------------------------------------------------------------------- /lib/cache/behaviour.ex: -------------------------------------------------------------------------------- 1 | defmodule Spoxy.Cache.Behaviour do 2 | @moduledoc """ 3 | decides if the stored data should be invalidated (for example stale data) 4 | """ 5 | @callback should_invalidate?( 6 | req :: any, 7 | resp :: any, 8 | metadata :: any 9 | ) :: boolean() 10 | end 11 | -------------------------------------------------------------------------------- /lib/cache/cache.ex: -------------------------------------------------------------------------------- 1 | defmodule Spoxy.Cache do 2 | @moduledoc """ 3 | responsible on orchestration of the request lifecycle. 4 | in case the request is cached returns the response, 5 | if the cached data is in the cache but with stale data, 6 | the cached data will be returned when executed within `non-blocking` mode. 7 | 8 | when the request isn't in the cache, trigger a calculation (called prerender) 9 | and stores the result for later usage. 10 | """ 11 | 12 | alias Spoxy.Cache.Janitor 13 | 14 | def async_get_or_fetch(mods, req, req_key, opts \\ []) do 15 | Task.async(fn -> 16 | started = System.system_time(:milliseconds) 17 | 18 | {:ok, resp} = get_or_fetch(mods, req, req_key, opts) 19 | 20 | ended = System.system_time(:milliseconds) 21 | 22 | {:ok, resp, ended - started} 23 | end) 24 | end 25 | 26 | def get_or_fetch(mods, req, req_key, opts \\ []) do 27 | {prerender_module, store_module, tasks_executor_mod} = mods 28 | 29 | hit_or_miss = get(store_module, req_key, opts) 30 | 31 | case hit_or_miss do 32 | {:hit, {resp, metadata}} -> 33 | if should_invalidate?(req, resp, metadata) do 34 | # the cache holds stale data, 35 | # now we need to decide if we will force refreshing the cache 36 | # before returning a response (a _blocking_ call) 37 | # or whether we'll return a stale reponse and enqueue 38 | # a background task that will refresh the cache 39 | blocking = Keyword.get(opts, :blocking, false) 40 | 41 | if blocking do 42 | # we don't want the stale data, so force recalculation 43 | refresh_req!({prerender_module, store_module}, req, req_key, opts) 44 | else 45 | # we'll spawn a background task in a fire-and-forget manner 46 | # that will make sure the stale data is refreshed 47 | # so that future requests, will benefit from a fresh data 48 | enqueue_req(tasks_executor_mod, req_key, req, opts) 49 | 50 | # returning the stale data 51 | {:ok, resp} 52 | end 53 | else 54 | # we have a fresh data in the cache 55 | {:ok, resp} 56 | end 57 | 58 | {:miss, _} -> 59 | # we have nothing in the cache, we need to calculate the request's value 60 | refresh_req!({prerender_module, store_module}, req, req_key, opts) 61 | end 62 | end 63 | 64 | def get_and_trigger_async_fetch(mods, req, req_key, opts \\ []) do 65 | {_prerender_module, store_module, tasks_executor_mod} = mods 66 | 67 | hit_or_miss = get(store_module, req_key, opts) 68 | 69 | case hit_or_miss do 70 | {:hit, {resp, metadata}} -> 71 | if should_invalidate?(req, resp, metadata) do 72 | # we have stale data in the cache... 73 | # we trigger a refresh in the background 74 | enqueue_req(tasks_executor_mod, req_key, req, opts) 75 | 76 | {:ok, resp} 77 | else 78 | # we have a fresh data 79 | {:ok, resp} 80 | end 81 | 82 | {:miss, _} -> 83 | enqueue_req(tasks_executor_mod, req_key, req, opts) 84 | 85 | {:miss, "couldn't locate in cache"} 86 | end 87 | end 88 | 89 | def get(store_module, req_key, opts \\ []) do 90 | {:ok, table_name} = Keyword.fetch(opts, :table_name) 91 | lookup = lookup_req(store_module, table_name, req_key) 92 | 93 | case lookup do 94 | nil -> {:miss, "couldn't locate in cache"} 95 | _ -> {:hit, lookup} 96 | end 97 | end 98 | 99 | def refresh_req!({prerender_module, store_module}, req, req_key, opts) do 100 | case do_req(prerender_module, req) do 101 | {{:ok, resp}, :active} -> 102 | {:ok, table_name} = Keyword.fetch(opts, :table_name) 103 | {:ok, ttl_ms} = Keyword.fetch(opts, :ttl_ms) 104 | 105 | version = UUID.uuid1() 106 | metadata = %{version: version} 107 | 108 | store_opts = [table_name, {req, req_key, resp, metadata}, opts] 109 | store_req!(store_module, store_opts) 110 | 111 | do_janitor_work = Keyword.get(opts, :do_janitor_work, true) 112 | 113 | if do_janitor_work do 114 | Janitor.schedule_janitor_work( 115 | store_module, 116 | table_name, 117 | req_key, 118 | version, 119 | ttl_ms * 2 120 | ) 121 | end 122 | 123 | {:ok, resp} 124 | 125 | {{:ok, resp}, :passive} -> 126 | {:ok, resp} 127 | 128 | {{:error, reason}, _} -> 129 | {:error, reason} 130 | end 131 | end 132 | 133 | def do_req(prerender_module, req) do 134 | apply(prerender_module, :perform, [req]) 135 | end 136 | 137 | def store_req!(store_module, opts) do 138 | apply(store_module, :store_req!, opts) 139 | end 140 | 141 | def lookup_req(store_module, table_name, req_key) do 142 | apply(store_module, :lookup_req, [table_name, req_key]) 143 | end 144 | 145 | def should_invalidate?(_req, _resp, metadata) do 146 | %{expires_at: expires_at} = metadata 147 | 148 | System.system_time(:milliseconds) > expires_at 149 | end 150 | 151 | def enqueue_req(tasks_executor_mod, req_key, req, opts) do 152 | Task.start(fn -> 153 | tasks_executor_mod.enqueue_task(req_key, [req, opts]) 154 | end) 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /lib/cache/gen_cache.ex: -------------------------------------------------------------------------------- 1 | defmodule GenSpoxy.Cache do 2 | @moduledoc """ 3 | This behaviour is responsible for implementing a caching layer on top of the prerender 4 | """ 5 | 6 | defmacro __using__(opts) do 7 | quote bind_quoted: [opts: opts] do 8 | alias Spoxy.Cache 9 | alias GenSpoxy.Stores.Ets 10 | 11 | @behaviour Spoxy.Cache.Behaviour 12 | 13 | @store_module Keyword.get(opts, :store_module, Ets) 14 | @prerender_module Keyword.get(opts, :prerender_module) 15 | 16 | cache_module = __MODULE__ 17 | tasks_executor_mod = String.to_atom("#{cache_module}.TasksExecutor") 18 | 19 | @tasks_executor_mod tasks_executor_mod 20 | 21 | config = Keyword.get(opts, :config, []) 22 | executor_opts = Keyword.merge(config, cache_module: __MODULE__) 23 | 24 | defmodule @tasks_executor_mod do 25 | use GenSpoxy.Prerender.PeriodicTasksExecutor, executor_opts 26 | end 27 | 28 | tasks_executor_sup_mod = String.to_atom("#{tasks_executor_mod}.Supervisor") 29 | 30 | defmodule tasks_executor_sup_mod do 31 | use GenSpoxy.Prerender.Supervisor, supervised_module: tasks_executor_mod 32 | end 33 | 34 | def async_get_or_fetch(req, opts \\ []) do 35 | req_key = calc_req_key(req) 36 | mods = {@prerender_module, @store_module, @tasks_executor_mod} 37 | 38 | Cache.async_get_or_fetch(mods, req, req_key, opts) 39 | end 40 | 41 | def get_or_fetch(req, opts \\ []) do 42 | req_key = calc_req_key(req) 43 | mods = {@prerender_module, @store_module, @tasks_executor_mod} 44 | 45 | Cache.get_or_fetch(mods, req, req_key, opts) 46 | end 47 | 48 | @doc """ 49 | receives a request `req`, determines it's signature (a.k.a `req_key`), 50 | then it fetches the local cache. it returns `nil` in case there is nothing in cache 51 | 52 | if the cache is empty or the data is stale a background fetch task is issued 53 | """ 54 | def get_and_trigger_async_fetch(req, opts \\ []) do 55 | req_key = calc_req_key(req) 56 | mods = {@prerender_module, @store_module, @tasks_executor_mod} 57 | 58 | Cache.get_and_trigger_async_fetch(mods, req, req_key, opts) 59 | end 60 | 61 | @doc """ 62 | receives a request `req`, determines it's signature (a.k.a `req_key`), 63 | then it fetches the local cache. it returns `nil` in case there is nothing in cache, 64 | else returns the cached entry 65 | """ 66 | def get(req, opts \\ []) do 67 | req_key = calc_req_key(req) 68 | Cache.get(@store_module, req_key, opts) 69 | end 70 | 71 | def refresh_req!(req, opts) do 72 | req_key = calc_req_key(req) 73 | mods = {@prerender_module, @store_module} 74 | Cache.refresh_req!(mods, req, req_key, opts) 75 | end 76 | 77 | def await(task) do 78 | {:ok, resp, total} = Task.await(task) 79 | end 80 | 81 | def do_req(req) do 82 | Cache.do_req(@prerender_module, req) 83 | end 84 | 85 | def store_req!(opts) do 86 | Cache.store_req!(@store_module, opts) 87 | end 88 | 89 | def lookup_req(table_name, req_key) do 90 | Cache.lookup_req(@store_module, table_name, req_key) 91 | end 92 | 93 | @impl true 94 | def should_invalidate?(req, resp, metadata) do 95 | Cache.should_invalidate?(req, resp, metadata) 96 | end 97 | 98 | # defoverridable [should_invalidate?: 3] 99 | 100 | defp calc_req_key(req) do 101 | apply(@prerender_module, :calc_req_key, [req]) 102 | end 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/cache/janitor.ex: -------------------------------------------------------------------------------- 1 | defmodule Spoxy.Cache.Janitor do 2 | @moduledoc """ 3 | responsible on garbage collecting stale data out of the cache store (for example: `ets`) 4 | """ 5 | 6 | def schedule_janitor_work(store_module, table_name, req_key, metadata, janitor_time) do 7 | entry = {store_module, table_name, req_key, metadata} 8 | timeout = janitor_time + 5_000 9 | pid = spawn(__MODULE__, :do_janitor_work, [timeout]) 10 | Process.send_after(pid, {:invalidate_if_stale!, entry}, janitor_time) 11 | end 12 | 13 | def do_janitor_work(timeout \\ 30_000) do 14 | timeout = if timeout <= 0, do: 30_000, else: timeout 15 | 16 | receive do 17 | {:invalidate_if_stale!, {store_module, table_name, req_key, version}} -> 18 | case lookup_req(store_module, table_name, req_key) do 19 | {_resp, %{version: ^version}} -> 20 | invalidate!(store_module, table_name, req_key) 21 | 22 | _ -> 23 | :ignore 24 | end 25 | 26 | _ -> 27 | Process.exit(self(), :error) 28 | after 29 | timeout -> Process.exit(self(), :timeout) 30 | end 31 | end 32 | 33 | defp lookup_req(store_module, table_name, req_key) do 34 | apply(store_module, :lookup_req, [table_name, req_key]) 35 | end 36 | 37 | defp invalidate!(store_module, table_name, req_key) do 38 | apply(store_module, :invalidate!, [table_name, req_key]) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/defaults.ex: -------------------------------------------------------------------------------- 1 | defmodule GenSpoxy.Defaults do 2 | @moduledoc """ 3 | gathers all the default settings 4 | """ 5 | 6 | def total_partitions do 7 | System.schedulers_online() * 4 8 | end 9 | 10 | def prerender_timeout do 11 | 6000 12 | end 13 | 14 | def prerender_sampling_interval do 15 | 500 16 | end 17 | 18 | # for background tasks 19 | def periodic_sampling_interval do 20 | 5000 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/partitions/behaviour.ex: -------------------------------------------------------------------------------- 1 | defmodule Spoxy.Partitionable.Behaviour do 2 | @moduledoc """ 3 | since `GenServer` based module, process its own mailbox messages in serial manner, 4 | it's subject to have a long queuing time in case the queue becomes big. 5 | 6 | In order to scale such modules, we introduce the `GenSpoxy.Partitionable` behaviour, 7 | it will be implemented by modules that require and suit a paritioning logic 8 | """ 9 | 10 | @callback total_partitions() :: Integer 11 | 12 | @callback calc_req_partition(key :: String.t()) :: term 13 | 14 | @callback partition_server(key :: term) :: term 15 | end 16 | -------------------------------------------------------------------------------- /lib/partitions/gen_partitionable.ex: -------------------------------------------------------------------------------- 1 | defmodule GenSpoxy.Partitionable do 2 | @moduledoc """ 3 | since `GenServer` based module, process its own mailbox messages in serial manner, 4 | it's subject to have a long queuing time in case the queue becomes big. 5 | 6 | In order to scale such modules, we introduce the `GenSpoxy.Partitionable` behaviour, 7 | it will be implemented by modules that require and suit a paritioning logic 8 | """ 9 | 10 | defmacro __using__(_opts) do 11 | quote do 12 | @behaviour Spoxy.Partitionable.Behaviour 13 | 14 | def partition_server(partition) do 15 | {:global, "#{__MODULE__}-#{partition}"} 16 | end 17 | 18 | defoverridable partition_server: 1 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/periodic/tasks_executor.ex: -------------------------------------------------------------------------------- 1 | defmodule GenSpoxy.Periodic.TasksExecutor do 2 | @moduledoc """ 3 | a behaviour for running prerender tasks periodically. 4 | 5 | when we execute a `spoxy` reqeust and have a cache miss returning a stale date, 6 | we may choose to return the stale data and queue a background task. 7 | """ 8 | 9 | @callback execute_tasks!(req_key :: String.t(), req_tasks :: Array) :: :ok 10 | 11 | defmacro __using__(opts) do 12 | quote bind_quoted: [opts: opts] do 13 | use GenServer 14 | use GenSpoxy.Partitionable 15 | 16 | alias GenSpoxy.Defaults 17 | 18 | @sampling_interval Keyword.get( 19 | opts, 20 | :periodic_sampling_interval, 21 | Defaults.periodic_sampling_interval() 22 | ) 23 | 24 | default_partitions = 25 | Keyword.get( 26 | opts, 27 | :total_partitions, 28 | Defaults.total_partitions() 29 | ) 30 | 31 | @total_partitions Keyword.get( 32 | opts, 33 | :periodic_total_partitions, 34 | default_partitions 35 | ) 36 | 37 | def start_link(opts) do 38 | GenServer.start_link(__MODULE__, :ok, opts) 39 | end 40 | 41 | def enqueue_task(req_key, task) do 42 | server = lookup_req_server(req_key) 43 | GenServer.cast(server, {:enqueue_task, req_key, task}) 44 | end 45 | 46 | # callbacks 47 | @impl true 48 | def init(_opts) do 49 | Process.send_after(self(), :execute_tasks, @sampling_interval) 50 | 51 | {:ok, %{}} 52 | end 53 | 54 | @impl true 55 | def handle_cast({:enqueue_task, req_key, task}, state) do 56 | req_tasks = Map.get(state, req_key, []) 57 | new_state = Map.put(state, req_key, [task | req_tasks]) 58 | 59 | {:noreply, new_state} 60 | end 61 | 62 | @impl true 63 | def handle_info(:execute_tasks, state) do 64 | Process.send_after(self(), :execute_tasks, @sampling_interval) 65 | 66 | Enum.each(state, fn {req_key, req_tasks} -> 67 | Task.start(fn -> 68 | execute_tasks!(req_key, req_tasks) 69 | end) 70 | end) 71 | 72 | {:noreply, %{}} 73 | end 74 | 75 | @impl true 76 | def total_partitions do 77 | @total_partitions 78 | end 79 | 80 | @impl true 81 | def calc_req_partition(req_key) do 82 | 1 + :erlang.phash2(req_key, @total_partitions) 83 | end 84 | 85 | defp lookup_req_server(req_key) do 86 | partition = calc_req_partition(req_key) 87 | 88 | partition_server(partition) 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/prerender/behaviour.ex: -------------------------------------------------------------------------------- 1 | defmodule Spoxy.Prerender.Behaviour do 2 | @moduledoc """ 3 | executing the request itself 4 | """ 5 | @callback do_req(req :: any) :: {:ok, any} | {:error, any} 6 | 7 | @doc """ 8 | calculating the request signature (must be a deterministic calculation) 9 | i.e: given a `req` input, always returns the same `req_key` 10 | """ 11 | @callback calc_req_key(req :: any) :: String.t() 12 | end 13 | -------------------------------------------------------------------------------- /lib/prerender/gen_prerender.ex: -------------------------------------------------------------------------------- 1 | defmodule GenSpoxy.Prerender do 2 | @moduledoc """ 3 | a behaviour for defining prerender 4 | """ 5 | 6 | defmacro __using__(opts) do 7 | quote bind_quoted: [opts: opts] do 8 | use GenSpoxy.Partitionable 9 | 10 | alias GenSpoxy.Defaults 11 | alias Spoxy.Prerender.Server 12 | 13 | @behaviour Spoxy.Prerender.Behaviour 14 | 15 | config = Keyword.get(opts, :config, []) 16 | 17 | @default_timeout Keyword.get( 18 | config, 19 | :prerender_timeout, 20 | Defaults.prerender_timeout() 21 | ) 22 | 23 | default_partitions = 24 | Keyword.get( 25 | config, 26 | :total_partitions, 27 | Defaults.total_partitions() 28 | ) 29 | 30 | @total_partitions Keyword.get( 31 | config, 32 | :prerender_total_partitions, 33 | default_partitions 34 | ) 35 | 36 | @sample_interval Keyword.get( 37 | config, 38 | :prerender_sampling_interval, 39 | Defaults.prerender_sampling_interval() 40 | ) 41 | 42 | def start_link(opts) do 43 | Server.start_link(opts) 44 | end 45 | 46 | def perform(req) do 47 | server = lookup_server_name(req) 48 | req_key = calc_req_key(req) 49 | 50 | opts = [timeout: @default_timeout, interval: @sample_interval] 51 | 52 | Server.perform(server, __MODULE__, req, req_key, opts) 53 | end 54 | 55 | def sample_task_interval do 56 | @sample_interval 57 | end 58 | 59 | @impl true 60 | def calc_req_partition(req_key) do 61 | 1 + :erlang.phash2(req_key, total_partitions()) 62 | end 63 | 64 | @doc """ 65 | used for testing 66 | """ 67 | def get_req_state(req) do 68 | partition = req_partition(req) 69 | get_partition_state(partition) 70 | end 71 | 72 | @impl true 73 | def total_partitions do 74 | @total_partitions 75 | end 76 | 77 | @doc """ 78 | used for testing 79 | """ 80 | def get_partition_state(partition) do 81 | server = partition_server(partition) 82 | 83 | Server.get_partition_state(server) 84 | end 85 | 86 | def req_partition(req) do 87 | req_key = calc_req_key(req) 88 | calc_req_partition(req_key) 89 | end 90 | 91 | def inspect_all_partitions do 92 | initial_state = %{total_listeners: 0, total_passive: 0} 93 | 94 | Enum.reduce(1..@total_partitions, initial_state, fn partition, acc -> 95 | {:ok, total_listeners} = Map.fetch(acc, :total_listeners) 96 | {:ok, total_passive} = Map.fetch(acc, :total_passive) 97 | 98 | partition_data = inspect_partition(partition) 99 | 100 | {:ok, listeners} = Map.fetch(partition_data, :total_listeners) 101 | {:ok, passive} = Map.fetch(partition_data, :total_passive) 102 | 103 | new_total_listeners = total_listeners + listeners 104 | new_total_passive = total_passive + passive 105 | 106 | acc 107 | |> Map.put(:total_listeners, new_total_listeners) 108 | |> Map.put(:total_passive, new_total_passive) 109 | end) 110 | end 111 | 112 | @doc """ 113 | returns for `partition` the total number of listeners across all the partition requests 114 | and how many of them are passive listeners 115 | """ 116 | def inspect_partition(partition) do 117 | %{reqs_state: reqs_state} = get_partition_state(partition) 118 | 119 | initial_state = %{total_listeners: 0, total_passive: 0} 120 | 121 | Enum.reduce(reqs_state, initial_state, fn {_req_key, req_state}, acc -> 122 | {:ok, total_listeners} = Map.fetch(acc, :total_listeners) 123 | {:ok, total_passive} = Map.fetch(acc, :total_passive) 124 | 125 | %{listeners: listeners} = req_state 126 | 127 | passive = for {:passive, listener} <- listeners, do: listener 128 | 129 | new_total_listeners = total_listeners + Enum.count(listeners) 130 | new_total_passive = total_passive + Enum.count(passive) 131 | 132 | acc 133 | |> Map.put(:total_listeners, new_total_listeners) 134 | |> Map.put(:total_passive, new_total_passive) 135 | end) 136 | end 137 | 138 | defp lookup_server_name(req) do 139 | partition = req_partition(req) 140 | 141 | partition_server(partition) 142 | end 143 | 144 | prerender_module = __MODULE__ 145 | prerender_sup_module = String.to_atom("#{prerender_module}.Supervisor") 146 | 147 | defmodule prerender_sup_module do 148 | use GenSpoxy.Prerender.Supervisor, supervised_module: prerender_module 149 | end 150 | end 151 | end 152 | end 153 | -------------------------------------------------------------------------------- /lib/prerender/gen_prerender_sup.ex: -------------------------------------------------------------------------------- 1 | defmodule GenSpoxy.Prerender.Supervisor do 2 | @moduledoc """ 3 | a prerender dedicated supervisor. 4 | the prerender supervised module is assumed to implement the `GenSpoxy.Partitionable` behaviour 5 | """ 6 | 7 | defmacro __using__(opts) do 8 | quote do 9 | use Supervisor 10 | 11 | @supervised_module Keyword.get(unquote(opts), :supervised_module) 12 | 13 | def start_link(opts \\ []) do 14 | Supervisor.start_link(__MODULE__, opts, name: __MODULE__) 15 | end 16 | 17 | def init(_opts) do 18 | total_partitions = apply(@supervised_module, :total_partitions, []) 19 | 20 | children = 21 | Enum.map(1..total_partitions, fn partition -> 22 | child_name = calc_child_name(partition) 23 | 24 | worker( 25 | @supervised_module, 26 | [[name: child_name]], 27 | id: "#{@supervised_module}-#{partition}" 28 | ) 29 | end) 30 | 31 | supervise(children, strategy: :one_for_one) 32 | end 33 | 34 | defp calc_child_name(partition) do 35 | apply(@supervised_module, :partition_server, [partition]) 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/prerender/periodic/tasks_executor.ex: -------------------------------------------------------------------------------- 1 | defmodule GenSpoxy.Prerender.PeriodicTasksExecutor do 2 | @moduledoc """ 3 | responsible on periodically executing prerender tasks. 4 | """ 5 | 6 | defmacro __using__(opts) do 7 | quote bind_quoted: [opts: opts] do 8 | use GenSpoxy.Periodic.TasksExecutor, opts 9 | 10 | @cache_module Keyword.get(opts, :cache_module) 11 | 12 | def execute_tasks!(_req_key, []), do: :ok 13 | 14 | def execute_tasks!(req_key, [task | _] = _req_tasks) do 15 | [req, opts] = task 16 | 17 | # since all `_req_tasks` are exactly the same (since all have the same `req_key`) 18 | # we execute only one task 19 | 20 | apply(@cache_module, :refresh_req!, [req, opts]) 21 | 22 | :ok 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/prerender/server.ex: -------------------------------------------------------------------------------- 1 | defmodule Spoxy.Prerender.Server do 2 | @moduledoc """ 3 | responsible on managing prerender tasks 4 | """ 5 | 6 | use GenServer 7 | 8 | require Logger 9 | 10 | def start_link(opts) do 11 | GenServer.start_link(__MODULE__, :ok, opts) 12 | end 13 | 14 | def perform(server, prerender_module, req, req_key, opts) do 15 | {:ok, timeout} = Keyword.fetch(opts, :timeout) 16 | {:ok, interval} = Keyword.fetch(opts, :interval) 17 | 18 | command = {:perform, prerender_module, req, req_key, interval} 19 | 20 | GenServer.call(server, command, timeout) 21 | end 22 | 23 | def get_partition_state(server) do 24 | GenServer.call(server, :get_state) 25 | end 26 | 27 | @impl true 28 | def init(_opts) do 29 | Process.flag(:trap_exit, true) 30 | 31 | state = %{pid_ref: %{}, refs_resp: %{}, refs_req: %{}, reqs_state: %{}} 32 | 33 | {:ok, state} 34 | end 35 | 36 | # callbacks 37 | @impl true 38 | def handle_call({:perform, prerender_module, req, req_key, interval}, from, state) do 39 | {:ok, pid_ref} = Map.fetch(state, :pid_ref) 40 | {:ok, refs_resp} = Map.fetch(state, :refs_resp) 41 | {:ok, refs_req} = Map.fetch(state, :refs_req) 42 | {:ok, reqs_state} = Map.fetch(state, :reqs_state) 43 | 44 | not_started_state = %{listeners: [], status: :not_started} 45 | req_state = Map.get(reqs_state, req_key, not_started_state) 46 | 47 | %{listeners: listeners, status: status} = req_state 48 | 49 | {new_req_state, new_pid_ref, new_refs_req} = 50 | case status do 51 | :not_started -> 52 | task = Task.async(prerender_module, :do_req, [req]) 53 | %Task{ref: ref, pid: pid} = task 54 | 55 | # we first assert that `ref` and `pid` aren't in use. 56 | # *we handling theoretical `pid`/`ref` collisions) 57 | # if such case happens it'll probably implies a bug 58 | if Map.has_key?(pid_ref, pid) || Map.has_key?(refs_resp, ref) || 59 | Map.has_key?(refs_req, ref) do 60 | # this should never really happen 61 | Logger.error("resources (pid/ref) collisions") 62 | 63 | Process.exit(pid, :kill) 64 | 65 | raise "fatal error" 66 | end 67 | 68 | task = %{pid: pid, ref: ref} 69 | 70 | # we'll sample the task completion status 71 | # in `interval` ms from now 72 | schedule_sample_task(task, 1, interval) 73 | 74 | new_running_req_state = %{ 75 | listeners: [{:active, from}], 76 | status: :running, 77 | task: task 78 | } 79 | 80 | {new_running_req_state, Map.put_new(pid_ref, pid, ref), 81 | Map.put_new(refs_req, ref, req_key)} 82 | 83 | :running -> 84 | new_listeners = [{:passive, from} | listeners] 85 | {%{req_state | listeners: new_listeners}, pid_ref, refs_req} 86 | 87 | _ -> 88 | raise "unknown req status: '#{status}'" 89 | end 90 | 91 | new_reqs_state = Map.put(reqs_state, req_key, new_req_state) 92 | 93 | new_state = %{ 94 | state 95 | | pid_ref: new_pid_ref, 96 | refs_req: new_refs_req, 97 | reqs_state: new_reqs_state 98 | } 99 | 100 | # the `handle_info` method will reply to `from` in the future. 101 | # so we return `:noreply` for now. (see: `handle_info({ref, resp}, state)`) 102 | {:noreply, new_state} 103 | end 104 | 105 | @impl true 106 | def handle_call(:get_state, _from, state) do 107 | {:reply, state, state} 108 | end 109 | 110 | # this method expects the response of the prerender task 111 | @impl true 112 | def handle_info({ref, resp}, state) do 113 | %{refs_resp: refs_resp, refs_req: refs_req, reqs_state: reqs_state} = state 114 | 115 | new_refs_resp = Map.put_new(refs_resp, ref, resp) 116 | req_key = Map.get(refs_req, ref) 117 | req_state = Map.get(reqs_state, req_key) 118 | 119 | %{listeners: listeners} = req_state 120 | 121 | notify_listeners(listeners, resp) 122 | 123 | # we do a cleanup for the request state 124 | # the rest of the cleanup will be done in `do_cleanup` 125 | new_reqs_state = Map.delete(reqs_state, req_key) 126 | 127 | new_state = %{state | refs_resp: new_refs_resp, reqs_state: new_reqs_state} 128 | 129 | {:noreply, new_state} 130 | end 131 | 132 | # here we sample for the 1st time a running prerender task. 133 | # In case the task has been completed we cleanup its resources, 134 | # else, we schedule a 2nd and final sample in the future 135 | 136 | @impl true 137 | def handle_info({:first_iteration, task, interval}, state) do 138 | {:ok, ref} = Map.fetch(task, :ref) 139 | {:ok, refs_resp} = Map.fetch(state, :refs_resp) 140 | 141 | {:noreply, _} = 142 | if Map.has_key?(refs_resp, ref) do 143 | Logger.debug("1st sample task: performing cleanup for a terminated task") 144 | 145 | cleanup_opts = [delete_req_state: false, shutdown_task: false] 146 | do_cleanup(task, state, cleanup_opts) 147 | else 148 | # task didn't finish... 149 | # we're going to sample it again in `interval` ms 150 | schedule_sample_task(task, 2, interval) 151 | {:noreply, state} 152 | end 153 | end 154 | 155 | # Here we perform the 2nd and final sample for a prerender task. 156 | # If the task has been completed we are left with cleaning up its resources, 157 | # else, if task is still on-going (should be a very rare case), 158 | # we'll brutally kill it and then cleanup the associated resources. 159 | 160 | @impl true 161 | def handle_info({:second_iteration, task, _interval}, state) do 162 | {:ok, ref} = Map.fetch(task, :ref) 163 | {:ok, refs_resp} = Map.fetch(state, :refs_resp) 164 | 165 | {:noreply, _} = 166 | if Map.has_key?(refs_resp, ref) do 167 | Logger.debug("2nd sample task: performing cleanup for a terminated task") 168 | 169 | do_cleanup(task, state, delete_req_state: false, shutdown_task: false) 170 | else 171 | # seems the task is taking too much time... 172 | # we'll shut it down brutally and reset its state 173 | 174 | cleanup_opts = [delete_req_state: true, shutdown_task: true] 175 | 176 | Logger.debug(fn -> 177 | "2nd sample task: performing full cleanup (#{inspect(cleanup_opts)})" 178 | end) 179 | 180 | do_cleanup(task, state, cleanup_opts) 181 | end 182 | end 183 | 184 | @impl true 185 | def handle_info({:EXIT, pid, {_error, _stacktrace}}, state) do 186 | %{pid_ref: pid_ref, refs_req: refs_req, reqs_state: reqs_state} = state 187 | 188 | ref = Map.get(pid_ref, pid) 189 | req_key = Map.get(refs_req, ref) 190 | req_state = Map.get(reqs_state, req_key) 191 | 192 | %{listeners: listeners} = req_state 193 | 194 | notify_listeners(listeners, {:error, "error occurred"}) 195 | 196 | cleanup_opts = [delete_req_state: true, shutdown_task: false] 197 | {:noreply, _} = do_cleanup(%{ref: ref, pid: pid}, state, cleanup_opts) 198 | end 199 | 200 | def handle_info(_msg, state) do 201 | {:noreply, state} 202 | end 203 | 204 | ## private 205 | defp do_cleanup(%{ref: ref, pid: pid}, state, opts) do 206 | {:ok, pid_ref} = Map.fetch(state, :pid_ref) 207 | {:ok, refs_resp} = Map.fetch(state, :refs_resp) 208 | {:ok, refs_req} = Map.fetch(state, :refs_req) 209 | {:ok, reqs_state} = Map.fetch(state, :reqs_state) 210 | 211 | req_key = Map.get(refs_req, ref) 212 | 213 | new_refs_resp = Map.delete(refs_resp, ref) 214 | new_pid_ref = Map.delete(pid_ref, pid) 215 | new_refs_req = Map.delete(refs_req, ref) 216 | 217 | shutdown_task = Keyword.get(opts, :shutdown_task, false) 218 | delete_req_state = Keyword.get(opts, :delete_req_state, false) 219 | 220 | new_reqs_state = 221 | if delete_req_state do 222 | Map.delete(reqs_state, req_key) 223 | else 224 | reqs_state 225 | end 226 | 227 | if shutdown_task do 228 | Process.exit(pid, :kill) 229 | end 230 | 231 | new_state = %{ 232 | pid_ref: new_pid_ref, 233 | refs_resp: new_refs_resp, 234 | refs_req: new_refs_req, 235 | reqs_state: new_reqs_state 236 | } 237 | 238 | {:noreply, new_state} 239 | end 240 | 241 | defp notify_listeners(listeners, resp) do 242 | for {active_or_passive, from} <- listeners do 243 | GenServer.reply(from, {resp, active_or_passive}) 244 | end 245 | end 246 | 247 | defp schedule_sample_task(task, iteration, interval) do 248 | iteration_name = 249 | case iteration do 250 | 1 -> :first_iteration 251 | 2 -> :second_iteration 252 | _ -> raise "invalid iteration: #{iteration}" 253 | end 254 | 255 | Process.send_after(self(), {iteration_name, task, interval}, interval) 256 | end 257 | end 258 | -------------------------------------------------------------------------------- /lib/store/ets/ets.ex: -------------------------------------------------------------------------------- 1 | defmodule GenSpoxy.Stores.Ets do 2 | @moduledoc """ 3 | implements the `GenSpoxy.Store` behaviour. 4 | it stores its data under `ets` and it manages it in using sharded `GenServer`. 5 | """ 6 | 7 | use GenServer 8 | use GenSpoxy.Partitionable 9 | 10 | @behaviour GenSpoxy.Store 11 | 12 | alias GenSpoxy.Defaults 13 | 14 | @total_partitions Defaults.total_partitions() * 10 15 | 16 | # API 17 | def start_link(opts \\ []) do 18 | {:ok, partition} = Keyword.fetch(opts, :partition) 19 | 20 | opts = Keyword.put(opts, :name, partition_server(partition)) 21 | 22 | GenServer.start_link(__MODULE__, partition, opts) 23 | end 24 | 25 | @impl true 26 | def lookup_req(table_name, req_key) do 27 | partition = calc_req_partition(table_name) 28 | 29 | case :ets.lookup(ets_partition_table(partition), req_key) do 30 | [{^req_key, {resp, metadata}}] -> {resp, metadata} 31 | _ -> nil 32 | end 33 | end 34 | 35 | @impl true 36 | def store_req!(table_name, {req, req_key, resp, metadata}, opts) do 37 | partition = calc_req_partition(table_name) 38 | 39 | GenServer.call( 40 | partition_server(partition), 41 | {:store_req!, partition, req, req_key, resp, metadata, opts} 42 | ) 43 | end 44 | 45 | @impl true 46 | def invalidate!(table_name, req_key) do 47 | partition = calc_req_partition(table_name) 48 | server = partition_server(partition) 49 | 50 | GenServer.call(server, {:invalidate!, partition, req_key}) 51 | end 52 | 53 | @doc """ 54 | used for testing 55 | """ 56 | def reset_partition!(partition) do 57 | server = partition_server(partition) 58 | 59 | GenServer.call(server, {:reset!, partition}) 60 | end 61 | 62 | @doc """ 63 | used for testing 64 | """ 65 | def reset_all! do 66 | tasks = 67 | Enum.map(1..@total_partitions, fn partition -> 68 | Task.async(fn -> reset_partition!(partition) end) 69 | end) 70 | 71 | Enum.each(tasks, &Task.await/1) 72 | end 73 | 74 | # callbacks 75 | @impl true 76 | def init(partition) do 77 | :ets.new(ets_partition_table(partition), [ 78 | :set, 79 | :protected, 80 | :named_table, 81 | {:read_concurrency, true} 82 | ]) 83 | 84 | {:ok, []} 85 | end 86 | 87 | @impl true 88 | def handle_call({:store_req!, partition, _req, req_key, resp, metadata, opts}, _from, state) do 89 | uuid = UUID.uuid1() 90 | now = System.system_time(:milliseconds) 91 | 92 | {:ok, ttl_ms} = Keyword.fetch(opts, :ttl_ms) 93 | expires_at = now + ttl_ms 94 | 95 | metadata = Map.merge(metadata, %{uuid: uuid, expires_at: expires_at}) 96 | 97 | :ets.insert(ets_partition_table(partition), {req_key, {resp, metadata}}) 98 | 99 | {:reply, :ok, state} 100 | end 101 | 102 | @impl true 103 | def handle_call({:invalidate!, partition, req_key}, _from, state) do 104 | :ets.delete(ets_partition_table(partition), req_key) 105 | 106 | {:reply, :ok, state} 107 | end 108 | 109 | @impl true 110 | def handle_call({:reset!, partition}, _from, state) do 111 | :ets.delete_all_objects(ets_partition_table(partition)) 112 | 113 | {:reply, :ok, state} 114 | end 115 | 116 | @impl true 117 | def total_partitions do 118 | @total_partitions 119 | end 120 | 121 | @impl true 122 | def calc_req_partition(table_name) do 123 | 1 + :erlang.phash2(table_name, @total_partitions) 124 | end 125 | 126 | defp ets_partition_table(partition) do 127 | String.to_atom("ets-#{partition}") 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /lib/store/ets/ets_sup.ex: -------------------------------------------------------------------------------- 1 | defmodule GenSpoxy.Stores.Ets.Supervisor do 2 | @moduledoc """ 3 | supervising the `ets` tables used as a cache store 4 | """ 5 | 6 | use Supervisor 7 | 8 | alias GenSpoxy.Stores.Ets 9 | 10 | def start_link(opts \\ []) do 11 | Supervisor.start_link(__MODULE__, opts, name: __MODULE__) 12 | end 13 | 14 | def init(_opts) do 15 | children = 16 | Enum.map(1..Ets.total_partitions(), fn partition -> 17 | worker(Ets, [[partition: partition]], id: "ets-store-#{partition}") 18 | end) 19 | 20 | supervise(children, strategy: :one_for_one) 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/store/gen_store.ex: -------------------------------------------------------------------------------- 1 | defmodule GenSpoxy.Store do 2 | @moduledoc """ 3 | Behaviour to be implemented by backing stores 4 | """ 5 | 6 | @doc """ 7 | retrieving the data locally 8 | """ 9 | @callback lookup_req(table_name :: term, req_key :: any) :: any 10 | 11 | @doc """ 12 | storing the prerender 'request' -> 'response' pairs locally 13 | """ 14 | @callback store_req!( 15 | table_name :: String.t(), 16 | entry :: tuple, 17 | opts :: any 18 | ) :: any 19 | 20 | @doc """ 21 | removing the cached 'request' -> 'response' pair using `req_key` 22 | """ 23 | @callback invalidate!(table_name :: String.t(), req_key :: any) :: any 24 | end 25 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule GenPrerender.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.0.14-beta.3" 5 | 6 | @description "caching made fun!" 7 | 8 | def project do 9 | [ 10 | app: :gen_spoxy, 11 | version: @version, 12 | elixir: "~> 1.6", 13 | start_permanent: Mix.env() == :prod, 14 | deps: deps(), 15 | package: package(), 16 | description: @description, 17 | name: "GenSpoxy", 18 | docs: [ 19 | extras: ["README.md"], 20 | source_url: "https://github.com/spotim/gen_spoxy" 21 | ] 22 | ] 23 | end 24 | 25 | def application do 26 | [ 27 | extra_applications: [:logger] 28 | ] 29 | end 30 | 31 | defp deps do 32 | [ 33 | {:uuid, "~> 1.1"}, 34 | {:ex_doc, ">= 0.0.0", only: :dev}, 35 | {:credo, "~> 0.3", only: [:dev, :test]} 36 | ] 37 | end 38 | 39 | defp package() do 40 | [ 41 | licenses: ["MIT License"], 42 | maintainers: ["Yaron Wittenstein"], 43 | links: %{"Github" => "https://github.com/spotim/gen_spoxy"}, 44 | files: ["lib", "mix.exs", "README.md", "CHANGELOG.md", ".formatter.exs"] 45 | ] 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"}, 3 | "credo": {:hex, :credo, "0.8.10", "261862bb7363247762e1063713bb85df2bbd84af8d8610d1272cd9c1943bba63", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}], "hexpm"}, 4 | "earmark": {:hex, :earmark, "1.2.4", "99b637c62a4d65a20a9fb674b8cffb8baa771c04605a80c911c4418c69b75439", [:mix], [], "hexpm"}, 5 | "ex_doc": {:hex, :ex_doc, "0.18.3", "f4b0e4a2ec6f333dccf761838a4b253d75e11f714b85ae271c9ae361367897b7", [:mix], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"}, 6 | "uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [:mix], [], "hexpm"}, 7 | } 8 | -------------------------------------------------------------------------------- /test/cache/gen_cache_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GenSpoxy.Cache.Tests do 2 | use ExUnit.Case, async: false 3 | 4 | alias GenSpoxy.Stores.Ets 5 | 6 | import Macros.Tests 7 | 8 | defprerender(SamplePrerender, do_req: fn req -> {:ok, "response for #{inspect(req)}"} end) 9 | 10 | defmodule SampleCache do 11 | use GenSpoxy.Cache, 12 | prerender_module: SamplePrerender, 13 | config: [ 14 | periodic_sampling_interval: 50, 15 | periodic_total_partitions: 1 16 | ] 17 | end 18 | 19 | setup_all do 20 | SamplePrerender.Supervisor.start_link() 21 | SampleCache.TasksExecutor.Supervisor.start_link() 22 | 23 | :ok 24 | end 25 | 26 | setup do 27 | Ets.reset_all!() 28 | :ok 29 | end 30 | 31 | test "cache miss triggers prerender-fetch and stores the response" do 32 | table_name = "table-prerender-cache-test-1" 33 | req = ["req-cache-test-1", "newest"] 34 | ttl_ms = 200 35 | 36 | result = SampleCache.get(req, table_name: table_name) 37 | assert {:miss, _reason} = result 38 | 39 | resp = 40 | SampleCache.get_or_fetch( 41 | req, 42 | table_name: table_name, 43 | do_janitor_work: false, 44 | blocking: true, 45 | ttl_ms: ttl_ms 46 | ) 47 | 48 | assert {:ok, "response for [\"req-cache-test-1\", \"newest\"]"} = resp 49 | 50 | resp = SampleCache.get(req, table_name: table_name) 51 | assert {:hit, {"response for [\"req-cache-test-1\", \"newest\"]", %{}}} = resp 52 | 53 | resp = 54 | SampleCache.get_or_fetch( 55 | req, 56 | table_name: table_name, 57 | do_janitor_work: false, 58 | blocking: true, 59 | ttl_ms: ttl_ms 60 | ) 61 | 62 | assert {:ok, "response for [\"req-cache-test-1\", \"newest\"]"} = resp 63 | 64 | # invalidate 65 | req_key = SamplePrerender.calc_req_key(req) 66 | Ets.invalidate!(table_name, req_key) 67 | assert {:miss, _reason} = SampleCache.get(req, table_name: table_name) 68 | end 69 | 70 | test "stale data invalidates the request when `blocking=true`" do 71 | table_name = "table-prerender-cache-test-2" 72 | req = ["req-cache-test-2", "newest"] 73 | ttl_ms = 200 74 | 75 | assert {:miss, _reason} = SampleCache.get(req, table_name: table_name) 76 | 77 | # triggers fetch-and-store 78 | SampleCache.get_or_fetch( 79 | req, 80 | table_name: table_name, 81 | do_janitor_work: false, 82 | blocking: true, 83 | ttl_ms: ttl_ms 84 | ) 85 | 86 | resp = SampleCache.get(req, table_name: table_name) 87 | 88 | assert {:hit, 89 | {"response for [\"req-cache-test-2\", \"newest\"]", %{version: version} = metadata}} = 90 | resp 91 | 92 | # data is still fresh 93 | refute SampleCache.should_invalidate?(req, resp, metadata) 94 | 95 | # waiting `ttl_ms * 3` ms so that the data will become stale for sure 96 | :timer.sleep(ttl_ms * 3) 97 | 98 | resp = SampleCache.get(req, table_name: table_name) 99 | 100 | assert {:hit, 101 | {"response for [\"req-cache-test-2\", \"newest\"]", %{version: ^version} = metadata}} = 102 | resp 103 | 104 | # data should have become stale by now 105 | assert SampleCache.should_invalidate?(req, resp, metadata) 106 | 107 | # this fetch-and-store should trigger a refresh to the stale data 108 | resp = 109 | SampleCache.get_or_fetch( 110 | req, 111 | table_name: table_name, 112 | do_janitor_work: false, 113 | blocking: true, 114 | ttl_ms: ttl_ms 115 | ) 116 | 117 | assert {:ok, "response for [\"req-cache-test-2\", \"newest\"]"} = resp 118 | 119 | # asserting the stored metadata has been changed 120 | resp = SampleCache.get(req, table_name: table_name) 121 | 122 | assert {:hit, {"response for [\"req-cache-test-2\", \"newest\"]", %{version: new_version}}} = 123 | resp 124 | 125 | refute version == new_version 126 | end 127 | 128 | test "returns stale data and refreshes the cache in the background when `blocking=false` (which is the default setting)" do 129 | table_name = "table-prerender-cache-test-3" 130 | req = ["req-cache-test-3", "newest"] 131 | ttl_ms = 200 132 | 133 | assert {:miss, _reason} = SampleCache.get(req, table_name: table_name) 134 | 135 | # triggers fetch-and-store 136 | SampleCache.get_or_fetch( 137 | req, 138 | table_name: table_name, 139 | do_janitor_work: false, 140 | blocking: true, 141 | ttl_ms: ttl_ms 142 | ) 143 | 144 | resp = SampleCache.get(req, table_name: table_name) 145 | 146 | assert {:hit, 147 | {"response for [\"req-cache-test-3\", \"newest\"]", %{version: version} = metadata}} = 148 | resp 149 | 150 | # data is still fresh 151 | refute SampleCache.should_invalidate?(req, resp, metadata) 152 | 153 | # waiting `ttl_ms * 3` ms so that the data will become stale for sure 154 | :timer.sleep(ttl_ms * 3) 155 | 156 | resp = SampleCache.get(req, table_name: table_name) 157 | 158 | assert {:hit, 159 | {"response for [\"req-cache-test-3\", \"newest\"]", %{version: ^version} = metadata}} = 160 | resp 161 | 162 | # data should have become stale by now 163 | assert SampleCache.should_invalidate?(req, resp, metadata) 164 | 165 | # this fetch-and-store should trigger a refresh in the background 166 | resp = 167 | SampleCache.get_or_fetch( 168 | req, 169 | table_name: table_name, 170 | do_janitor_work: false, 171 | background: false, 172 | ttl_ms: ttl_ms 173 | ) 174 | 175 | assert {:ok, "response for [\"req-cache-test-3\", \"newest\"]"} = resp 176 | 177 | # asserting the stored metadata has been changed 178 | resp = SampleCache.get(req, table_name: table_name) 179 | 180 | assert {:hit, {"response for [\"req-cache-test-3\", \"newest\"]", %{version: new_version}}} = 181 | resp 182 | 183 | assert version == new_version 184 | end 185 | 186 | test "cache auto-invalidates expired data via a background janitor work" do 187 | table_name = "table-prerender-cache-test-4" 188 | req = ["req-cache-test-4", "newest"] 189 | ttl_ms = 200 190 | 191 | assert {:miss, _reason} = SampleCache.get(req, table_name: table_name) 192 | 193 | # triggers fetch-and-store 194 | SampleCache.get_or_fetch( 195 | req, 196 | table_name: table_name, 197 | do_janitor_work: true, 198 | blocking: true, 199 | ttl_ms: ttl_ms 200 | ) 201 | 202 | resp = SampleCache.get(req, table_name: table_name) 203 | 204 | assert {:hit, 205 | {"response for [\"req-cache-test-4\", \"newest\"]", %{version: _version} = metadata}} = 206 | resp 207 | 208 | # data is still fresh 209 | refute SampleCache.should_invalidate?(req, resp, metadata) 210 | 211 | :timer.sleep(ttl_ms * 3) 212 | 213 | resp = SampleCache.get(req, table_name: table_name) 214 | assert {:miss, _reason} = resp 215 | end 216 | 217 | test "cache skips janitor work when `do_janitor_work=false`" do 218 | table_name = "table-prerender-cache-test-5" 219 | req = ["req-cache-test-5", "newest"] 220 | ttl_ms = 200 221 | 222 | assert {:miss, _reason} = SampleCache.get(req, table_name: table_name) 223 | 224 | # triggers fetch-and-store 225 | SampleCache.get_or_fetch( 226 | req, 227 | table_name: table_name, 228 | do_janitor_work: false, 229 | blocking: true, 230 | ttl_ms: ttl_ms 231 | ) 232 | 233 | resp = SampleCache.get(req, table_name: table_name) 234 | 235 | assert {:hit, 236 | {"response for [\"req-cache-test-5\", \"newest\"]", %{version: _version} = metadata}} = 237 | resp 238 | 239 | # data is still fresh 240 | refute SampleCache.should_invalidate?(req, resp, metadata) 241 | 242 | # waiting `ttl_ms * 3` ms so that the data will become stale for sure 243 | :timer.sleep(ttl_ms * 3) 244 | 245 | resp = SampleCache.get(req, table_name: table_name) 246 | 247 | assert {:hit, 248 | {"response for [\"req-cache-test-5\", \"newest\"]", %{version: _version} = metadata}} = 249 | resp 250 | 251 | # data is stale 252 | assert SampleCache.should_invalidate?(req, resp, metadata) 253 | end 254 | 255 | test "doing a cache lookup and before returning, triggers a fetch in the background for the benefit of future requests" do 256 | table_name = "table-prerender-cache-test-6" 257 | req = ["req-cache-test-6", "newest"] 258 | 259 | assert {:miss, _reason} = 260 | SampleCache.get_and_trigger_async_fetch(req, table_name: table_name, ttl_ms: 4000) 261 | 262 | :timer.sleep(300) 263 | 264 | resp = SampleCache.get(req, table_name: table_name) 265 | 266 | assert {:hit, {"response for [\"req-cache-test-6\", \"newest\"]", %{version: _}}} = resp 267 | end 268 | end 269 | -------------------------------------------------------------------------------- /test/cache/stress_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GenSpoxy.Cache.StressTests do 2 | use ExUnit.Case, async: false 3 | 4 | alias GenSpoxy.Stores.Ets 5 | 6 | import Macros.Tests 7 | 8 | defprerender( 9 | Stress.SamplePrerender, 10 | do_req: fn req -> 11 | :timer.sleep(200) 12 | {:ok, "response for #{inspect(req)}"} 13 | end 14 | ) 15 | 16 | defmodule Stress.SampleCache do 17 | use GenSpoxy.Cache, prerender_module: Stress.SamplePrerender 18 | end 19 | 20 | setup_all do 21 | Stress.SamplePrerender.Supervisor.start_link() 22 | :ok 23 | end 24 | 25 | setup do 26 | Ets.reset_all!() 27 | :ok 28 | end 29 | 30 | test "multiple concurrent clients calling the same request" do 31 | n = 1000 32 | 33 | table_name = "table-prerender-cache-stress-1" 34 | req = ["req-prerender-cache-stress-1", "newest"] 35 | ttl_ms = 100 36 | 37 | assert {:miss, _reason} = Stress.SampleCache.get(req, table_name: table_name) 38 | 39 | opts = [table_name: table_name, do_janitor_work: false, blocking: true, ttl_ms: ttl_ms] 40 | 41 | active_task = Stress.SampleCache.async_get_or_fetch(req, opts) 42 | 43 | passive_tasks = 44 | Enum.map(2..n, fn _ -> 45 | Stress.SampleCache.async_get_or_fetch(req, opts) 46 | end) 47 | 48 | Enum.each(passive_tasks, fn task -> 49 | {:ok, "response for [\"req-prerender-cache-stress-1\", \"newest\"]", _bench} = 50 | Stress.SampleCache.await(task) 51 | end) 52 | 53 | {:ok, "response for [\"req-prerender-cache-stress-1\", \"newest\"]", _bench} = 54 | Stress.SampleCache.await(active_task) 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /test/periodic/tasks_executor_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GenSpoxy.Periodic.TasksExecutor.Tests do 2 | use ExUnit.Case, async: false 3 | 4 | import Macros.Tests 5 | 6 | alias GenSpoxy.Stores.Ets 7 | 8 | defprerender( 9 | Periodic.SamplePrerender, 10 | do_req: fn req -> 11 | :timer.sleep(300) 12 | {:ok, "response for #{inspect(req)}"} 13 | end 14 | ) 15 | 16 | defmodule Periodic.SampleCache do 17 | use GenSpoxy.Cache, 18 | store_module: Ets, 19 | prerender_module: Periodic.SamplePrerender, 20 | config: [total_partitions: 1, periodic_sampling_interval: 100] 21 | end 22 | 23 | # `SampleCache.TasksExecutor` is auto-generated by `Periodic.SampleCache` 24 | # when calling `use GenSpoxy.Cache 25 | alias __MODULE__.Periodic.SampleCache.TasksExecutor 26 | 27 | setup_all do 28 | Periodic.SamplePrerender.Supervisor.start_link() 29 | :ok 30 | end 31 | 32 | setup do 33 | Ets.reset_all!() 34 | :ok 35 | end 36 | 37 | test "executes the enqueued taks periodically" do 38 | req = ["periodic-test-1", "newest"] 39 | req_key = "req-1" 40 | opts = [table_name: "table-periodic-1", ttl_ms: 1000] 41 | 42 | partition = TasksExecutor.calc_req_partition(req_key) 43 | server_name = TasksExecutor.partition_server(partition) 44 | 45 | {:ok, _pid} = TasksExecutor.start_link(name: server_name) 46 | 47 | Enum.each(1..10, fn _ -> 48 | TasksExecutor.enqueue_task(req_key, [req, opts]) 49 | end) 50 | 51 | :timer.sleep(250) 52 | Periodic.SamplePrerender.inspect_all_partitions() 53 | %{total_listeners: 1, total_passive: 0} = Periodic.SamplePrerender.inspect_all_partitions() 54 | 55 | :timer.sleep(300) 56 | %{total_listeners: 0, total_passive: 0} = Periodic.SamplePrerender.inspect_all_partitions() 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/prerender/gen_prerender_sup_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GenSpoxy.Prerender.Supervisor.Tests do 2 | use ExUnit.Case, async: false 3 | 4 | import Macros.Tests 5 | 6 | defprerender(SupervisedPrerender, do_req: fn _ -> :ok end) 7 | 8 | setup_all do 9 | SupervisedPrerender.Supervisor.start_link() 10 | :ok 11 | end 12 | 13 | test "all children are of type `prerender_module`" do 14 | prerender_module = GenSpoxy.Prerender.Supervisor.Tests.SupervisedPrerender 15 | total_partitions = SupervisedPrerender.total_partitions() 16 | 17 | children = Supervisor.which_children(SupervisedPrerender.Supervisor) 18 | 19 | # asserting all childern are of `prerender_module` 20 | children 21 | |> Enum.with_index(0) 22 | |> Enum.each(fn {child, i} -> 23 | partition = total_partitions - i 24 | child_name = "#{prerender_module}-#{partition}" 25 | assert {^child_name, _worker_pid, :worker, [^prerender_module]} = child 26 | end) 27 | end 28 | 29 | test "auto-restarts terminated children" do 30 | prerender_module = GenSpoxy.Prerender.Supervisor.Tests.SupervisedPrerender 31 | 32 | children = Supervisor.which_children(SupervisedPrerender.Supervisor) 33 | 34 | {_, worker_pid, _, _} = 35 | Enum.find(children, fn child -> 36 | {child_name, _, :worker, [^prerender_module]} = child 37 | child_name == "#{prerender_module}-5" 38 | end) 39 | 40 | ref = Process.monitor(worker_pid) 41 | 42 | Process.exit(worker_pid, :kill) 43 | 44 | receive do 45 | {:DOWN, ^ref, :process, ^worker_pid, :killed} -> "" 46 | end 47 | 48 | children = Supervisor.which_children(SupervisedPrerender.Supervisor) 49 | 50 | {_, worker_pid_new, _, _} = 51 | Enum.find(children, fn child -> 52 | {child_name, _, :worker, [^prerender_module]} = child 53 | child_name == "#{prerender_module}-5" 54 | end) 55 | 56 | refute worker_pid == worker_pid_new 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/prerender/gen_prerender_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GenSpoxy.Prerender.Tests do 2 | use ExUnit.Case, async: false 3 | 4 | import ExUnit.CaptureLog 5 | import Macros.Tests 6 | 7 | defprerender(FastPrerender, do_req: fn req -> {:ok, "response for #{inspect(req)}"} end) 8 | 9 | defprerender( 10 | SlowPrerender, 11 | do_req: fn req -> 12 | :timer.sleep(SlowPrerender.sample_task_interval() + 50) 13 | {:ok, "response for #{inspect(req)}"} 14 | end 15 | ) 16 | 17 | defprerender(FrozenPrerender, do_req: fn _ -> :timer.sleep(:infinity) end) 18 | defprerender(FailingPrerender, do_req: fn _ -> {:error, "error occurred"} end) 19 | defprerender(RaisingErrorsPrerender, do_req: fn _ -> raise "oops..." end) 20 | 21 | test "fast-prerender, cleanup will take place after 1st sampling" do 22 | {:ok, pid} = FastPrerender.Supervisor.start_link() 23 | 24 | fun = fn -> 25 | req = ["req-fast-1", "newest"] 26 | resp = FastPrerender.perform(req) 27 | assert resp == {{:ok, "response for [\"req-fast-1\", \"newest\"]"}, :active} 28 | 29 | state = FastPrerender.get_req_state(req) 30 | 31 | # assert `reqs_state` is empty 32 | assert Map.get(state, :reqs_state) == %{} 33 | 34 | # cleanup hasn't been executed yet 35 | refute %{pid_ref: %{}, refs_resp: %{}, refs_req: %{}, reqs_state: %{}} == state 36 | 37 | # 1st sample takes place after `sample_task_interval`, so we'll wait a bit longer 38 | # to let the cleanup process execute for sure 39 | :timer.sleep(FastPrerender.sample_task_interval() + 50) 40 | 41 | # cleanup has been executed 42 | state = FastPrerender.get_req_state(req) 43 | 44 | assert %{pid_ref: %{}, refs_resp: %{}, refs_req: %{}, reqs_state: %{}} == state 45 | end 46 | 47 | assert capture_log(fun) =~ "1st sample task: performing cleanup" 48 | 49 | {:error, {:already_started, ^pid}} = FastPrerender.Supervisor.start_link() 50 | end 51 | 52 | test "slow prerender, cleanup will take place after the 2nd sample" do 53 | {:ok, pid} = SlowPrerender.Supervisor.start_link() 54 | 55 | fun = fn -> 56 | req = ["req-slow-1", "newest"] 57 | resp = SlowPrerender.perform(req) 58 | assert resp == {{:ok, "response for [\"req-slow-1\", \"newest\"]"}, :active} 59 | 60 | state = SlowPrerender.get_req_state(req) 61 | 62 | # assert `reqs_state` is empty 63 | assert Map.get(state, :reqs_state) == %{} 64 | 65 | :timer.sleep(SlowPrerender.sample_task_interval() + 50) 66 | 67 | # cleanup has been executed 68 | state = SlowPrerender.get_req_state(req) 69 | assert %{pid_ref: %{}, refs_resp: %{}, refs_req: %{}, reqs_state: %{}} == state 70 | end 71 | 72 | assert capture_log(fun) =~ "2nd sample task: performing cleanup" 73 | 74 | {:error, {:already_started, ^pid}} = SlowPrerender.Supervisor.start_link() 75 | end 76 | 77 | test "raising errors prerender, cleanup will take place after 1st sampling" do 78 | {:ok, pid} = RaisingErrorsPrerender.Supervisor.start_link() 79 | 80 | fun = fn -> 81 | req = ["req-raising-1", "newest"] 82 | assert {{:error, "error occurred"}, :active} == RaisingErrorsPrerender.perform(req) 83 | 84 | state = RaisingErrorsPrerender.get_req_state(req) 85 | 86 | # cleanup hasn been executed 87 | assert %{pid_ref: %{}, refs_resp: %{}, refs_req: %{}, reqs_state: %{}} == state 88 | end 89 | 90 | # silencing the error raised by `RaisingErrorsPrerender` 91 | capture_log(fun) 92 | 93 | {:error, {:already_started, ^pid}} = RaisingErrorsPrerender.Supervisor.start_link() 94 | end 95 | 96 | test "failing prerender, cleanup will take place after 1st sampling" do 97 | {:ok, pid} = FailingPrerender.Supervisor.start_link() 98 | 99 | fun = fn -> 100 | req = ["req-failing-1", "newest"] 101 | resp = FailingPrerender.perform(req) 102 | assert resp == {{:error, "error occurred"}, :active} 103 | 104 | state = FailingPrerender.get_req_state(req) 105 | 106 | # assert `reqs_state` is empty 107 | assert %{reqs_state: %{}} = state 108 | 109 | # cleanup hasn't been executed yet 110 | refute %{pid_ref: %{}, refs_resp: %{}, refs_req: %{}, reqs_state: %{}} == state 111 | 112 | # 1st sample takes place after `sample_task_interval`, so we'll wait a bit longer 113 | # to let the cleanup process execute 114 | :timer.sleep(FailingPrerender.sample_task_interval()) 115 | 116 | # cleanup has been executed 117 | state = FailingPrerender.get_req_state(req) 118 | assert %{pid_ref: %{}, refs_resp: %{}, refs_req: %{}, reqs_state: %{}} == state 119 | end 120 | 121 | assert capture_log(fun) =~ "1st sample task: performing cleanup" 122 | 123 | {:error, {:already_started, ^pid}} = FailingPrerender.Supervisor.start_link() 124 | end 125 | 126 | test "frozen prerender, cleanup will brutally kill the task" do 127 | {:ok, pid} = FrozenPrerender.Supervisor.start_link() 128 | 129 | fun = fn -> 130 | try do 131 | FrozenPrerender.perform(["req-frozen-1", "newest"]) 132 | catch 133 | :exit, {:timeout, _} -> "" 134 | end 135 | end 136 | 137 | assert capture_log(fun) =~ 138 | "2nd sample task: performing full cleanup ([delete_req_state: true, shutdown_task: true])" 139 | 140 | {:error, {:already_started, ^pid}} = FrozenPrerender.Supervisor.start_link() 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /test/prerender/stress_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GenSpoxy.Prerender.StressTests do 2 | use ExUnit.Case, async: false 3 | 4 | import Macros.Tests 5 | 6 | defprerender( 7 | StressedPrerender, 8 | do_req: fn req -> 9 | :timer.sleep(300) 10 | {:ok, "response for #{inspect(req)}"} 11 | end 12 | ) 13 | 14 | setup_all do 15 | StressedPrerender.Supervisor.start_link() 16 | :ok 17 | end 18 | 19 | test "multiple concurrent clients calling the same request" do 20 | n = 1000 21 | req = ["prerender-stress-1", "newest"] 22 | 23 | active_task = Task.async(fn -> StressedPrerender.perform(req) end) 24 | 25 | :timer.sleep(100) 26 | 27 | passive_tasks = 28 | Enum.map(2..n, fn _ -> 29 | Task.async(fn -> StressedPrerender.perform(req) end) 30 | end) 31 | 32 | Enum.each(passive_tasks, fn task -> 33 | {{:ok, "response for [\"prerender-stress-1\", \"newest\"]"}, :passive} = Task.await(task) 34 | end) 35 | 36 | {{:ok, "response for [\"prerender-stress-1\", \"newest\"]"}, :active} = 37 | Task.await(active_task) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /test/store/ets_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Spoxy.Stores.Ets.Tests do 2 | use ExUnit.Case, async: false 3 | 4 | alias GenSpoxy.Stores.Ets 5 | 6 | setup do 7 | Ets.reset_all!() 8 | :ok 9 | end 10 | 11 | test "returns 'nil' when data isn't in the cache" do 12 | lookup = Ets.lookup_req("req-ets-test-1", "key-1") 13 | 14 | assert is_nil(lookup) 15 | end 16 | 17 | test "returns cached data if exists" do 18 | table_name = "table-ets-test-2" 19 | 20 | entry = {["req-ets-test-2", "newest"], "key-2", "resp for req", %{etag: 10}} 21 | 22 | Ets.store_req!(table_name, entry, ttl_ms: 10) 23 | 24 | lookup = Ets.lookup_req(table_name, "key-2") 25 | assert {"resp for req", %{etag: 10, uuid: _uuid}} = lookup 26 | end 27 | 28 | test "data invalidation" do 29 | table_name = "table-ets-test-3" 30 | 31 | entry = {["req-ets-test-3", "newest"], "key-3", "resp for req", %{etag: 10}} 32 | 33 | Ets.store_req!(table_name, entry, ttl_ms: 10) 34 | 35 | lookup = Ets.lookup_req(table_name, "key-3") 36 | assert {"resp for req", %{etag: 10, uuid: _uuid}} = lookup 37 | 38 | Ets.invalidate!(table_name, "key-3") 39 | 40 | lookup = Ets.lookup_req(table_name, "key-3") 41 | assert is_nil(lookup) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | GenSpoxy.Stores.Ets.Supervisor.start_link() 2 | 3 | Code.load_file("test/test_macros.exs") 4 | ExUnit.configure(max_cases: 1) 5 | ExUnit.start() 6 | -------------------------------------------------------------------------------- /test/test_macros.exs: -------------------------------------------------------------------------------- 1 | defmodule Macros.Tests do 2 | defmacro defprerender(name, opts \\ [], do_req: do_req) do 3 | quote do 4 | defmodule unquote(name) do 5 | use GenSpoxy.Prerender, unquote(opts) 6 | 7 | @impl true 8 | def do_req(req) do 9 | unquote(do_req).(req) 10 | end 11 | 12 | @impl true 13 | def calc_req_key(req) do 14 | Enum.join(req, "-") 15 | end 16 | end 17 | end 18 | end 19 | end 20 | --------------------------------------------------------------------------------