fix(cache): add periodic TTL sweeper to bound ETS growth

This commit is contained in:
Graham McIntire 2026-04-21 13:18:23 -05:00
parent 5255a6ba63
commit b874020a87
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 64 additions and 0 deletions

View file

@ -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

View file

@ -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