kura_cache

Application-side read cache for kura projects: keyed memoization with per-kind TTL, row-cache sugar keyed by schema primary keys, telemetry, and a pluggable invalidation broadcast hook for clusters.

This is deliberately not a kura backend. Backends (kura_sqlite, kura_postgres, kura_ets) implement storage; caching is application policy — which reads are hot, how stale is acceptable, how evictions reach other nodes — and lives above the repo, next to the code that owns the write paths.

Usage

%% under your supervisor
kura_cache:start_link(#{
kinds => #{
user_schema => 60_000, %% row kinds: TTL per schema module
fanout => 30_000 %% free-form kinds work too
},
%% optional: deliver evictions to your other nodes (syn, pg, ...)
broadcast => fun my_app_cache:broadcast/1
}).
%% cached row read
get_user(Id) ->
kura_cache:fetch_row(user_schema, Id, fun() ->
kura_repo_worker:get(db_repo, user_schema, Id)
end).
%% after every write to the row: database first, then evict
ok = kura_cache:evict_row(user_schema, Id).
%% free-form memoization with the same TTL/eviction machinery
member_ids(ChannelId) ->
kura_cache:fetch(fanout, ChannelId, fun() -> load_member_ids(ChannelId) end).

Values meaning "nothing there" (undefined, {error, _}) are returned but never cached. A kind with no configured TTL is not cached at all. A cache that is not running behaves as all-miss, so tests and boot need no guards.

Cluster invalidation

kura_cache does not pick a transport. Configure broadcast; on the receiving side call kura_cache:local_evict/2 / local_flush/1:

%% sender side (configured hook)
broadcast(Event) -> syn:publish(my_scope, cache_group, {cache, Event}).
%% receiver side (any process joined to the group)
handle_info({cache, {evict, Kind, Key}}, S) ->
kura_cache:local_evict(Kind, Key), {noreply, S};
handle_info({cache, {flush, Kind}}, S) ->
kura_cache:local_flush(Kind), {noreply, S}.

Telemetry

[kura_cache, hit], [kura_cache, miss], [kura_cache, evict] with #{count => 1} and #{kind => Kind} metadata.

Discipline

  1. Only read the cache by primary key; list/conditional queries go straight to the database.
  2. Write the database first; touch the cache only after the write succeeded.
  3. Evict after writes — never re-put (re-putting races concurrent writers; eviction's worst case is one extra miss).
  4. After update_all / delete_all / raw SQL, flush_rows/1 the schema: those paths cannot tell you which keys changed.