Current section

Files

Jump to
erlfdb src erlfdb_directory_cache.erl
Raw

src/erlfdb_directory_cache.erl

% Licensed under the Apache License, Version 2.0 (the "License"); you may not
% use this file except in compliance with the License. You may obtain a copy of
% the License at
%
% http://www.apache.org/licenses/LICENSE-2.0
%
% Unless required by applicable law or agreed to in writing, software
% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
% License for the specific language governing permissions and limitations under
% the License.
-module(erlfdb_directory_cache).
-export([
new/0,
new/1,
new/2,
open/3,
open/4,
create_or_open/3,
create_or_open/4,
store/2,
store/3,
remove/3,
remove/4,
remove_if_exists/3,
remove_if_exists/4,
invalidate/2,
invalidate/3,
purge/0,
purge/1,
purge/2
]).
-define(DOCATTRS, ?OTP_RELEASE >= 27).
-if(?DOCATTRS).
-moduledoc """
An ETS-backed cache for `m:erlfdb_directory` nodes.
Opening a directory requires `1 + 2N` sequential round trips to FDB for a
path of depth N. This cache eliminates that cost for repeated opens of the
same path by storing the resolved directory node in an ETS table after the
first lookup.
## Usage
Create one cache at application startup and pass it alongside your
`erlfdb_directory` root wherever directories are opened:
```erlang
% Cache is linked to calling process (ETS)
_ = erlfdb_directory_cache:new(),
Root = erlfdb_directory:root(),
Dir = erlfdb_directory_cache:create_or_open(Db, Root, [<<"customers">>, CustomerId]),
erlfdb_directory:pack(Dir, {some_field})
```
## Cache invalidation
Directory node names (and therefore subspace prefixes) are immutable once
created - they do not change when data is written, across restarts, or across
reconnects. Invalidation is only needed when a directory is explicitly moved
or removed, and should be performed co-located with that operation:
```erlang
erlfdb_directory:remove(Db, Root, Path),
erlfdb_directory_cache:invalidate(Root, Path)
```
Cache invalidation can be safely executed within an `erlfdb:transactional`
callback because it is idempotent. `ets:delete/2` takes effect immediately and
is not rolled back if the surrounding transaction retries - on each retry,
invalidation simply runs again, and deleting an already-absent key is a no-op.
A spurious eviction from a retried or aborted transaction carries no consistency
penalty: the cache is never a source of truth, only a read-through shortcut, so
any evicted entry is transparently re-populated from FDB on the next access.
## Transaction semantics
Passing a `Db` handle to `open/3,4` or `create_or_open/3,4` is always safe:
the directory operation runs in its own self-contained FDB transaction, and the
cache is populated only after that transaction successfully commits.
Passing a `Tx` handle (inside an outer `erlfdb:transactional` callback) is safe
for `open` and for `create_or_open` when the directory already exists, because a
stable directory's node name does not change between retries. It is **unsafe**
for `create_or_open` when the directory does not yet exist: the High Contention
Allocator assigns a new node name on each transaction attempt, so if the outer
transaction retries, the cache will hold a node name from an attempt that was
never committed.
For that case, perform the directory operation directly and populate the cache
explicitly after the outer transaction commits using `store/2,3`:
```erlang
Node = erlfdb:transactional(Db, fun(Tx) ->
Dir = erlfdb_directory:create_or_open(Tx, Root, Path),
%% ... other transactional work ...
Dir
end),
erlfdb_directory_cache:store(Root, Node)
```
## Concurrency
All operations are safe to call from multiple processes simultaneously. Cache
misses use `ets:insert_new/2` so that only the first writer's result is kept;
concurrent openers for the same path discard their result and return the
winning entry.
""".
-endif.
-if(?DOCATTRS).
-doc """
Creates a new ETS-backed directory cache using `erlfdb_directory_cache` as
the table name. Equivalent to `new(erlfdb_directory_cache, [])`.
""".
-endif.
-spec new() -> ets:tid().
new() ->
new(?MODULE, []).
-if(?DOCATTRS).
-doc """
Creates a new ETS-backed directory cache registered publicly with the given name.
Equivalent to `new(Name, [])`.
""".
-endif.
-spec new(atom()) -> ets:tid().
new(Name) ->
new(Name, []).
-if(?DOCATTRS).
-doc """
Creates a new ETS-backed directory cache registered publicly with the given name.
The table is created as a named, public set with `read_concurrency` enabled so
that concurrent lookups are cheap. The returned value is the ETS table
identifier and can be passed to `open/4`, `create_or_open/4`, `store/3`, and
`invalidate/3`.
""".
-endif.
-spec new(atom(), [term()]) -> ets:tid().
new(Name, _Options) ->
ets:new(Name, [
named_table,
set,
public,
{read_concurrency, true}
]).
-if(?DOCATTRS).
-doc """
Opens a directory at `Path` under `Root`, using the default cache table.
Equivalent to `open(erlfdb_directory_cache, TxObj, Root, Path)`.
""".
-endif.
-spec open(erlfdb:tx_object(), erlfdb_directory:t(), erlfdb_directory:path()) ->
erlfdb_directory:t().
open(TxObj, Root, Path) ->
open(?MODULE, TxObj, Root, Path).
-if(?DOCATTRS).
-doc """
Opens an existing directory at `Path` under `Root`, returning the cached node
if already known.
On a cache miss the directory is resolved from FDB via `erlfdb_directory:open/3`
and the result is stored in `Table`. Raises `{erlfdb_directory, {open_error,
path_missing, Path}}` if the directory does not exist; errors are not cached.
""".
-endif.
-spec open(ets:table(), erlfdb:tx_object(), erlfdb_directory:t(), erlfdb_directory:path()) ->
erlfdb_directory:t().
open(Table, TxObj, Root, Path) ->
open_with_cache(Table, TxObj, Root, Path, fun erlfdb_directory:open/3).
-if(?DOCATTRS).
-doc """
Opens or creates a directory at `Path` under `Root`, using the default cache
table. Equivalent to `create_or_open(erlfdb_directory_cache, TxObj, Root, Path)`.
""".
-endif.
-spec create_or_open(erlfdb:tx_object(), erlfdb_directory:t(), erlfdb_directory:path()) ->
erlfdb_directory:t().
create_or_open(TxObj, Root, Path) ->
create_or_open(?MODULE, TxObj, Root, Path).
-if(?DOCATTRS).
-doc """
Opens an existing directory at `Path` under `Root`, or creates it if absent,
returning the cached node if already known.
On a cache miss the directory is resolved (and created if necessary) via
`erlfdb_directory:create_or_open/3` and the result is stored in `Table`.
""".
-endif.
-spec create_or_open(
ets:table(), erlfdb:tx_object(), erlfdb_directory:t(), erlfdb_directory:path()
) -> erlfdb_directory:t().
create_or_open(Table, TxObj, Root, Path) ->
open_with_cache(Table, TxObj, Root, Path, fun erlfdb_directory:create_or_open/3).
-if(?DOCATTRS).
-doc """
Stores `Node` in the default cache table under `Root`.
Equivalent to `store(erlfdb_directory_cache, Root, Node)`.
""".
-endif.
-spec store(erlfdb_directory:t(), erlfdb_directory:t()) -> true.
store(Root, Node) ->
store(?MODULE, Root, Node).
-if(?DOCATTRS).
-doc """
Stores a directory `Node` directly in `Table`, bypassing the FDB lookup.
Use this when a directory has been opened or created inside an outer
`erlfdb:transactional` callback that may retry — call `store/2,3` with the
committed node after the transaction returns, rather than letting
`create_or_open/3,4` populate the cache mid-transaction. See the
*Transaction semantics* section in the module documentation.
Unlike the cache miss path in `open/4` and `create_or_open/4`, this function
uses `ets:insert/2` rather than `ets:insert_new/2`, so it overwrites any
previously cached entry for the same path.
""".
-endif.
-spec store(ets:table(), erlfdb_directory:t(), erlfdb_directory:t()) -> true.
store(Table, Root, Node) ->
RootPathLen = length(erlfdb_directory:get_path(Root)),
AbsPath = erlfdb_directory:get_path(Node),
RelPath = lists:nthtail(RootPathLen, AbsPath),
ets:insert(Table, {path_key(Root, RelPath), Node, erlang:monotonic_time(millisecond)}).
-if(?DOCATTRS).
-doc """
Removes the directory at `Path` under `Root` from FDB and invalidates its
cache entry in the default table.
Equivalent to `remove(erlfdb_directory_cache, TxObj, Root, Path)`.
""".
-endif.
-spec remove(erlfdb:tx_object(), erlfdb_directory:t(), erlfdb_directory:path()) -> ok.
remove(TxObj, Root, Path) ->
remove(?MODULE, TxObj, Root, Path).
-if(?DOCATTRS).
-doc """
Removes the directory at `Path` under `Root` from FDB and invalidates its
cache entry in `Table`.
The cache entry is invalidated before the FDB operation so that a concurrent
reader that races the removal sees a miss rather than a stale entry. The
invalidation is idempotent — if the surrounding transaction retries, the
delete simply runs again with no ill effect.
""".
-endif.
-spec remove(ets:table(), erlfdb:tx_object(), erlfdb_directory:t(), erlfdb_directory:path()) -> ok.
remove(Table, TxObj, Root, Path) ->
invalidate(Table, Root, Path),
erlfdb_directory:remove(TxObj, Root, Path).
-if(?DOCATTRS).
-doc """
Removes the directory at `Path` under `Root` from FDB (if it exists) and
invalidates its cache entry in the default table.
Equivalent to `remove_if_exists(erlfdb_directory_cache, TxObj, Root, Path)`.
""".
-endif.
-spec remove_if_exists(erlfdb:tx_object(), erlfdb_directory:t(), erlfdb_directory:path()) -> ok.
remove_if_exists(TxObj, Root, Path) ->
remove_if_exists(?MODULE, TxObj, Root, Path).
-if(?DOCATTRS).
-doc """
Removes the directory at `Path` under `Root` from FDB if it exists, and
invalidates its cache entry in `Table`. Unlike `remove/4`, this does not
raise an error when the directory is absent.
""".
-endif.
-spec remove_if_exists(
ets:table(), erlfdb:tx_object(), erlfdb_directory:t(), erlfdb_directory:path()
) -> ok.
remove_if_exists(Table, TxObj, Root, Path) ->
invalidate(Table, Root, Path),
erlfdb_directory:remove_if_exists(TxObj, Root, Path).
-if(?DOCATTRS).
-doc """
Removes the entry for `Path` under `Root` from the default cache table.
Equivalent to `invalidate(erlfdb_directory_cache, Root, Path)`.
""".
-endif.
-spec invalidate(erlfdb_directory:t(), erlfdb_directory:path()) -> true.
invalidate(Root, Path) ->
invalidate(?MODULE, Root, Path).
-if(?DOCATTRS).
-doc """
Removes the entry for `Path` under `Root` from `Table` so that the next
`open/4` or `create_or_open/4` call re-resolves the directory from FDB.
Call this co-located with any `erlfdb_directory:move/4` or
`erlfdb_directory:remove/3` on the same path.
""".
-endif.
-spec invalidate(ets:table(), erlfdb_directory:t(), erlfdb_directory:path()) -> true.
invalidate(Table, Root, Path) ->
ets:delete(Table, path_key(Root, Path)).
-if(?DOCATTRS).
-doc """
Deletes all entries from the default cache table.
Equivalent to `purge(erlfdb_directory_cache, -1)`.
""".
-endif.
-spec purge() -> non_neg_integer().
purge() ->
purge(?MODULE, -1).
-if(?DOCATTRS).
-doc """
Deletes all entries from `Table`.
Equivalent to `purge(Table, -1)`.
""".
-endif.
-spec purge(ets:table()) -> non_neg_integer().
purge(Table) ->
purge(Table, -1).
-if(?DOCATTRS).
-doc """
Deletes entries from `Table` that were cached more than `Ttl` milliseconds ago.
A `Ttl` of `-1` matches all entries regardless of age, making `purge(Table, -1)`
equivalent to clearing the entire table.
Periodic purging can be scheduled with `timer:apply_interval(Ttl, erlfdb_directory_cache, purge, [Table, Ttl])`.
""".
-endif.
-spec purge(ets:table(), integer()) -> non_neg_integer().
purge(Table, Ttl) ->
Cutoff = erlang:monotonic_time(millisecond) - Ttl,
ets:select_delete(Table, [{{'_', '_', '$1'}, [{'<', '$1', Cutoff}], [true]}]).
-spec path_key(erlfdb_directory:t(), erlfdb_directory:path()) ->
{binary(), erlfdb_directory:path()}.
path_key(Node, Path) ->
% See docs for `erlfdb_directory:adj_path/2` for key canonicalization details
{Root, AbsPath} = erlfdb_directory:adj_path(Node, Path),
{erlfdb_directory:get_node_prefix(Root), AbsPath}.
-spec open_with_cache(
ets:table(),
erlfdb:tx_object(),
erlfdb_directory:t(),
erlfdb_directory:path(),
fun((erlfdb:tx_object(), erlfdb_directory:t(), erlfdb_directory:path()) -> erlfdb_directory:t())
) -> erlfdb_directory:t().
open_with_cache(Table, TxObj, Root, Path, Func) ->
Key = path_key(Root, Path),
case ets:lookup(Table, Key) of
[{Key, Node, _CachedAt}] ->
Node;
[] ->
Node = Func(TxObj, Root, Path),
ets:insert_new(Table, {Key, Node, erlang:monotonic_time(millisecond)}),
case ets:lookup(Table, Key) of
[{Key, Cached, _CachedAt}] -> Cached;
[] -> Node
end
end.