Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add an initial implementation of set_many/3 #161

Merged
merged 1 commit into from
Jan 18, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions lib/cachex.ex
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ defmodule Cachex do
refresh: [ 2, 3 ],
reset: [ 1, 2 ],
set: [ 3, 4 ],
set_many: [ 2, 3 ],
size: [ 1, 2 ],
stats: [ 1, 2 ],
stream: [ 1, 2 ],
Expand Down Expand Up @@ -1071,6 +1072,41 @@ defmodule Cachex do
end
end

@doc """
Places a batch of entries in a cache.

This operates in the same way as `set/4`, except that multiple keys can be
inserted in a single atomic batch. This is a performance gain over writing
keys using multiple calls to `set/4`, however it's a performance penalty
when writing a single key pair due to some batching overhead.

## Options

* `:ttl`

</br>
An expiration time to set for the provided keys (time-to-line), overriding
any default expirations set on a cache. This value should be in milliseconds.

## Examples

iex> Cachex.set_many(:my_cache, [ { "key", "value" } ])
{ :ok, true }

iex> Cachex.set_many(:my_cache, [ { "key", "value" } ], ttl: :timer.seconds(5))
iex> Cachex.ttl(:my_cache, "key")
{ :ok, 5000 }

"""
# TODO: maybe rename TTL to be expiration?
@spec set_many(cache, [ { any, any } ], Keyword.t) :: { status, boolean }
def set_many(cache, pairs, options \\ [])
when is_list(pairs) and is_list(options) do
Overseer.enforce(cache) do
Actions.SetMany.execute(cache, pairs, options)
end
end

@doc """
Retrieves the total size of a cache.

Expand Down
62 changes: 62 additions & 0 deletions lib/cachex/actions/set_many.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
defmodule Cachex.Actions.SetMany do
@moduledoc """
Command module to enable batch insertion of cache entries.

This is an alternative entry point for adding new entries to the cache,
specifically in the case of multiple entries at the same time. Performance
is enhanced in this use case, but lowered in the case of single entries.

This command will use lock aware contexts to ensure that there are no key
clashes when writing values to the cache.
"""
alias Cachex.Actions
alias Cachex.Services.Locksmith
alias Cachex.Util

# add our macros
import Cachex.Actions
import Cachex.Errors
import Cachex.Spec

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

@doc """
Inserts a batch of values into the cache.

This takes expiration times into account before insertion and will operate
inside a lock aware context to avoid clashing with other processes.
"""
defaction set_many(cache() = cache, pairs, options) do
ttlval = Util.get_opt(options, :ttl, &is_integer/1)
expiry = Util.get_expiration(cache, ttlval)

with { :ok, keys, entries } <- map_entries(expiry, pairs, [], []) do
Locksmith.write(cache, keys, fn ->
Actions.write(cache, entries)
end)
end
end

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

# Generates keys/entries from the provided list of pairs.
#
# Pairs must be Tuples of two, a key and a value. The keys will be
# buffered into a list to be used to handle locking, whilst entries
# will also be buffered into a batch of writes.
#
# If an unexpected pair is hit, an error will be returned and no
# values will be written to the backing table.
defp map_entries(ttl, [ { key, value } | pairs ], keys, entries) do
entry = entry_now(key: key, ttl: ttl, value: value)
map_entries(ttl, pairs, [ key | keys ], [ entry | entries ])
end
defp map_entries(_ttl, [], keys, entries),
do: { :ok, keys, entries }
defp map_entries(_ttl, _inv, _keys, _entries),
do: error(:invalid_pairs)
end
12 changes: 7 additions & 5 deletions lib/cachex/errors.ex
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ defmodule Cachex.Errors do
than bloating blocks with potentially large error messages.
"""
@known_errors [
:invalid_command, :invalid_expiration, :invalid_fallback,
:invalid_hook, :invalid_limit, :invalid_match,
:invalid_name, :invalid_option, :janitor_disabled,
:no_cache, :non_numeric_value, :not_started,
:stats_disabled, :unreachable_file
:invalid_command, :invalid_expiration, :invalid_fallback,
:invalid_hook, :invalid_limit, :invalid_match,
:invalid_name, :invalid_option, :invalid_pairs,
:janitor_disabled, :no_cache, :non_numeric_value,
:not_started, :stats_disabled, :unreachable_file
]

##########
Expand Down Expand Up @@ -71,6 +71,8 @@ defmodule Cachex.Errors do
do: "Invalid cache name provided"
def long_form(:invalid_option),
do: "Invalid option syntax provided"
def long_form(:invalid_pairs),
do: "Invalid insertion pairs provided"
def long_form(:janitor_disabled),
do: "Specified janitor process running"
def long_form(:no_cache),
Expand Down
38 changes: 25 additions & 13 deletions lib/cachex/stats.ex
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ defmodule Cachex.Stats do
#
# This is done by passing off to `register_action/2` internally as we use
# multiple function head definitions to easily separate action logic.
def handle_notify({ action, _options }, result, stats) do
def handle_notify(action, result, stats) do
stats
|> register_action(action, result)
|> increment(:global, :opCount, 1)
Expand All @@ -101,7 +101,7 @@ defmodule Cachex.Stats do
#
# A clear call returns the number of entries removed, so this will update both
# the total number of cleared entries as well as the global eviction count.
defp register_action(stats, :clear, { _status, value }) do
defp register_action(stats, { :clear, _args }, { _status, value }) do
stats
|> increment(:clear, :total, value)
|> increment(:global, :evictionCount, value)
Expand All @@ -111,7 +111,7 @@ defmodule Cachex.Stats do
#
# Deleting a cache entry should increment the delete count
# and also the global eviction count by 1.
defp register_action(stats, :del, { _status, value }) do
defp register_action(stats, { :del, _args }, { _status, value }) do
tmp = increment(stats, :del, value, 1)
case value do
true -> increment(tmp, :global, :evictionCount, 1)
Expand All @@ -124,7 +124,7 @@ defmodule Cachex.Stats do
# This needs to increment the global hit/miss count based on the value
# boolean coming back. It will also increment the value key under the
# `:exists?` action namespace in the statistics container.
defp register_action(stats, :exists?, { _status, value }) do
defp register_action(stats, { :exists?, _args }, { _status, value }) do
stats
|> increment(:exists?, value, 1)
|> increment(:global, value && :hitCount || :missCount, 1)
Expand All @@ -134,7 +134,7 @@ defmodule Cachex.Stats do
#
# A purge call returns the number of entries removed, so this will update both
# the total number of purged entries as well as the global expired count.
defp register_action(stats, :purge, { _status, value }) do
defp register_action(stats, { :purge, _args }, { _status, value }) do
stats
|> increment(:purge, :total, value)
|> increment(:global, :expiredCount, value)
Expand All @@ -145,20 +145,32 @@ defmodule Cachex.Stats do
# Set calls will increment the result of the call in the `:set`
# namespace inside the statistics container. It will also
# increment the global entry set count.
defp register_action(stats, :set, { _status, value }) do
defp register_action(stats, { :set, _args }, { _status, value }) do
tmp = increment(stats, :set, value, 1)
case value do
true -> increment(tmp, :global, :setCount, 1)
false -> tmp
end
end

# Handles registration of `set_many()` command calls.
#
# This is the same as the `set()` handler except that it
# will count the number of pairs being processed.
defp register_action(stats, { :set_many, [ pairs | _ ] }, { _status, value }) do
tmp = increment(stats, :set_many, value, 1)
case value do
true -> increment(tmp, :global, :setCount, length(pairs))
false -> tmp
end
end

# Handles registration of `take()` command calls.
#
# Take calls are a little complicated because they need to increment the
# global eviction count (due to removal) but also increment the global
# hit/miss count, in addition to the status in the `:take` namespace.
defp register_action(stats, :take, { status, _value }) do
defp register_action(stats, { :take, _args }, { status, _value }) do
tmp =
stats
|> increment(:take, status, 1)
Expand All @@ -174,7 +186,7 @@ defmodule Cachex.Stats do
#
# This will increment the status in the `:ttl` namespace as well
# as incrementing the global hit/miss count for the cache.
defp register_action(stats, :ttl, { status, _value }) do
defp register_action(stats, { :ttl, _args }, { status, _value }) do
stats
|> increment(:ttl, status, 1)
|> increment(:global, status == :ok && :hitCount || :missCount, 1)
Expand All @@ -184,7 +196,7 @@ defmodule Cachex.Stats do
#
# This will increment the global update count as well as the value
# inside the `:update` namespace, to represent an update hit.
defp register_action(stats, :update, { _status, value }) do
defp register_action(stats, { :update, _args }, { _status, value }) do
tmp = increment(stats, :update, value, 1)
case value do
true -> increment(tmp, :global, :updateCount, 1)
Expand All @@ -196,7 +208,7 @@ defmodule Cachex.Stats do
#
# This needs to increment the status in the global container, in addition to adding
# the status to the namespace of the provided action (either `:get` or `:fetch`).
defp register_action(stats, action, { status, _value })
defp register_action(stats, { action, _args }, { status, _value })
when action in [ :get, :fetch ] do
stats
|> increment(action, status, 1)
Expand All @@ -208,7 +220,7 @@ defmodule Cachex.Stats do
# Both of these calls operate in the same way, just negative/positive. We use the
# status to determine if a new value was inserted or if it was updated. Aside from
# this we just increment the status in the action namespace, as always.
defp register_action(stats, action, { status, _value })
defp register_action(stats, { action, _args }, { status, _value })
when action in [ :decr, :incr ] do
stats
|> increment(action, status, 1)
Expand All @@ -219,7 +231,7 @@ defmodule Cachex.Stats do
#
# This is a common set of updates which changes the global update count alongside the
# received value in the action namespace, as all of these actions are related and shared.
defp register_action(stats, action, { _status, value })
defp register_action(stats, { action, _args }, { _status, value })
when action in [ :expire, :expire_at, :persist, :refresh ] do
tmp = increment(stats, action, value, 1)
case value do
Expand All @@ -231,7 +243,7 @@ defmodule Cachex.Stats do
# Handles the registration of any other calls.
#
# This purely increments the action call by 1.
defp register_action(stats, action, _result),
defp register_action(stats, { action, _args }, _result),
do: increment(stats, action, :calls, 1)

# Increments a given set of statistics in the stats container.
Expand Down
106 changes: 106 additions & 0 deletions test/cachex/actions/set_many_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
defmodule Cachex.Actions.SetManyTest do
use CachexCase

# This test verifies the addition of many new entries to a cache. It will
# ensure that values can be added and can be expired as necessary. These
# test cases operate in the same way as the `set()` tests, jsut using the
# batch insertion method for a cache instead of the default insert.
test "adding many new values to the cache" do
# create a forwarding hook
hook = ForwardHook.create()

# create a test cache
cache1 = Helper.create_cache([ hooks: [ hook ] ])

# create a test cache with a default ttl
cache2 = Helper.create_cache([ hooks: [ hook ], expiration: expiration(default: 10000) ])

# set some values in the cache
set1 = Cachex.set_many(cache1, [ { 1, 1 }, { 2, 2 } ])
set2 = Cachex.set_many(cache1, [ { 3, 3 }, { 4, 4 } ], ttl: 5000)
set3 = Cachex.set_many(cache2, [ { 1, 1 }, { 2, 2 } ])
set4 = Cachex.set_many(cache2, [ { 3, 3 }, { 4, 4 } ], ttl: 5000)

# ensure all set actions worked
assert(set1 == { :ok, true })
assert(set2 == { :ok, true })
assert(set3 == { :ok, true })
assert(set4 == { :ok, true })

# verify the hooks were updated with the message
assert_receive({ { :set_many, [ [ { 1, 1 }, { 2, 2 } ], [] ] }, ^set1 })
assert_receive({ { :set_many, [ [ { 1, 1 }, { 2, 2 } ], [] ] }, ^set3 })
assert_receive({ { :set_many, [ [ { 3, 3 }, { 4, 4 } ], [ ttl: 5000 ] ] }, ^set2 })
assert_receive({ { :set_many, [ [ { 3, 3 }, { 4, 4 } ], [ ttl: 5000 ] ] }, ^set4 })

# read back all values from the cache
value1 = Cachex.get(cache1, 1)
value2 = Cachex.get(cache1, 2)
value3 = Cachex.get(cache1, 3)
value4 = Cachex.get(cache1, 4)
value5 = Cachex.get(cache2, 1)
value6 = Cachex.get(cache2, 2)
value7 = Cachex.get(cache2, 3)
value8 = Cachex.get(cache2, 4)

# verify all values exist
assert(value1 == { :ok, 1 })
assert(value2 == { :ok, 2 })
assert(value3 == { :ok, 3 })
assert(value4 == { :ok, 4 })
assert(value5 == { :ok, 1 })
assert(value6 == { :ok, 2 })
assert(value7 == { :ok, 3 })
assert(value8 == { :ok, 4 })

# read back all key TTLs
ttl1 = Cachex.ttl!(cache1, 1)
ttl2 = Cachex.ttl!(cache1, 2)
ttl3 = Cachex.ttl!(cache1, 3)
ttl4 = Cachex.ttl!(cache1, 4)
ttl5 = Cachex.ttl!(cache2, 1)
ttl6 = Cachex.ttl!(cache2, 2)
ttl7 = Cachex.ttl!(cache2, 3)
ttl8 = Cachex.ttl!(cache2, 4)

# the first two should have no TTL
assert(ttl1 == nil)
assert(ttl2 == nil)

# the second two should have a TTL around 5s
assert_in_delta(ttl3, 5000, 10)
assert_in_delta(ttl4, 5000, 10)

# the third two should have a TTL around 10s
assert_in_delta(ttl5, 10000, 10)
assert_in_delta(ttl6, 10000, 10)

# the last two should have a TTL around 5s
assert_in_delta(ttl7, 5000, 10)
assert_in_delta(ttl8, 5000, 10)
end

# Since we have a hard requirement on the format of a batch, we
# need a quick test to ensure that everything is rejected as
# necessary if they do not match the correct pair format.
test "handling invalid pairs in a batch" do
# create a forwarding hook
hook = ForwardHook.create()

# create a test cache
cache = Helper.create_cache([ hooks: [ hook ] ])

# try set some values in the cache
set1 = Cachex.set_many(cache, [ { 1, 1 }, "key" ])
set2 = Cachex.set_many(cache, [ { 1, 1 }, { 2, 2, 2} ])

# ensure all set actions failed
assert(set1 == error(:invalid_pairs))
assert(set2 == error(:invalid_pairs))

# try without a list of pairs
assert_raise(FunctionClauseError, fn ->
Cachex.set_many(cache, { 1, 1 })
end)
end
end
1 change: 1 addition & 0 deletions test/cachex/errors_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ defmodule Cachex.ErrorsTest do
invalid_match: "Invalid match specification provided",
invalid_name: "Invalid cache name provided",
invalid_option: "Invalid option syntax provided",
invalid_pairs: "Invalid insertion pairs provided",
janitor_disabled: "Specified janitor process running",
no_cache: "Specified cache not running",
non_numeric_value: "Attempted arithmetic operations on a non-numeric value",
Expand Down
Loading