prop/lib/microwaveprop/cache.ex
Graham McIntire d67186176f
fix: resolve all dialyzer type errors across project (46→0 project errors)
Type spec fixes:
- duct_usable_* return boolean→float (delegate to duct_usable_for_band)
- sanitize/1 spec includes :unicode error tuples
- match_delete/1 broadened from :ets.match_spec()
- telemetry_event local type replaces :telemetry.event/0
- wgrib2 parse_lon_val_segment corrected to tuple spec
- preloaded Ecto assoc types in get_mission/get_contact! specs

Unmatched returns:
- _ = prefix on Task.start, Oban.insert, Repo.query!, PubSub.subscribe,
  :ets.new, and if-expression returns across 18 files

Pattern match fixes:
- markdown: restructure acc!=[] guard as direct pattern match
- path_compute/pskr/skewt_location_resolver: remove dead clauses
- calibrate.aprs_144: remove unreachable format_float catch-all
- unused.ex: suppress MapSet.union no_opaque

Also: remove unused unicode_util_compat from mix.lock
2026-06-08 17:51:13 -05:00

117 lines
3.3 KiB
Elixir

defmodule Microwaveprop.Cache do
@moduledoc """
Tiny ETS-backed TTL cache for values that are expensive to compute but
tolerate short staleness. Used for things like `Repo.aggregate` counts that
would otherwise run on every page load.
Not a replacement for `Microwaveprop.Propagation.ScoreCache` or
`Microwaveprop.Weather.NexradCache` — those have bespoke invalidation logic
driven by PubSub. This module is for generic time-boxed memoization.
"""
use GenServer
@table :microwaveprop_cache
@sweep_interval_ms to_timeout(minute: 1)
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Return the cached value for `key` if it's still fresh, otherwise invoke
`fun`, store the result with `ttl_ms` lifetime, and return it.
"""
@spec fetch_or_store(term(), non_neg_integer(), (-> value)) :: value when value: term()
def fetch_or_store(key, ttl_ms, fun) when is_function(fun, 0) do
now = System.monotonic_time(:millisecond)
case :ets.lookup(@table, key) do
[{_, value, expires_at}] when expires_at > now ->
value
_ ->
value = fun.()
:ets.insert(@table, {key, value, now + ttl_ms})
value
end
end
@doc "Insert `value` directly, overwriting any existing entry for `key`."
@spec put(term(), term(), integer()) :: :ok
def put(key, value, ttl_ms) do
:ets.insert(@table, {key, value, System.monotonic_time(:millisecond) + ttl_ms})
:ok
end
@doc "Remove `key` from the cache, forcing the next fetch to recompute."
@spec invalidate(term()) :: :ok
def invalidate(key) do
:ets.delete(@table, key)
:ok
end
@doc """
Delete every entry matching a match pattern. The pattern follows
`ets:match_delete/2` conventions — use `:"_"` for wildcards.
Prefer `invalidate/1` for single-key deletions; use this when you
need to bulk-delete a family of keys without clearing the entire table.
"""
@spec match_delete(atom() | tuple()) :: :ok
def match_delete(pattern) do
:ets.match_delete(@table, pattern)
:ok
end
@spec clear() :: :ok
def clear do
:ets.delete_all_objects(@table)
:ok
end
@doc """
Delete every entry whose TTL has elapsed. Returns the number of rows
removed.
Because TTL enforcement in `fetch_or_store/3` is lazy, keys minted
dynamically (e.g. per-URL HRRR idx entries) would otherwise accumulate
forever. A supervised timer calls this on an interval; tests can call
it directly to avoid waiting on the timer.
Uses `:ets.select_delete/2` so the sweep happens in a single BIF call
without blocking concurrent readers or writers.
"""
@spec sweep() :: non_neg_integer()
def sweep do
now = System.monotonic_time(:millisecond)
match_spec = [{{:_, :_, :"$1"}, [{:"=<", :"$1", now}], [true]}]
:ets.select_delete(@table, match_spec)
end
@impl true
def init(_opts) do
_ =
:ets.new(@table, [
:set,
:named_table,
:public,
read_concurrency: true,
write_concurrency: true
])
schedule_sweep()
{:ok, %{}}
end
@impl true
def handle_info(:sweep, state) do
_ = sweep()
schedule_sweep()
{:noreply, state}
end
defp schedule_sweep do
Process.send_after(self(), :sweep, @sweep_interval_ms)
end
end