From b874020a87e20795950d0b50884767b8925e22d7 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Tue, 21 Apr 2026 13:18:23 -0500 Subject: [PATCH] fix(cache): add periodic TTL sweeper to bound ETS growth --- lib/microwaveprop/cache.ex | 32 +++++++++++++++++++++++++++++++ test/microwaveprop/cache_test.exs | 32 +++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/lib/microwaveprop/cache.ex b/lib/microwaveprop/cache.ex index 34435108..199d4647 100644 --- a/lib/microwaveprop/cache.ex +++ b/lib/microwaveprop/cache.ex @@ -11,6 +11,7 @@ defmodule Microwaveprop.Cache do use GenServer @table :microwaveprop_cache + @sweep_interval_ms to_timeout(minute: 1) @spec start_link(keyword()) :: GenServer.on_start() def start_link(opts) do @@ -56,6 +57,25 @@ defmodule Microwaveprop.Cache do :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 _ = @@ -67,6 +87,18 @@ defmodule Microwaveprop.Cache do 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 diff --git a/test/microwaveprop/cache_test.exs b/test/microwaveprop/cache_test.exs index c7526a28..ab460727 100644 --- a/test/microwaveprop/cache_test.exs +++ b/test/microwaveprop/cache_test.exs @@ -43,4 +43,36 @@ defmodule Microwaveprop.CacheTest do assert Cache.fetch_or_store(:k, 1_000, fn -> "fresh" end) == "fresh" end end + + describe "sweep/0" do + test "removes expired entries" do + Cache.put(:expired, "old", 1) + Process.sleep(5) + + assert Cache.sweep() >= 1 + assert :ets.lookup(:microwaveprop_cache, :expired) == [] + end + + test "leaves unexpired entries in place" do + Cache.put(:fresh, "new", 60_000) + + _ = Cache.sweep() + + assert [{:fresh, "new", _}] = :ets.lookup(:microwaveprop_cache, :fresh) + end + + test "handles a mix of expired and unexpired entries" do + Cache.put(:expired_a, 1, 1) + Cache.put(:expired_b, 2, 1) + Cache.put(:fresh, 3, 60_000) + Process.sleep(5) + + deleted = Cache.sweep() + assert deleted >= 2 + + assert :ets.lookup(:microwaveprop_cache, :expired_a) == [] + assert :ets.lookup(:microwaveprop_cache, :expired_b) == [] + assert [{:fresh, 3, _}] = :ets.lookup(:microwaveprop_cache, :fresh) + end + end end