defmodule Cachex.Services.Locksmith do
@moduledoc """
Locking service in charge of table transactions.
This module acts as a global lock table against all cache. This is due to the
fact that ETS tables are fairly expensive to construct if they're only going
to store a few keys.
Due to this we have a single global table in charge of locks, and we tag just
the key in the table with the name of the cache it's associated with. This
keyspace will typically be very small, so there should be almost no impact to
operating in this way (except that we only have a single ETS table rather than
a potentially large N).
It should be noted that the behaviour in this module could easily live as a
GenServer if it weren't for the speedup gained when using ETS. When using an
ETS table, checking for a lock is typically 0.3-0.5µs/op whereas a call to a
server process is roughly 10x this (due to the process interactions).
"""
alias Cachex.Services.Locksmith.Queue
# we need records
import Cachex.Spec
# our global lock table name
@table_name :cachex_locksmith
@doc """
Starts the backing services required by the Locksmith.
At this point this will start the backing ETS table required by the locking
logic inside the Locksmith. This is started with concurrency enabled and
logging disabled to avoid spamming log output.
This may become configurable in future, but this table will likelyn never
cause issues in the first place (as it only handles very basic operations).
"""
@spec start_link :: GenServer.on_start()
def start_link do
Eternal.start_link(
@table_name,
[read_concurrency: true, write_concurrency: true],
quiet: true
)
end
@doc """
Locks a number of keys for a cache.
This function can handle multiple keys to lock together atomically. The
returned boolean will signal if the lock was successful. A lock can fail
if one of the provided keys is already locked.
"""
@spec lock(Spec.cache(), [any]) :: boolean
def lock(cache(name: name), keys) do
t_proc = self()
writes =
keys
|> List.wrap()
|> Enum.map(&{{name, &1}, t_proc})
:ets.insert_new(@table_name, writes)
end
@doc """
Retrieves a list of locked keys for a cache.
This uses some ETS matching voodoo to pull back the locked keys. They
won't be returned in any specific order, so don't rely on it.
"""
@spec locked(Spec.cache()) :: [any]
def locked(cache(name: name)),
do: :ets.select(@table_name, [{{{name, :"$1"}, :_}, [], [:"$1"]}])
@doc """
Determines if a key is able to be written to by the current process.
For a key to be writeable, it must either have no lock or be locked by the
calling process.
"""
@spec locked?(Spec.cache(), [any]) :: true | false
def locked?(cache(name: name), keys) when is_list(keys) do
Enum.any?(keys, fn key ->
case :ets.lookup(@table_name, {name, key}) do
[{_key, proc}] ->
proc != self()
_else ->
false
end
end)
end
@doc """
Executes a transaction against a cache table.
If the process is already in a transactional context, the provided function
will be executed immediately. Otherwise the required keys will be locked until
the provided function has finished executing.
This is mainly shorthand to avoid having to handle row locking explicitly.
"""
@spec transaction(Spec.cache(), [any], (() -> any)) :: any
def transaction(cache() = cache, keys, fun) when is_list(keys) do
case transaction?() do
true -> fun.()
false -> Queue.transaction(cache, keys, fun)
end
end
@doc """
Determines if the current process is in transactional context.
"""
@spec transaction? :: boolean
def transaction?,
do: Process.get(:cachex_transaction, false)
@doc """
Flags this process as running in a transaction.
"""
@spec start_transaction :: no_return
def start_transaction,
do: Process.put(:cachex_transaction, true)
@doc """
Flags this process as not running in a transaction.
"""
@spec stop_transaction :: no_return
def stop_transaction,
do: Process.put(:cachex_transaction, false)
@doc """
Unlocks a number of keys for a cache.
There's currently no way to batch delete items in ETS beyond a select_delete,
so we have to simply iterate over the locks and remove them sequentially. This
is a little less desirable, but needs must.
"""
# TODO: figure out how to remove atomically
@spec unlock(Spec.cache(), [any]) :: true
def unlock(cache(name: name), keys) do
keys
|> List.wrap()
|> Enum.all?(&:ets.delete(@table_name, {name, &1}))
end
@doc """
Performs a write against the given key inside the table.
If the key is locked, the write is queued inside the lock server
to ensure that we execute consistently.
This is a little hard to explain, but if the cache has not had any
transactions executed against it we skip the lock check as any of
our ETS writes are atomic and so do not require a lock.
"""
@spec write(Spec.cache(), any, (() -> any)) :: any
def write(cache(transactional: false), _keys, fun),
do: fun.()
def write(cache() = cache, keys, fun) do
case transaction?() or !locked?(cache, keys) do
true -> fun.()
false -> Queue.execute(cache, fun)
end
end
end