diff --git a/test/excache/eviction_test.exs b/test/excache/eviction_test.exs index 19fa793..4887fb5 100644 --- a/test/excache/eviction_test.exs +++ b/test/excache/eviction_test.exs @@ -152,3 +152,229 @@ defmodule Excache.EvictionTest do "k4" => %{lastAccess: @now - 30_000, counter: 9} } + assert {:ok, report} = + Eviction.run( + policy: :allkeysLru, + maxmemory: 2 * perEntry, + keyspace: keyspace, + now: @now, + metadata: metadata, + sampleSize: 2, + sampler: windowSampler() + ) + + assert report.evicted == ["k1", "k2"] + end + + test "sampling scores a window instead of the whole keyspace" do + entries = stringEntries(~w(k1 k2 k3 k4), "payload") + + metadata = %{ + "k1" => %{lastAccess: @now - 1_000, counter: 9}, + "k2" => %{lastAccess: @now - 2_000, counter: 9}, + "k3" => %{lastAccess: @now - 3_000, counter: 9}, + "k4" => %{lastAccess: @now - 900_000, counter: 9} + } + + opts = [now: @now, metadata: metadata, sampler: firstSampler()] + + assert Eviction.selectVictim(entries, [policy: :allkeysLru, sampleSize: 1] ++ opts) == + {:ok, "k1"} + + assert Eviction.selectVictim(entries, [policy: :allkeysLru, sampleSize: 4] ++ opts) == + {:ok, "k4"} + end + + test "volatile-ttl evicts in expiry order and ignores keys without a TTL" do + entries = [ + {"far", :string, "payload", @now + 900_000}, + {"immortal", :string, "payload", nil}, + {"near", :string, "payload", @now + 1_000}, + {"mid", :string, "payload", @now + 60_000} + ] + + perEntry = Memory.entryBytes(hd(entries)) + {table, keyspace} = fixtureKeyspace(entries) + + assert {:ok, report} = + Eviction.run( + policy: :volatileTtl, + maxmemory: 2 * perEntry, + keyspace: keyspace, + now: @now, + sampleSize: 8 + ) + + assert report.evicted == ["near", "mid"] + assert :ets.lookup(table, "immortal") != [] + end + + test "volatile candidates can run out before the limit is reached" do + entries = + stringEntries(~w(a1 a2 a3), "payload") ++ + [ + {"t1", :string, "payload", @now + 900_000}, + {"t2", :string, "payload", @now + 900_000} + ] + + {table, keyspace} = fixtureKeyspace(entries) + + assert {:error, report} = + Eviction.run( + policy: :volatileRandom, + maxmemory: 1, + keyspace: keyspace, + now: @now, + sampleSize: 8 + ) + + assert report.reason == :exhausted + assert Enum.sort(report.evicted) == ["t1", "t2"] + assert report.usedMemory > 1 + assert :ets.info(table, :size) == 3 + end + + test "allkeys-lfu backed by a live tracker keeps the hot key and forgets the victim" do + tracker = startTracker(logFactor: 0, decayTime: 0) + Enum.each(1..50, fn _index -> Tracker.touch(tracker, "hot", @now) end) + Tracker.touch(tracker, "cold", @now) + + entries = stringEntries(["cold", "hot"], "payload") + coldBytes = Memory.entryBytes(hd(entries)) + {table, keyspace} = fixtureKeyspace(entries) + + assert {:ok, report} = + Eviction.run( + policy: :allkeysLfu, + maxmemory: Memory.usedMemory(entries) - coldBytes, + keyspace: keyspace, + tracker: tracker, + now: @now, + sampleSize: 8 + ) + + assert report.evicted == ["cold"] + assert :ets.lookup(table, "hot") != [] + assert Tracker.lookup(tracker, "cold", @now) == nil + assert Tracker.counter(tracker, "hot", @now) == 54 + end + + test "volatile-lru backed by a live tracker evicts the idle TTL key" do + tracker = startTracker(logFactor: 0, decayTime: 0) + Tracker.touch(tracker, "stale", @now - 300_000) + Tracker.touch(tracker, "fresh", @now - 1_000) + Tracker.touch(tracker, "idlest", @now - 900_000) + + entries = [ + {"fresh", :string, "payload", @now + 900_000}, + {"stale", :string, "payload", @now + 900_000}, + {"idlest", :string, "payload", nil} + ] + + assert Eviction.selectVictim(entries, + policy: :volatileLru, + tracker: tracker, + now: @now, + sampleSize: 8 + ) == {:ok, "stale"} + + assert Eviction.selectVictim(entries, + policy: :allkeysLru, + tracker: tracker, + now: @now, + sampleSize: 8 + ) == {:ok, "idlest"} + end + + test "a run against the live store really removes the keys" do + for index <- 1..20 do + :ok = Store.set("evict:#{index}", String.duplicate("payload", 20), []) + end + + used = Memory.usedMemory() + target = div(used, 2) + + assert {:ok, report} = + Eviction.run(policy: :allkeysRandom, maxmemory: target, seed: {1, 2, 3}) + + assert report.reason == :evicted + assert report.evictedCount >= 10 + assert Memory.usedMemory() <= target + assert Memory.usedMemory() == report.usedMemory + assert Store.dbsize() == 20 - report.evictedCount + + for key <- report.evicted do + assert Store.get(key) == nil + end + end + + test "candidates carry the estimated size of the entry they stand for" do + entries = [ + {"small", :string, "v", nil}, + {"large", :string, :binary.copy("z", 4096), @now + 1_000} + ] + + [small, large] = Eviction.candidates(entries, :allkeysLru) + + assert small.bytes == Memory.entryBytes({"small", :string, "v", nil}) + assert large.bytes > 4096 + assert large.expiresAt == @now + 1_000 + assert Eviction.candidates(entries, :volatileLru) |> Enum.map(& &1.key) == ["large"] + assert Eviction.candidates(entries, :noeviction) == [] + end + + defp mixedEntries do + [ + {"random-all", :string, "payload", nil}, + {"idle-all", :string, "payload", nil}, + {"cold-all", :string, "payload", nil}, + {"random-ttl", :string, "payload", @now + 900_000}, + {"idle-ttl", :string, "payload", @now + 900_000}, + {"cold-ttl", :string, "payload", @now + 900_000}, + {"soon-ttl", :string, "payload", @now + 1_000}, + {"filler", :string, "payload", nil} + ] + end + + defp mixedMetadata do + %{ + "random-all" => %{lastAccess: @now - 1_000, counter: 200}, + "idle-all" => %{lastAccess: @now - 100_000, counter: 200}, + "cold-all" => %{lastAccess: @now - 1_000, counter: 1}, + "random-ttl" => %{lastAccess: @now - 1_000, counter: 200}, + "idle-ttl" => %{lastAccess: @now - 90_000, counter: 200}, + "cold-ttl" => %{lastAccess: @now - 1_000, counter: 2}, + "soon-ttl" => %{lastAccess: @now - 1_000, counter: 200}, + "filler" => %{lastAccess: @now - 500, counter: 250} + } + end + + defp stringEntries(keys, value), do: Enum.map(keys, fn key -> {key, :string, value, nil} end) + + defp fixtureKeyspace(entries) do + table = :ets.new(:evictionFixture, [:ordered_set, :public]) + Enum.each(entries, &:ets.insert(table, &1)) + + {table, + %{ + entries: fn -> :ets.tab2list(table) end, + delete: fn key -> :ets.delete(table, key) end + }} + end + + defp firstSampler, do: fn pool, count, randState -> {Enum.take(pool, count), randState} end + + defp windowSampler do + {:ok, cursor} = Agent.start_link(fn -> 0 end) + + fn pool, count, randState -> + offset = Agent.get_and_update(cursor, fn pass -> {pass, pass + 1} end) + {pool |> Enum.drop(offset) |> Enum.take(count), randState} + end + end + + defp startTracker(opts) do + id = {Tracker, System.unique_integer([:positive])} + start_supervised!(Supervisor.child_spec({Tracker, opts}, id: id)) + end +end