defmodule Vela.NearCache do @moduledoc """ L1 read-through layer for Vela caches. When enabled, adds a local ETS table in front of the real topology. Hot keys are served from L1 (microseconds) instead of hitting the topology (which may involve network hops for Redis, Partitioned, etc.). L1 is ephemeral — if the tables are lost, reads fall through to the topology and L1 warms back up naturally. This is NOT a topology. It's a transparent middleware that sits between the Cache API and whatever topology is configured. """ alias Vela.Backend.ETS.TableOwner alias Vela.Cache.Entry @doc "Creates the L1 ETS tables for a cache." def create_tables(cache_name) do l1_name = l1_name(cache_name) data_table = TableOwner.data_table(l1_name) ttl_table = TableOwner.ttl_table(l1_name) :ets.new(data_table, [ :set, :public, :named_table, read_concurrency: true, write_concurrency: true ]) :ets.new(ttl_table, [ :ordered_set, :public, :named_table, read_concurrency: true, write_concurrency: true ]) :ok end @doc "Look up a key in L1. Returns `{:ok, entry}` or `:miss`." def get(cache_name, key) do table = TableOwner.data_table(l1_name(cache_name)) case :ets.lookup(table, key) do [{^key, entry}] -> if Entry.expired?(entry) do :ets.delete(table, key) :miss else {:ok, entry} end [] -> :miss end end @doc "Store an entry in L1 with a capped TTL." def put(cache_name, %Entry{} = entry, l1_ttl) do l1_entry = cap_ttl(entry, l1_ttl) table = TableOwner.data_table(l1_name(cache_name)) :ets.insert(table, {l1_entry.key, l1_entry}) :ok end @doc "Delete a key from L1." def delete(cache_name, key) do table = TableOwner.data_table(l1_name(cache_name)) :ets.delete(table, key) :ok end @doc "Flush all L1 entries." def flush(cache_name) do table = TableOwner.data_table(l1_name(cache_name)) :ets.delete_all_objects(table) :ok end @doc "Delete all L1 entries with the given tag." def invalidate_tag(cache_name, tag) do table = TableOwner.data_table(l1_name(cache_name)) :ets.foldl( fn {key, entry}, acc -> if tag in entry.tags do :ets.delete(table, key) acc + 1 else acc end end, 0, table ) end defp cap_ttl(entry, l1_ttl) do now = System.monotonic_time(:millisecond) l1_expires = now + l1_ttl new_expires = case entry.expires_at do :infinity -> l1_expires t -> min(t, l1_expires) end %{entry | expires_at: new_expires} end defp l1_name(cache_name), do: :"#{cache_name}_l1" end