fix(cache): add periodic TTL sweeper to bound ETS growth
This commit is contained in:
parent
5255a6ba63
commit
b874020a87
2 changed files with 64 additions and 0 deletions
|
|
@ -11,6 +11,7 @@ defmodule Microwaveprop.Cache do
|
||||||
use GenServer
|
use GenServer
|
||||||
|
|
||||||
@table :microwaveprop_cache
|
@table :microwaveprop_cache
|
||||||
|
@sweep_interval_ms to_timeout(minute: 1)
|
||||||
|
|
||||||
@spec start_link(keyword()) :: GenServer.on_start()
|
@spec start_link(keyword()) :: GenServer.on_start()
|
||||||
def start_link(opts) do
|
def start_link(opts) do
|
||||||
|
|
@ -56,6 +57,25 @@ defmodule Microwaveprop.Cache do
|
||||||
:ok
|
:ok
|
||||||
end
|
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
|
@impl true
|
||||||
def init(_opts) do
|
def init(_opts) do
|
||||||
_ =
|
_ =
|
||||||
|
|
@ -67,6 +87,18 @@ defmodule Microwaveprop.Cache do
|
||||||
write_concurrency: true
|
write_concurrency: true
|
||||||
])
|
])
|
||||||
|
|
||||||
|
schedule_sweep()
|
||||||
{:ok, %{}}
|
{:ok, %{}}
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -43,4 +43,36 @@ defmodule Microwaveprop.CacheTest do
|
||||||
assert Cache.fetch_or_store(:k, 1_000, fn -> "fresh" end) == "fresh"
|
assert Cache.fetch_or_store(:k, 1_000, fn -> "fresh" end) == "fresh"
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue