Packages
macula
0.22.8
7.0.0
6.0.0
5.2.2
5.2.1
5.2.0
5.1.0
5.0.0
4.8.0
4.7.1
4.7.0
4.6.0
4.5.0
4.4.10
4.4.9
4.4.8
4.4.7
4.4.6
4.4.5
4.4.4
4.4.3
4.4.2
4.4.1
4.4.0
4.3.1
4.3.0
4.2.9
4.2.8
4.2.7
4.2.6
4.2.5
4.2.4
4.2.3
4.2.2
4.2.1
4.2.0
4.1.1
4.1.0
4.0.0
3.16.0
3.15.3
3.15.2
3.15.1
3.14.0
3.13.0
3.12.1
3.12.0
3.11.1
3.11.0
3.10.3
3.10.2
3.10.1
3.9.0
3.8.0
3.7.0
3.5.0
3.4.0
3.3.0
3.2.0
3.1.0
3.0.0
2.1.1
2.1.0
2.0.0
1.5.2
1.5.1
1.4.30
1.4.29
1.4.28
1.4.27
1.4.26
1.4.25
1.4.24
1.4.23
1.4.22
1.4.21
1.4.20
1.4.19
1.4.18
1.4.17
1.4.16
1.4.15
1.4.14
1.4.13
1.4.11
1.4.10
1.4.9
1.4.8
1.4.7
1.4.6
1.4.5
1.4.4
1.4.3
1.4.2
1.4.1
1.4.0
1.3.1
1.3.0
1.2.0
1.1.0
1.0.10
1.0.9
1.0.8
1.0.7
1.0.6
1.0.5
1.0.4
1.0.3
1.0.2
1.0.1
1.0.0
0.48.6
0.48.5
0.48.4
0.48.3
0.48.2
0.48.1
0.48.0
0.47.1
0.47.0
0.46.3
0.46.1
0.46.0
0.45.3
0.45.2
0.45.1
0.45.0
0.44.2
0.44.1
0.44.0
0.43.3
0.43.2
0.43.1
0.43.0
0.42.9
0.42.8
0.42.7
0.42.6
0.42.5
0.42.4
0.42.3
0.42.2
0.42.1
0.42.0
0.41.1
0.41.0
0.40.1
0.40.0
0.39.9
0.39.8
0.39.7
0.39.6
0.39.5
0.39.4
0.39.3
0.39.2
0.39.1
0.39.0
0.38.8
0.38.7
0.38.6
0.38.5
0.38.4
0.38.3
0.38.2
0.38.1
0.38.0
0.37.7
0.37.6
0.37.5
0.37.4
0.37.3
0.37.2
0.37.1
0.37.0
0.36.6
0.36.5
0.36.4
0.36.3
0.36.2
0.36.1
0.36.0
0.35.4
0.35.3
0.35.2
0.35.1
0.35.0
0.34.1
0.34.0
0.33.1
0.33.0
0.32.5
0.32.4
0.32.3
0.32.2
0.32.1
0.32.0
0.31.9
0.31.8
0.31.7
0.31.6
0.31.5
0.31.4
0.31.3
0.31.2
0.31.1
0.31.0
0.30.10
0.30.9
0.30.8
0.30.7
0.30.6
0.30.5
0.30.4
0.30.3
0.30.2
0.30.1
0.30.0
0.29.0
0.28.3
0.28.2
0.28.1
0.28.0
0.27.1
0.27.0
0.26.1
0.26.0
0.25.6
0.25.5
0.25.4
0.25.3
0.25.2
0.25.1
0.25.0
0.24.6
0.24.5
0.24.4
0.24.3
0.24.2
0.24.1
0.24.0
0.23.3
0.23.2
0.23.1
0.23.0
0.22.12
0.22.11
0.22.10
0.22.9
0.22.8
0.22.7
0.22.6
0.22.5
0.22.4
0.22.3
0.22.2
0.22.1
0.22.0
0.21.7
0.21.6
0.21.5
0.21.4
0.21.2
0.21.1
0.21.0
0.20.25
0.20.24
0.20.23
0.20.22
0.20.21
0.20.20
0.20.19
0.20.18
0.20.17
0.20.16
0.20.15
0.20.14
0.20.13
0.20.12
0.20.11
0.20.10
0.20.9
0.20.8
0.20.7
0.20.6
0.20.5
0.20.3
0.20.2
0.20.1
0.20.0
0.19.2
0.19.1
0.19.0
0.18.1
0.18.0
0.17.4
0.17.3
0.17.2
0.17.1
0.17.0
0.16.6
0.16.5
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.1
0.15.0
0.14.3
0.14.2
0.14.1
0.14.0
0.12.6
0.12.5
0.12.3
0.11.3
0.10.2
0.10.1
0.10.0
0.9.2
0.9.1
0.9.0
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.10
0.8.9
0.8.8
0.8.7
0.8.6
0.8.5
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.30
0.7.29
0.7.28
0.7.27
0.7.26
0.7.25
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.0
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.4
0.3.3
0.3.2
0.3.1
Macula HTTP/3 Mesh SDK — connect, subscribe, publish, call, advertise
Current section
Files
Jump to
Current section
Files
src/macula_bridge_system/macula_bridge_cache.erl
%%%-------------------------------------------------------------------
%%% @doc
%%% Macula Bridge Cache - Caches results from parent DHT queries.
%%%
%%% When a DHT query is escalated to a parent level and succeeds,
%%% the result is cached locally to avoid repeated parent queries.
%%%
%%% Cache characteristics:
%%% - TTL-based expiration (configurable per mesh level)
%%% - LRU eviction when cache is full
%%% - Different TTLs for different mesh levels:
%%% - Cluster: 5 minutes (local, changes frequently)
%%% - Street: 10 minutes
%%% - Neighborhood: 15 minutes
%%% - City: 30 minutes
%%% - Country+: 60 minutes
%%%
%%% @end
%%%-------------------------------------------------------------------
-module(macula_bridge_cache).
-behaviour(gen_server).
-include_lib("kernel/include/logger.hrl").
%% API
-export([
start_link/1,
get/2,
put/3,
put/4,
delete/2,
clear/1,
size/1,
get_stats/1
]).
%% gen_server callbacks
-export([
init/1,
handle_call/3,
handle_cast/2,
handle_info/2,
terminate/2
]).
%% State
-record(state, {
cache :: ets:tid(), % ETS table for cache entries
mesh_level :: atom(), % Determines default TTL
max_size :: pos_integer(), % Maximum cache entries
default_ttl :: pos_integer(), % Default TTL in seconds
stats :: map()
}).
-record(cache_entry, {
key :: binary(),
value :: term(),
expires_at :: integer(), % Unix timestamp
inserted_at :: integer(),
hit_count :: non_neg_integer()
}).
-define(DEFAULT_MAX_SIZE, 10000).
-define(CLEANUP_INTERVAL, 60000). % 1 minute
%% Default TTLs per mesh level (in seconds)
-define(TTL_CLUSTER, 300). % 5 minutes
-define(TTL_STREET, 600). % 10 minutes
-define(TTL_NEIGHBORHOOD, 900). % 15 minutes
-define(TTL_CITY, 1800). % 30 minutes
-define(TTL_PROVINCE, 3600). % 60 minutes
-define(TTL_COUNTRY, 3600). % 60 minutes
-define(TTL_REGION, 3600). % 60 minutes
-define(TTL_GLOBAL, 3600). % 60 minutes
%%%===================================================================
%%% API Functions
%%%===================================================================
%% @doc Start bridge cache with registered name.
-spec start_link(map()) -> {ok, pid()} | {error, term()}.
start_link(Config) ->
gen_server:start_link({local, ?MODULE}, ?MODULE, Config, []).
%% @doc Get value from cache.
-spec get(pid(), binary()) -> {ok, term()} | not_found | expired.
get(Pid, Key) ->
gen_server:call(Pid, {get, Key}).
%% @doc Put value in cache with default TTL.
-spec put(pid(), binary(), term()) -> ok.
put(Pid, Key, Value) ->
gen_server:call(Pid, {put, Key, Value, default}).
%% @doc Put value in cache with specific TTL (in seconds).
-spec put(pid(), binary(), term(), pos_integer() | default) -> ok.
put(Pid, Key, Value, TTL) ->
gen_server:call(Pid, {put, Key, Value, TTL}).
%% @doc Delete value from cache.
-spec delete(pid(), binary()) -> ok.
delete(Pid, Key) ->
gen_server:call(Pid, {delete, Key}).
%% @doc Clear entire cache.
-spec clear(pid()) -> ok.
clear(Pid) ->
gen_server:call(Pid, clear).
%% @doc Get current cache size.
-spec size(pid()) -> non_neg_integer().
size(Pid) ->
gen_server:call(Pid, size).
%% @doc Get cache statistics.
-spec get_stats(pid()) -> {ok, map()}.
get_stats(Pid) ->
gen_server:call(Pid, get_stats).
%%%===================================================================
%%% gen_server Callbacks
%%%===================================================================
init(Config) ->
MeshLevel = maps:get(mesh_level, Config, cluster),
MaxSize = maps:get(cache_max_size, Config, ?DEFAULT_MAX_SIZE),
DefaultTTL = maps:get(cache_ttl, Config, ttl_for_level(MeshLevel)),
%% Create ETS table for cache
Cache = ets:new(bridge_cache, [
set,
private,
{keypos, #cache_entry.key}
]),
?LOG_INFO("[BridgeCache] Starting for level ~p with TTL ~p seconds, max ~p entries",
[MeshLevel, DefaultTTL, MaxSize]),
State = #state{
cache = Cache,
mesh_level = MeshLevel,
max_size = MaxSize,
default_ttl = DefaultTTL,
stats = init_stats()
},
%% Schedule periodic cleanup
erlang:send_after(?CLEANUP_INTERVAL, self(), cleanup_expired),
{ok, State}.
handle_call({get, Key}, _From, #state{cache = Cache, stats = Stats} = State) ->
Now = erlang:system_time(second),
{Reply, NewStats} = case ets:lookup(Cache, Key) of
[] ->
{not_found, increment_stat(misses, Stats)};
[#cache_entry{expires_at = ExpiresAt}] when ExpiresAt =< Now ->
%% Entry expired (ExpiresAt <= Now) - delete and return expired
ets:delete(Cache, Key),
{expired, increment_stat(expired_hits, Stats)};
[#cache_entry{value = Value, hit_count = Hits} = Entry] ->
%% Valid entry - update hit count
ets:insert(Cache, Entry#cache_entry{hit_count = Hits + 1}),
{{ok, Value}, increment_stat(hits, Stats)}
end,
{reply, Reply, State#state{stats = NewStats}};
handle_call({put, Key, Value, TTLSpec}, _From,
#state{cache = Cache, default_ttl = DefaultTTL, max_size = MaxSize,
stats = Stats} = State) ->
%% Ensure we have room
CurrentSize = ets:info(Cache, size),
evict_if_needed(Cache, CurrentSize, MaxSize),
TTL = resolve_ttl(TTLSpec, DefaultTTL),
Now = erlang:system_time(second),
Entry = #cache_entry{
key = Key,
value = Value,
expires_at = Now + TTL,
inserted_at = Now,
hit_count = 0
},
ets:insert(Cache, Entry),
NewStats = increment_stat(inserts, Stats),
{reply, ok, State#state{stats = NewStats}};
handle_call({delete, Key}, _From, #state{cache = Cache, stats = Stats} = State) ->
ets:delete(Cache, Key),
NewStats = increment_stat(deletes, Stats),
{reply, ok, State#state{stats = NewStats}};
handle_call(clear, _From, #state{cache = Cache, stats = Stats} = State) ->
ets:delete_all_objects(Cache),
NewStats = increment_stat(clears, Stats),
{reply, ok, State#state{stats = NewStats}};
handle_call(size, _From, #state{cache = Cache} = State) ->
Size = ets:info(Cache, size),
{reply, Size, State};
handle_call(get_stats, _From, #state{cache = Cache, stats = Stats,
mesh_level = Level, default_ttl = TTL,
max_size = MaxSize} = State) ->
FullStats = Stats#{
current_size => ets:info(Cache, size),
max_size => MaxSize,
mesh_level => Level,
default_ttl => TTL
},
{reply, {ok, FullStats}, State};
handle_call(_Request, _From, State) ->
{reply, {error, unknown_request}, State}.
handle_cast(_Request, State) ->
{noreply, State}.
handle_info(cleanup_expired, #state{cache = Cache, stats = Stats} = State) ->
ExpiredCount = cleanup_expired_entries(Cache),
NewStats = case ExpiredCount > 0 of
true ->
?LOG_DEBUG("[BridgeCache] Cleaned up ~p expired entries", [ExpiredCount]),
maps:update_with(expired_cleaned, fun(V) -> V + ExpiredCount end, ExpiredCount, Stats);
false ->
Stats
end,
erlang:send_after(?CLEANUP_INTERVAL, self(), cleanup_expired),
{noreply, State#state{stats = NewStats}};
handle_info(_Info, State) ->
{noreply, State}.
terminate(_Reason, #state{cache = Cache}) ->
ets:delete(Cache),
ok.
%%%===================================================================
%%% Internal Functions
%%%===================================================================
%% @doc Initialize statistics map.
-spec init_stats() -> map().
init_stats() ->
#{
hits => 0,
misses => 0,
expired_hits => 0,
inserts => 0,
deletes => 0,
clears => 0,
evictions => 0,
expired_cleaned => 0,
started_at => erlang:system_time(second)
}.
%% @doc Increment a statistic counter.
-spec increment_stat(atom(), map()) -> map().
increment_stat(Key, Stats) ->
maps:update_with(Key, fun(V) -> V + 1 end, 1, Stats).
%% @doc Get default TTL for mesh level.
-spec ttl_for_level(atom()) -> pos_integer().
ttl_for_level(cluster) -> ?TTL_CLUSTER;
ttl_for_level(street) -> ?TTL_STREET;
ttl_for_level(neighborhood) -> ?TTL_NEIGHBORHOOD;
ttl_for_level(city) -> ?TTL_CITY;
ttl_for_level(province) -> ?TTL_PROVINCE;
ttl_for_level(country) -> ?TTL_COUNTRY;
ttl_for_level(region) -> ?TTL_REGION;
ttl_for_level(global) -> ?TTL_GLOBAL;
ttl_for_level(_) -> ?TTL_CLUSTER.
%% @doc Resolve TTL specification to actual value.
-spec resolve_ttl(pos_integer() | default, pos_integer()) -> pos_integer().
resolve_ttl(default, DefaultTTL) -> DefaultTTL;
resolve_ttl(TTL, _) when is_integer(TTL), TTL > 0 -> TTL.
%% @doc Evict entries if cache is at capacity.
-spec evict_if_needed(ets:tid(), non_neg_integer(), pos_integer()) -> ok.
evict_if_needed(Cache, CurrentSize, MaxSize) when CurrentSize >= MaxSize ->
%% Evict ~10% of entries using LRU (lowest hit count + oldest)
EvictCount = max(1, MaxSize div 10),
evict_lru_entries(Cache, EvictCount);
evict_if_needed(_, _, _) ->
ok.
%% @doc Evict least recently used entries.
-spec evict_lru_entries(ets:tid(), pos_integer()) -> ok.
evict_lru_entries(Cache, Count) ->
%% Get all entries sorted by hit_count (ascending), then by inserted_at (ascending)
AllEntries = ets:tab2list(Cache),
Sorted = lists:sort(fun(#cache_entry{hit_count = H1, inserted_at = T1},
#cache_entry{hit_count = H2, inserted_at = T2}) ->
case H1 =:= H2 of
true -> T1 =< T2;
false -> H1 < H2
end
end, AllEntries),
%% Delete the first Count entries
ToEvict = lists:sublist(Sorted, Count),
lists:foreach(fun(#cache_entry{key = Key}) ->
ets:delete(Cache, Key)
end, ToEvict),
ok.
%% @doc Clean up expired entries.
-spec cleanup_expired_entries(ets:tid()) -> non_neg_integer().
cleanup_expired_entries(Cache) ->
Now = erlang:system_time(second),
%% Use match_delete for efficiency
%% Entry is expired if ExpiresAt =< Now (TTL has passed)
MatchSpec = [{
#cache_entry{key = '$1', expires_at = '$2', _ = '_'},
[{'=<', '$2', Now}],
[true]
}],
ets:select_delete(Cache, MatchSpec).