Step 6's keys never went away. A cache needs a TTL.
Each row now stores an expiry time. Reads ignore
expired rows right away, and a periodic sweep
(a message the GenServer sends itself) deletes them
so memory doesn't grow forever.
1
defmoduleKV.CachedouseGenServer@table__MODULE__defstart_link(opts),do:GenServer.start_link(__MODULE__,opts,name:__MODULE__)defput(key,value,ttl_ms),do:GenServer.call(__MODULE__,{:put,key,value,ttl_ms})# Reads check the expiry themselves, so a key is# invisible the moment it expires, not whenever the# next sweep happens to run.defget(key)donow=now()case:ets.lookup(@table,key)do[{^key,value,expires}]whenexpires>now->{:ok,value}_->:errorendenddefsize,do::ets.info(@table,:size)# Sweep on demand, so we can show it without# waiting for the timer.defsweep,do:GenServer.call(__MODULE__,:sweep)defpnow,do:System.monotonic_time(:millisecond)@impltruedefinit(opts)do:ets.new(@table,[:set,:protected,:named_table,read_concurrency:true])interval=Keyword.get(opts,:sweep_every,1_000)Process.send_after(self(),:sweep,interval){:ok,%{interval:interval}}end@impltruedefhandle_call({:put,k,v,ttl},_from,s)do:ets.insert(@table,{k,v,now()+ttl}){:reply,:ok,s}enddefhandle_call(:sweep,_from,s),do:{:reply,delete_expired(),s}# The timer message arrives in `handle_info`. We# re-arm it every time, so it keeps running.@impltruedefhandle_info(:sweep,s)dodelete_expired()Process.send_after(self(),:sweep,s.interval){:noreply,s}end# `select_delete` removes every row whose expiry is# in the past, inside ETS, without copying rows out.defpdelete_expireddo:ets.select_delete(@table,[{{:_,:_,:"$1"},[{:"=<",:"$1",now()}],[true]}])endend
Monotonic time is used deliberately: wall-clock time
can jump (NTP, daylight saving), monotonic can't.
2
{:ok,_}=KV.Cache.start_link(sweep_every:60_000)KV.Cache.put("session:ada",:token_a,50)KV.Cache.put("session:grace",:token_g,60_000)IO.inspect(KV.Cache.get("session:ada"),label:"ada right away")Process.sleep(100)IO.inspect(KV.Cache.get("session:ada"),label:"ada after 100 ms")IO.inspect(KV.Cache.size(),label:"rows before sweep")IO.inspect(KV.Cache.sweep(),label:"swept")IO.inspect(KV.Cache.get("session:grace"),label:"grace")
ada right away: {:ok, :token_a}
ada after 100 ms: :error
rows before sweep: 2
swept: 1
grace: {:ok, :token_g}
KV is now concurrent, supervised, fast to read and
self-cleaning, but it lives on one machine. If that
node dies, everything is gone. The Distributed track
picks up here: connecting nodes, replicating buckets
with :pg process groups, and persisting with
Mnesia.