Skip to content

Commit

Permalink
Abstract out provisioning across all component types
Browse files Browse the repository at this point in the history
  • Loading branch information
whitfin committed Mar 23, 2024
1 parent 63d1565 commit 5678cf3
Show file tree
Hide file tree
Showing 11 changed files with 181 additions and 72 deletions.
11 changes: 3 additions & 8 deletions lib/cachex/actions/warm.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@ defmodule Cachex.Actions.Warm do
# The only reason to call this command is the case in which you already
# know the backing state of your cache has been updated and you need to
# immediately refresh your warmed entries.
alias Cachex.Services

# we need our imports
import Cachex.Spec

##############
Expand All @@ -25,14 +22,12 @@ defmodule Cachex.Actions.Warm do
set of warmer modules. The list of modules which had a warming triggered will
be returned in the result of this call.
"""
def execute(cache() = cache, options) do
def execute(cache(warmers: warmers), options) do
mods = Keyword.get(options, :modules, nil)
parent = Services.locate(cache, Services.Incubator)
children = if parent, do: Supervisor.which_children(parent), else: []

handlers =
for {mod, pid, _, _} <- children, mods == nil or mod in mods do
send(pid, :cachex_warmer) && mod
for warmer(module: mod) <- warmers, mods == nil or mod in mods do
send(mod, :cachex_warmer) && mod
end

{:ok, handlers}
Expand Down
41 changes: 2 additions & 39 deletions lib/cachex/hook.ex
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,6 @@ defmodule Cachex.Hook do
"""
@callback async? :: boolean

@doc """
Returns an enumerable of provisions this hook requires.
The current provisions available to a hook are:
* `cache` - a cache instance used to make cache calls from inside a hook
with zero overhead.
This should always return an enumerable of atoms; in the case of no required
provisions an empty enumerable should be returned.
"""
@callback provisions :: [atom]

@doc """
Returns the timeout for all calls to this hook.
Expand All @@ -65,15 +52,6 @@ defmodule Cachex.Hook do
"""
@callback handle_notify(tuple, tuple, any) :: {:ok, any}

@doc """
Handles a provisioning call.
The provided argument will be a Tuple dictating the type of value being
provisioned along with the value itself. This can be used to listen on
states required for hook executions (such as cache records).
"""
@callback handle_provision({atom, any}, any) :: {:ok, any}

##################
# Implementation #
##################
Expand All @@ -86,6 +64,7 @@ defmodule Cachex.Hook do

# inherit server
use GenServer
use Cachex.Provision

@doc false
def init(args),
Expand All @@ -110,10 +89,6 @@ defmodule Cachex.Hook do
def async?,
do: true

@doc false
def provisions,
do: []

@doc false
def timeout,
do: nil
Expand All @@ -125,7 +100,6 @@ defmodule Cachex.Hook do
# config overrides
defoverridable actions: 0,
async?: 0,
provisions: 0,
timeout: 0,
type: 0

Expand All @@ -137,13 +111,8 @@ defmodule Cachex.Hook do
def handle_notify(event, result, state),
do: {:ok, state}

@doc false
def handle_provision(provisions, state),
do: {:ok, state}

# listener override
defoverridable handle_notify: 3,
handle_provision: 2
defoverridable handle_notify: 3

##########################
# Private Implementation #
Expand All @@ -155,12 +124,6 @@ defmodule Cachex.Hook do
{:noreply, new_state}
end

@doc false
def handle_info({:cachex_provision, provisions}, state) do
{:ok, new_state} = handle_provision(provisions, state)
{:noreply, new_state}
end

@doc false
def handle_info({:cachex_notify, {event, result}}, state) do
case timeout() do
Expand Down
2 changes: 1 addition & 1 deletion lib/cachex/policy/lrw/scheduled.ex
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ defmodule Cachex.Policy.LRW.Scheduled do
@doc false
# Receives a provisioned cache instance.
#
# The provided cache is then stored in the cache and used for cache calls going
# The provided cache is then stored in the state and used for cache calls going
# forwards, in order to skip the lookups inside the cache overseer for performance.
def handle_provision({:cache, cache}, {_cache, limit}),
do: {:ok, {cache, limit}}
Expand Down
79 changes: 79 additions & 0 deletions lib/cachex/provision.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
defmodule Cachex.Provision do
@moduledoc """
Module controlling provisioning behaviour definitions.
This module defines the provisioning implementation for Cachex, allowing
components such as hooks and warmers to tap into state changes in the cache
table. By implementing `handle_provision/2` these components can be provided
with new versions of state as they're created.
"""

#############
# Behaviour #
#############

@doc """
Returns an enumerable of provisions this implementation requires.
The current provisions available are:
* `cache` - a cache instance used to make cache calls with lower overhead.
This should always return an enumerable of atoms; in the case of no required
provisions an empty enumerable should be returned.
"""
@callback provisions :: [atom]

@doc """
Handles a provisioning call.
The provided argument will be a Tuple dictating the type of value being
provisioned along with the value itself. This can be used to listen on
states required for hook executions (such as cache records).
"""
@callback handle_provision({atom, any}, any) :: {:ok, any}

##################
# Implementation #
##################

@doc false
defmacro __using__(_) do
quote location: :keep do
# use the provision behaviour
@behaviour Cachex.Provision

#################
# Configuration #
#################

@doc false
def provisions,
do: []

# config overrides
defoverridable provisions: 0

#########################
# Notification Handlers #
#########################

@doc false
def handle_provision(provision, state),
do: {:ok, state}

# listener override
defoverridable handle_provision: 2

##########################
# Private Implementation #
##########################

@doc false
def handle_info({:cachex_provision, provision}, state) do
{:ok, new_state} = handle_provision(provision, state)
{:noreply, new_state}
end
end
end
end
2 changes: 1 addition & 1 deletion lib/cachex/services/incubator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,6 @@ defmodule Cachex.Services.Incubator do
defp spec(warmer(module: module) = warmer, cache),
do: %{
id: module,
start: {GenServer, :start_link, [module, {cache, warmer}]}
start: {GenServer, :start_link, [module, {cache, warmer}, [name: module]]}
}
end
17 changes: 2 additions & 15 deletions lib/cachex/services/overseer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ defmodule Cachex.Services.Overseer do

# add service aliases
alias Services.Overseer
alias Services.Steward

# constants for manager/table names
@manager_name :cachex_overseer_manager
Expand Down Expand Up @@ -135,13 +136,7 @@ defmodule Cachex.Services.Overseer do

register(name, nstate)

with hooks(pre: pre_hooks, post: post_hooks) <- cache(nstate, :hooks) do
pre_hooks
|> Enum.concat(post_hooks)
|> Enum.filter(&requires_state?/1)
|> Enum.map(&hook(&1, :name))
|> Enum.each(&send(&1, {:cachex_provision, {:cache, nstate}}))
end
Steward.provide(nstate, {:cache, nstate})

nstate
end)
Expand Down Expand Up @@ -180,12 +175,4 @@ defmodule Cachex.Services.Overseer do
end
end
end

###############
# Private API #
###############

# Verifies if a hook has a cache provisioned.
defp requires_state?(hook(module: module)),
do: :cache in module.provisions()
end
50 changes: 50 additions & 0 deletions lib/cachex/services/steward.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
defmodule Cachex.Services.Steward do
@moduledoc """
Service module overseeing cache provisions.
This module controls state provision to Cachex components, such as hooks
and warmers. In previous versions of Cachex provisions were handled under
the `Cachex.Hook` behaviour, but the introduction of warmers meant that it
should be handled in a separate location.
This service module will handle the provision of state to relevant components
attached to a cache, without the caller having to think about it.
"""
import Cachex.Spec

# recognised
@provisions [
:cache
]

##############
# Public API #
##############

@doc """
Provides an state pair to relevant components.
This will send updated state to all interest components, but does not
wait for a response before returning. As provisions are handled in a
base implementation, we can be sure of safe implementation here.
"""
@spec provide(Cachex.Spec.cache(), {atom, any}) :: :ok
def provide(cache() = cache, {key, _} = provision) when key in @provisions do
cache(hooks: hooks(pre: pre_hooks, post: post_hooks)) = cache
cache(warmers: warmers) = cache

hook_pairs =
pre_hooks
|> Enum.concat(post_hooks)
|> Enum.map(fn hook(module: mod, name: name) -> {name, mod} end)

warmer_pairs =
warmers
|> Enum.map(fn warmer(module: mod) -> {mod, mod} end)

warmer_pairs
|> Enum.concat(hook_pairs)
|> Enum.filter(fn {_, mod} -> key in mod.provisions() end)
|> Enum.each(fn {name, _} -> send(name, {:cachex_provision, provision}) end)
end
end
2 changes: 1 addition & 1 deletion lib/cachex/spec.ex
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ defmodule Cachex.Spec do
#############

# a list of accepted service suffixes for a cache instance
@services [:courier, :eternal, :janitor, :locksmith, :stats]
@services [:courier, :eternal, :janitor, :locksmith, :stats, :steward]

#############
# Typespecs #
Expand Down
18 changes: 17 additions & 1 deletion lib/cachex/warmer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -61,18 +61,26 @@ defmodule Cachex.Warmer do
defmacro __using__(_) do
quote location: :keep, generated: true do
use GenServer
use Cachex.Provision

import Cachex.Spec

# enforce the behaviour
@behaviour Cachex.Warmer

@doc """
Return the provisions warmers require.
"""
@spec provisions :: [atom]
def provisions,
do: [:cache]

@doc false
# Initializes the warmer from a provided state.
#
# Initialization will trigger an initial cache warming, and store
# the provided state for later to provide during further warming.
def init({cache(name: cache), warmer(state: state)}) do
def init({cache() = cache, warmer(state: state)}) do
{:ok, {cache, state, nil}}
end

Expand Down Expand Up @@ -120,6 +128,14 @@ defmodule Cachex.Warmer do
# pass the new state
{:noreply, new_state}
end

@doc false
# Receives a provisioned cache instance.
#
# The provided cache is then stored in the state and used for cache calls going
# forwards, in order to skip the lookups inside the cache overseer for performance.
def handle_provision({:cache, cache}, {_cache, state, timer}),
do: {:ok, {cache, state, timer}}
end
end
end
22 changes: 22 additions & 0 deletions test/cachex/services/steward_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
defmodule Cachex.Services.StewardTest do
use CachexCase

test "provisioning cache state" do
# bind our hook
ForwardHook.bind(
steward_forward_hook_provisions: [
provisions: [:cache]
]
)

# create our hook with the provisions forwarded through to it
hook = ForwardHook.create(:steward_forward_hook_provisions)

# start a new cache using our forwarded hook
cache = Helper.create_cache(hooks: [hook])
cache = Services.Overseer.retrieve(cache)

# the provisioned value should match
assert_receive({:cache, ^cache})
end
end
Loading

0 comments on commit 5678cf3

Please sign in to comment.