Current section

Files

Jump to
scenic lib scenic cache base.ex
Raw

lib/scenic/cache/base.ex

#
# Created by Boyd Multerer on 2019-03-04.
# Copyright © 2019 Kry10 Industries. All rights reserved.
#
defmodule Scenic.Cache.Base do
@moduledoc """
Base module for static asset caches.
This module is not intended to be used directly. To access items in the
static cache, refer to the modules for the type of data you are
interested in.
| Asset Class | Module |
| ------------- | -----|
| Fonts | `Scenic.Cache.Static.Font` |
| Font Metrics | `Scenic.Cache.Static.FontMetrics` |
| Textures (images in a fill) | `Scenic.Cache.Static.Texture` |
| Raw Pixel Maps | `Scenic.Cache.Dynamic.Texture` |
Some of the Cache support modules have moved
| Old Module | New Module |
| ------------- | -----|
| `Scenic.Cache.Hash` | `Scenic.Cache.Support.Hash` |
| `Scenic.Cache.File` | `Scenic.Cache.Support.File` |
| `Scenic.Cache.Supervisor` | `Scenic.Cache.Support.Supervisor` |
## Overview
Static assets such as fonts, images and more tend to be relatively large compared to
other data. These assets are often used across multiple scenes and may need to be shared
with multiple drivers.
These assets also tend to have a significant load cost. Fonts need to be rendered. Images
interpreted into their final binary form, etc.
## Goals
Given this situation, the Cache module has multiple goals.
* __Reuse__ - assets used by multiple scenes should only be stored in memory once
* __Load Time__- loading cost should only be paid once
* __Copy time__ - assets are stored in ETS, so they don't need to be copied as they are used
* __Pub/Sub__ - Consumers of static assets (drivers...) should be notified when an asset is
loaded or changed. They should not poll the system.
* __Security__ - Static assets can become an attack vector. Helper modules are provided
to assist in verifying these files.
## Scope
When an asset is loaded into the cache, it is assigned a scope. The scope is used to
determine how long to hold the asset in memory before it is unloaded. Scope is either
the atom `:global`, or a `pid`.
The typical flow is that a scene will load an asset into the cache. A scope is automatically
defined that tracks the asset against the pid of the scene that loaded it. When the scene
is closed, the scope becomes empty and the asset is unloaded.
If, while that scene is loaded, another scene (or any process...) attempts to load
the same asset into the cache, a second scope is added and the duplicate load is
skipped. When the first scene closes, the asset stays in memory as long as the second
scope remains valid.
When a scene closes, it's scope stays valid for a short time in order to give the next
scene a chance to load its assets (or claim a scope) and possibly re-use the already
loaded assets.
This is also useful in the event of a scene crashing and being restarted. The delay
in unloading the scope means that the replacement scene will use already loaded
assets instead of loading the same files again for no real benefit.
When you load assets you can alternately provide your own scope instead of taking the
default, which is your processes pid. If you provide `:global`, then the asset will
stay in memory until you explicitly release it.
## Keys
At its simplest, accessing the cache is a key-value store. When inserting assets
via the main Cache module, you can supply any term you want as the key. However,
in most cases this is not recommended.
The key for an item in the cache should almost always be a SHA hash of the item itself.
Why? Read below...
The main exception is dynamic assets, such as video frames coming from a camera.
## Security
A lesson learned the hard way is that static assets (fonts, images, etc.) that your app
loads out of storage can easily become attack vectors.
These formats are complicated! There is no guarantee (on any system) that a malformed
asset will not cause an error in the C code that interprets it. Again - these are complicated
and the renderers need to be fast...
The solution is to compute a SHA hash of these files during build-time of your
and to store the result in your applications code itself. Then during run time, you
compare then pre-computed hash against the run-time of the asset being loaded.
Please take advantage of the helper modules [`Cache.File`](Scenic.Cache.File.html),
[`Cache.Term`](Scenic.Cache.Term.html), and [`Cache.Hash`](Scenic.Cache.Hash.html) to
do this for you. These modules load files and insert them into the cache while checking
a precomputed hash.
These scheme is much stronger when the application code itself is also signed and
verified, but that is an exercise for the packaging tools.
Full Example:
defmodule MyApp.MyScene do
use Scenic.Scene
import Scenic.Primitives
# build the path to the static asset file (compile time)
@asset_path :code.priv_dir(:my_app)
|> Path.join("/static/images/asset.jpg")
# pre-compute the hash (compile time)
@asset_hash Scenic.Cache.Hash.file!( @asset_path, :sha )
# build a graph that uses the asset (compile time)
@graph Scenic.Graph.build()
|> rect( {100, 100}, fill: {:image, @asset_hash} )
def init( _, _ ) do
# load the asset into the cache (run time)
Scenic.Cache.Static.Texture.load(@asset_path, @asset_hash)
{:ok, :some_state, push: @graph}
end
end
When assets are loaded this way, the `@asset_hash` term is also used as the key in
the cache. This has the additional benefit of allowing you to pre-compute
the graph itself, using the correct keys for the correct assets.
## Pub/Sub
Drivers (or any process...) listen to the Cache via a simple pub/sub api.
Because the graph, may be computed during compile time and pushed at some
other time than the assets are loaded, the drivers need to know when the assets
become available.
Whenever any asset is loaded into the cache, messages are sent to any
subscribing processes along with the affected keys. This allows them to react in a
loosely-coupled way to how the assets are managed in your scene.
"""
use GenServer
# import IEx
@type hash :: String.t()
@type sub_types ::
:put
| :delete
| :all
@default_release_delay 400
# ===========================================================================
defmodule Error do
@moduledoc """
Defines the exception thrown by the CacheModule
"""
defexception message: nil
end
# ============================================================================
# callback definitions
@callback load(file_path :: String.t(), hash_name :: String.t(), options :: list) ::
{:ok, data :: any()} | {:error, error :: atom}
@callback load!(file_path :: String.t(), hash_name :: String.t(), options :: list) ::
data :: any()
# ============================================================================
# using macro
# ===========================================================================
# the using macro for scenes adopting this behavior
defmacro __using__(using_opts \\ []) do
quote do
if unquote(using_opts)[:static] do
@behaviour Scenic.Cache.Base
end
unless unquote(using_opts)[:name] do
raise "You must supply a :name option to the \"use Scenic.Cache.Base\" macro."
end
# --------------------------------------------------------
@doc """
Retrieve a #{unquote(using_opts)[:name]} from the Cache.
If there is no item in the Cache that corresponds to the hash the function will return
either nil or the supplied default value
## Examples
iex> #{__MODULE__}.get("missing_hash")
nil
...> #{__MODULE__}.fetch("valid_hash")
{:ok, :test_data}
"""
@spec get(hash :: Scenic.Cache.Base.hash(), default :: term()) :: term() | nil
def get(hash, default \\ nil) do
Scenic.Cache.Base.get(__MODULE__, hash, default)
end
# --------------------------------------------------------
@doc """
Retrieve a #{unquote(using_opts)[:name]} from the cache and wrap it in an `{:ok, _}` tuple.
This function ideal if you need to pattern match on the result.
## Examples
iex> #{__MODULE__}.fetch("missing_hash")
...> {:error, :not_found}
iex> #{__MODULE__}.fetch("valid_hash")
...> {:ok, :test_data}
"""
@spec fetch(hash :: Scenic.Cache.Base.hash()) :: term() | {:error, :not_found}
def fetch(hash) do
Scenic.Cache.Base.fetch(__MODULE__, hash)
end
# --------------------------------------------------------
@doc """
Retrieve a #{unquote(using_opts)[:name]} from the Cache and raise an error if it doesn't exist.
If there is no item in the Cache that corresponds to the hash the function will raise an error.
"""
@spec get!(hash :: Scenic.Cache.Base.hash()) :: term()
def get!(hash) do
Scenic.Cache.Base.get!(__MODULE__, hash)
end
# put is not to be inserted into static-item caches
unless unquote(using_opts)[:static] do
# --------------------------------------------------------
@doc """
Insert #{unquote(using_opts)[:name]} into the Cache.
If the #{unquote(using_opts)[:name]} is already in the cache, its data
is overwritten.
Parameters:
* `hash` - term to use as the retrieval key. Typically a hash of the data itself.
It will be required to be a hash of the data in the future.
* `data` - term to use as the stored data
* `scope` - Optional scope to track the lifetime of this asset against. Can be `:global`
but is usually nil, which defaults to the pid of the calling process.
Returns: `{:ok, hash}`
"""
@spec put(
hash :: Scenic.Cache.Base.hash(),
data :: term(),
scope :: :global | nil | GenServer.server()
) :: term()
def put(hash, data, scope \\ nil) do
Scenic.Cache.Base.put(__MODULE__, hash, data, scope)
end
end
# --------------------------------------------------------
@doc """
Insert a new #{unquote(using_opts)[:name]} into the Cache.
If the #{unquote(using_opts)[:name]} is already in the cache, put_new
does nothing and just returns {:ok, hash}
Parameters:
* `hash` - term to use as the retrieval key. Typically a hash of the data itself.
It will be required to be a hash of the data in the future.
* `data` - term to use as the stored data
* `scope` - Optional scope to track the lifetime of this asset against. Can be `:global`
but is usually nil, which defaults to the pid of the calling process.
Returns: `{:ok, hash}`
"""
@spec put_new(
hash :: Scenic.Cache.Base.hash(),
data :: term(),
scope :: :global | nil | GenServer.server()
) :: term()
case unquote(using_opts)[:static] do
true ->
def put_new(hash, data, scope \\ nil) do
case member?(hash) do
false -> Scenic.Cache.Base.put(__MODULE__, hash, data, scope)
true -> {:ok, hash}
end
end
false ->
def put_new(hash, data, scope \\ nil) do
case member?(hash) do
false -> put(hash, data, scope)
true -> {:ok, hash}
end
end
end
# --------------------------------------------------------
@doc """
Add a scope to an existing #{unquote(using_opts)[:name]} in the cache.
Claiming an asset in the cache adds a lifetime scope to it. This is essentially a
refcount that is bound to a pid.
Returns `true` if the item is loaded and the scope is added.
Returns `false` if the asset is not loaded into the cache.
"""
@spec claim(
hash :: Scenic.Cache.Base.hash(),
scope :: :global | nil | GenServer.server()
) :: term()
def claim(hash, scope \\ nil) do
Scenic.Cache.Base.claim(__MODULE__, hash, scope)
end
# --------------------------------------------------------
@doc """
Release a scope claim on an #{unquote(using_opts)[:name]}.
Usually the scope is released automatically when a process shuts down. However if you
want to manually clean up, or unload an asset with the :global scope, then you should
use release.
Parameters:
* `key` - the key to release.
* `options` - options list
Options:
* `scope` - set to `:global` to release the global scope.
* `delay` - add a delay of n milliseconds before releasing. This allows starting
processes a chance to claim a scope before it is unloaded.
"""
# returns :ok
@spec release(
hash :: Scenic.Cache.Base.hash(),
opts :: list
) :: :ok
def release(hash, opts \\ []) do
Scenic.Cache.Base.release(__MODULE__, hash, opts)
end
# --------------------------------------------------------
@doc """
Get the current status of a #{unquote(using_opts)[:name]} in the cache.
This is used to test if the current process has claimed a scope on an asset.
"""
@spec status(
hash :: Scenic.Cache.Base.hash(),
scope :: :global | nil | GenServer.server()
) :: :ok
def status(hash, scope \\ nil) do
Scenic.Cache.Base.status(__MODULE__, hash, scope)
end
# --------------------------------------------------------
@doc """
Returns a list of keys claimed by the given scope.
"""
@spec keys(scope :: :global | nil | GenServer.server()) :: list
def keys(scope \\ nil) do
Scenic.Cache.Base.keys(__MODULE__, scope)
end
# --------------------------------------------------------
@doc """
Tests if a key is claimed by *any* scope.
"""
@spec member?(hash :: Scenic.Cache.Base.hash()) :: true | false
def member?(hash) do
Scenic.Cache.Base.member?(__MODULE__, hash)
end
# --------------------------------------------------------
@doc """
Tests if a key is claimed by the given scope.
"""
@spec claimed?(
hash :: Scenic.Cache.Base.hash(),
scope :: :global | nil | GenServer.server()
) :: true | false
def claimed?(hash, scope \\ nil) do
Scenic.Cache.Base.claimed?(__MODULE__, hash, scope)
end
# ----------------------------------------------
@doc """
Subscribe the calling process to cache messages.
Parameters
* `hash` - The hash key of the asset you want to listen to messages about. Pass
in :all for messages about all keys
* `sub_type` - Pass in the type of messages you want to unsubscribe from.
* `:put` - sent when assets are put into the cache
* `:delete` - sent when assets are fully unloaded from the cache
* `:claim` - sent when a scope is claimed
* `:release` - sent when a scope is released
* `:all` - all of the above message types
"""
@spec subscribe(
hash :: Scenic.Cache.Base.hash() | :all,
sub_type :: Scenic.Cache.Base.sub_types()
) :: :ok
def subscribe(hash, sub_type \\ :all) do
Scenic.Cache.Base.subscribe(__MODULE__, hash, sub_type)
end
# ----------------------------------------------
@doc """
Unsubscribe the calling process from cache messages.
Parameters
* `hash` - The hash key of the asset you want to listen to messages about. Pass
in :all for messages about all keys
* `sub_type` - Pass in the type of messages you want to unsubscribe from.
* `:put` - sent when assets are put into the cache
* `:delete` - sent when assets are fully unloaded from the cache
* `:claim` - sent when a scope is claimed
* `:release` - sent when a scope is released
* `:all` - all of the above message types
"""
@spec unsubscribe(
hash :: Scenic.Cache.Base.hash() | :all,
sub_type :: Scenic.Cache.Base.sub_types()
) :: :ok
def unsubscribe(hash, sub_type \\ :all) do
Scenic.Cache.Base.unsubscribe(__MODULE__, hash, sub_type)
end
# --------------------------------------------------------
# child spec that really starts up the cache
@doc false
def child_spec(_) do
%{
# make_ref(),
id: __MODULE__,
start: {Scenic.Cache.Base, :start_link, [__MODULE__, unquote(using_opts)[:name]]},
type: :worker,
restart: :permanent,
shutdown: 500
}
end
# --------------------------------------------------------
# add local shortcuts to things like get/put graph and modify element
# do not add a put element. keep it at modify to stay atomic
# --------------------------------------------------------
case unquote(using_opts)[:static] do
true -> [get: 1, get: 2, get!: 1, fetch: 1, put_new: 3]
_ -> [get: 1, get: 2, get!: 1, fetch: 1, put_new: 3, put: 3]
end
|> defoverridable()
end
end
# ============================================================================
# client API
# --------------------------------------------------------
@doc """
Retrieve an item from the Cache.
If there is no item in the Cache that corresponds to the hash the function will return
either nil or the supplied default value
"""
@spec get(
service :: atom,
hash :: Scenic.Cache.Base.hash(),
default :: term()
) :: term() | nil
def get(service, hash, default \\ nil) do
:ets.lookup_element(service, hash, 2)
rescue
ArgumentError ->
default
other ->
reraise(other, __STACKTRACE__)
end
# --------------------------------------------------------
@doc """
Retrieve an item from the cache and wrap it in an `{:ok, _}` tuple.
This function ideal if you need to pattern match on the result.
"""
@spec fetch(
service :: atom,
hash :: Scenic.Cache.Base.hash()
) :: term() | {:error, :not_found}
def fetch(service, hash)
def fetch(service, hash) do
{:ok, :ets.lookup_element(service, hash, 2)}
rescue
ArgumentError ->
{:error, :not_found}
other ->
reraise(other, __STACKTRACE__)
end
# --------------------------------------------------------
@doc """
Retrieve an item from the Cache and raise an error if it doesn't exist.
If there is no item in the Cache that corresponds to the hash the function will raise an error.
"""
@spec get!(
service :: atom,
hash :: Scenic.Cache.Base.hash()
) :: term()
def get!(service, hash)
def get!(service, hash) do
:ets.lookup_element(service, hash, 2)
rescue
ArgumentError ->
reraise(Error, [message: "Hash #{inspect(hash)} not found."], __STACKTRACE__)
other ->
reraise(other, __STACKTRACE__)
end
# --------------------------------------------------------
@doc """
Insert an item into the Cache. If it is already in the cache, then it
overwrites the data {:ok, hash}
Parameters:
* `key` - term to use as the retrieval key. Typically a hash of the data itself.
* `data` - term to use as the stored data
* `scope` - Optional scope to track the lifetime of this asset against. Can be `:global`
but is usually nil, which defaults to the pid of the calling process.
## Examples
iex> Scenic.Cache.get("test_key")
nil
iex> :ets.insert(:scenic_cache_key_table, {"test_key", 1, :test_data})
...> true
...> Scenic.Cache.get("test_key")
:test_data
"""
@spec put(
service :: atom,
hash :: Scenic.Cache.Base.hash(),
data :: term(),
scope :: :global | nil | GenServer.server()
) :: term()
def put(service, key, data, scope \\ nil)
when service != nil and (is_atom(service) or is_pid(service)) do
GenServer.call(service, {:put, normalize_scope(scope), key, data})
end
# --------------------------------------------------------
@doc """
Add a scope to an existing asset in the cache.
Claiming an asset in the cache adds a lifetime scope to it. This is essentially a
refcount that is bound to a pid.
Returns `true` if the item is loaded and the scope is added.
Returns `false` if the asset is not loaded into the cache.
"""
@spec claim(
service :: atom,
hash :: Scenic.Cache.Base.hash(),
scope :: :global | nil | GenServer.server()
) :: {:ok, Scenic.Cache.Base.hash()}
def claim(service, key, scope \\ nil) do
case :ets.member(service, key) do
true ->
GenServer.call(service, {:claim, normalize_scope(scope), key})
false ->
{:error, :not_found}
end
end
# --------------------------------------------------------
@doc """
Release a scope claim on an asset.
Usually the scope is released automatically when a process shuts down. However if you
want to manually clean up, or unload an asset with the :global scope, then you should
use release.
Parameters:
* `key` - the key to release.
* `options` - options list
Options:
* `scope` - set to `:global` to release the global scope.
* `delay` - add a delay of n milliseconds before releasing. This allows starting
processes a chance to claim a scope before it is unloaded.
"""
# returns `:ok`
@spec release(
service :: atom,
hash :: Scenic.Cache.Base.hash(),
opts :: list
) :: :ok
def release(service, hash, opts \\ []) do
case :ets.member(service, hash) do
true ->
scope = normalize_scope(opts[:scope])
msg = {:release, scope, hash}
case opts[:delay] do
nil ->
Process.send_after(service, msg, @default_release_delay)
0 ->
Process.send(service, msg, [])
delay when is_integer(delay) and delay >= 0 ->
Process.send_after(service, msg, delay)
end
:ok
false ->
{:error, :not_found}
end
end
# --------------------------------------------------------
@doc """
Get the current status of an asset in the cache.
This is used to test if the current process has claimed a scope on an asset.
Pass in the service, hash, and a scope.
Returns one of:
```elixir
{:ok, hash} # it is claimed by the given scope
{:ok, :global} # it is NOT claimed by the given scope, but is :global
{:error, :not_found} # it is not in the cache at all
```
"""
@spec status(
service :: atom,
hash :: Scenic.Cache.Base.hash(),
scope :: :global | nil | GenServer.server()
) :: :ok
def status(service, hash, scope \\ nil) do
case :ets.member(service, hash) do
true ->
GenServer.call(service, {:status, normalize_scope(scope), hash})
false ->
{:error, :not_found}
end
end
# --------------------------------------------------------
@doc """
Returns a list of asset keys claimed by the given scope.
Pass in the service and a scope.
Returns a list of claimed keys.
"""
@spec keys(
service :: atom,
scope :: :global | nil | GenServer.server()
) :: list
def keys(service, scope \\ nil) do
GenServer.call(service, {:keys, normalize_scope(scope)})
end
@doc """
Tests if a key is claimed by *any* scope.
Pass in the service and a hash.
Returns `true` or `false`.
"""
@spec member?(
service :: atom,
hash :: Scenic.Cache.Base.hash()
) :: true | false
def member?(service, key) do
:ets.member(service, key)
end
@doc """
Tests if a key is claimed by the scope.
Pass in the service, hash, and scope.
Returns `true` or `false`.
"""
@spec claimed?(
service :: atom,
hash :: Scenic.Cache.Base.hash(),
scope :: :global | nil | GenServer.server()
) :: true | false
def claimed?(service, key, scope \\ nil) do
case :ets.member(service, key) do
true ->
GenServer.call(service, {:claimed?, normalize_scope(scope), key})
false ->
{:error, :not_found}
end
end
# ----------------------------------------------
@doc """
Subscribe the calling process to cache messages.
Parameters
* `sub_type` - Pass in the type of messages you want to unsubscribe from.
* `:put` - sent when assets are put into the cache
* `:delete` - sent when assets are fully unloaded from the cache
* `:claim` - sent when a scope is claimed
* `:release` - sent when a scope is released
* `:all` - all of the above message types
* `hash` - The hash key of the asset you want to listen to messages about. Pass
in :all for messages about all keys
"""
@spec subscribe(
service :: atom,
hash :: hash() | :all,
sub_type :: sub_types()
) :: :ok
def subscribe(service, hash, sub_type \\ :all)
# explicit calls to enforce incoming types
def subscribe(service, hash, :all), do: do_subscribe(service, hash, :all)
def subscribe(service, hash, :put), do: do_subscribe(service, hash, :put)
def subscribe(service, hash, :delete), do: do_subscribe(service, hash, :delete)
def subscribe(service, hash, :cache_put), do: deprecated_sub(service, hash, :cache_put, :put)
def subscribe(service, hash, :cache_delete),
do: deprecated_sub(service, hash, :cache_delete, :delete)
defp do_subscribe(service, key, type) do
GenServer.cast(service, {:subscribe, type, key, self()})
end
defp deprecated_sub(service, hash, old, new) do
IO.ANSI.yellow() <>
"""
Cache subscription type #{inspect(old)} is deprecated
Please use #{inspect(new)} instead
""" <>
IO.ANSI.default_color()
do_subscribe(service, hash, new)
end
# ----------------------------------------------
@doc """
Unsubscribe the calling process from cache messages.
Parameters
* `sub_type` - Pass in the type of messages you want to unsubscribe from.
* `:put` - sent when assets are put into the cache
* `:delete` - sent when assets are fully unloaded from the cache
* `:claim` - sent when a scope is claimed
* `:release` - sent when a scope is released
* `:all` - all of the above message types
* `hash` - The hash key of the asset you want to listen to messages about. Pass
in :all for messages about all keys
"""
@spec unsubscribe(
service :: atom,
hash :: hash() | :all,
sub_type :: sub_types()
) :: :ok
def unsubscribe(service, sub_type, hash \\ :all)
# explicit calls to enforce incoming types
def unsubscribe(service, hash, :all), do: do_unsubscribe(service, hash, :all)
def unsubscribe(service, hash, :put), do: do_unsubscribe(service, hash, :put)
def unsubscribe(service, hash, :delete), do: do_unsubscribe(service, hash, :delete)
def unsubscribe(service, hash, :cache_put),
do: deprecated_unsub(service, hash, :cache_put, :put)
def unsubscribe(service, hash, :cache_delete),
do: deprecated_unsub(service, hash, :cache_delete, :delete)
defp do_unsubscribe(service, key, type) do
GenServer.cast(service, {:unsubscribe, type, key, self()})
end
defp deprecated_unsub(service, hash, old, new) do
IO.ANSI.yellow() <>
"""
Cache subscription type #{inspect(old)} is deprecated
Please use #{inspect(new)} instead
""" <>
IO.ANSI.default_color()
do_unsubscribe(service, hash, new)
end
# ============================================================================
# --------------------------------------------------------
@doc false
def start_link(module, friendly_name) do
GenServer.start_link(__MODULE__, {module, friendly_name}, name: module)
end
# --------------------------------------------------------
@doc false
def init({module, friendly_name}) do
state = %{
table: :ets.new(module, [:set, :protected, :named_table]),
module: module,
name: friendly_name,
scopes: %{},
claims: %{},
subs: %{}
}
{:ok, state}
end
# --------------------------------------------------------
@doc false
def handle_info({:DOWN, _, :process, scope_pid, _}, %{scopes: scopes} = state) do
# a scope process we are monitoring just went down. Clean up after it.
Map.get(scopes, scope_pid, [])
|> Enum.each(
&Process.send_after(
self(),
{:release, scope_pid, &1},
@default_release_delay
)
)
{:noreply, state}
end
# --------------------------------------------------------
def handle_info({:release, scope, key}, state) do
{:noreply, internal_release(scope, key, state)}
end
# ============================================================================
@doc false
# --------------------------------------------------------
def handle_call({:put, scope, key, data}, _, %{table: table} = state) do
unless :ets.member(table, key) do
# monitor the scope
monitor_scope(scope)
end
# update the item
:ets.insert(table, {key, data})
state = internal_claim(scope, key, state)
dispatch_notification(:put, key, state)
{:reply, {:ok, key}, state}
end
# # --------------------------------------------------------
def handle_call({:put_new, scope, key, data}, _, %{table: table} = state) do
# Check if the key already exists. If so, overwrite the data, if not insert it.
case :ets.member(table, key) do
true ->
# already there. only need to claim it
{:reply, {:ok, key}, internal_claim(scope, key, state)}
false ->
:ets.insert(table, {key, data})
# monitor the scope
monitor_scope(scope)
# dispatch a put message
dispatch_notification(:put, key, state)
state = internal_claim(scope, key, state)
{:reply, {:ok, key}, state}
end
end
# --------------------------------------------------------
def handle_call({:claim, scope, key}, _, state) do
state = internal_claim(scope, key, state)
{:reply, :ok, state}
end
# --------------------------------------------------------
def handle_call({:status, scope, key}, _, %{table: table, scopes: scopes} = state) do
reply =
case :ets.member(table, key) do
true -> internal_status(scope, key, scopes)
false -> {:error, :not_found}
end
{:reply, reply, state}
end
# --------------------------------------------------------
def handle_call({:keys, scope}, _, %{scopes: scopes} = state) do
{:reply, Map.get(scopes, scope, []), state}
end
# --------------------------------------------------------
def handle_call({:claimed?, scope, key}, _, %{scopes: scopes} = state) do
reply = Map.get(scopes, scope, []) |> Enum.member?(key)
{:reply, reply, state}
end
# --------------------------------------------------------
if Scenic.mix_env() == :test do
def handle_call(:reset, _, %{table: table} = state) do
:ets.delete_all_objects(table)
state =
state
|> Map.put(:scopes, %{})
|> Map.put(:claims, %{})
|> Map.put(:subs, %{})
{:reply, :ok, state}
end
end
# ============================================================================
@doc false
# --------------------------------------------------------
def handle_cast({:subscribe, type, target, pid}, state) do
{:noreply, internal_subscribe(state, type, target, pid)}
end
# --------------------------------------------------------
def handle_cast({:unsubscribe, type, target, pid}, state) do
{:noreply, internal_unsubscribe(state, type, target, pid)}
end
# ============================================================================
# private helpers
# --------------------------------------------------------
defp normalize_scope(scope)
defp normalize_scope(nil), do: self()
defp normalize_scope(:global), do: :global
defp normalize_scope(name) when is_atom(name), do: Process.whereis(name)
defp normalize_scope(pid) when is_pid(pid), do: pid
# --------------------------------------------------------
defp monitor_scope(:global), do: :ok
defp monitor_scope(scope_pid), do: Process.monitor(scope_pid)
# --------------------------------------------------------
defp internal_claim(scope, key, %{table: table, scopes: scopes, claims: claims} = state) do
scope_keys = Map.get(scopes, scope, [])
key_scopes = Map.get(claims, key, [])
with true <- :ets.member(table, key),
false <- Enum.member?(key_scopes, scope) do
scopes = Map.put(scopes, scope, Enum.uniq([key | scope_keys]))
claims = Map.put(claims, key, Enum.uniq([scope | key_scopes]))
%{state | scopes: scopes, claims: claims}
else
_ -> state
end
end
# --------------------------------------------------------
defp internal_release(scope, key, %{table: table, scopes: scopes, claims: claims} = state) do
scope_keys = Map.get(scopes, scope, [])
key_scopes = Map.get(claims, key, [])
# first, cleanup the scope tracking
scopes =
case Enum.member?(scope_keys, key) do
false ->
scopes
true ->
case Enum.reject(scope_keys, &Kernel.==(&1, key)) do
[] -> Map.delete(scopes, scope)
keys -> Map.put(scopes, scope, keys)
end
end
# second, clean up the claim tracking
# if claims go to zero, delete the row in the table
claims =
case Enum.member?(scope_keys, key) do
false ->
claims
true ->
case Enum.reject(key_scopes, &Kernel.==(&1, scope)) do
[] ->
dispatch_notification(:delete, key, state)
:ets.delete(table, key)
Map.delete(claims, key)
ks ->
Map.put(claims, key, ks)
end
end
%{state | scopes: scopes, claims: claims}
end
# --------------------------------------------------------
defp internal_status(:global, key, scopes) do
scopes
|> Map.get(:global, [])
|> Enum.member?(key)
|> case do
true -> {:ok, :global}
false -> {:error, :not_claimed}
end
end
defp internal_status(scope, key, scopes) do
scopes
|> Map.get(scope, [])
|> Enum.member?(key)
|> case do
true -> {:ok, scope}
false -> internal_status(:global, key, scopes)
end
end
# ============================================================================
# subscriptions
# @deprecated "Use Cache.unsubscribe/1 instead"
# ----------------------------------------------
defp internal_subscribe(state, :all, target, pid) do
state
|> internal_subscribe(:put, target, pid)
|> internal_subscribe(:delete, target, pid)
end
defp internal_subscribe(%{subs: subs} = state, type, target, pid) do
targets = Map.get(subs, type, %{})
subscribers =
Map.get(targets, target, [])
|> List.insert_at(0, pid)
|> Enum.uniq()
targets = Map.put(targets, target, subscribers)
subs = Map.put(subs, type, targets)
%{state | subs: subs}
end
# ----------------------------------------------
defp internal_unsubscribe(state, :all, target, pid) do
state
|> internal_unsubscribe(:put, target, pid)
|> internal_unsubscribe(:delete, target, pid)
end
defp internal_unsubscribe(%{subs: subs} = state, type, target, pid) do
targets = Map.get(subs, type, %{})
subscribers =
Map.get(targets, target, [])
|> Enum.reject(&Kernel.==(&1, pid))
targets =
case subscribers do
[] -> Map.delete(targets, target)
s -> Map.put(targets, target, s)
end
subs = Map.put(subs, type, targets)
%{state | subs: subs}
end
# ----------------------------------------------
defp dispatch_notification(type, target, %{subs: subs, module: module}) do
type_map = Map.get(subs, type, %{})
subs =
[Map.get(type_map, target, []) | Map.get(type_map, :all, [])]
|> List.flatten()
|> Enum.uniq()
for pid <- subs do
try do
GenServer.cast(pid, {module, type, target})
catch
kind, reason ->
formatted = Exception.format(kind, reason, System.stacktrace())
IO.puts("dispatch_notification/3 failed with #{formatted}")
end
end
:ok
end
end