From bdf88ca1ca2c5dc6b1dfc021599ea9dba69411db Mon Sep 17 00:00:00 2001 From: DualFroz Date: Sun, 13 Sep 2026 18:57:50 +0200 Subject: [PATCH] test: cover Excache.StoreConsistencyTest --- test/excache/store_consistency_test.exs | 159 ++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 test/excache/store_consistency_test.exs diff --git a/test/excache/store_consistency_test.exs b/test/excache/store_consistency_test.exs new file mode 100644 index 0000000..e9cbfa4 --- /dev/null +++ b/test/excache/store_consistency_test.exs @@ -0,0 +1,159 @@ +defmodule Excache.StoreConsistencyTest do + use Excache.Test.ServerCase, async: false + + alias Excache.Aof + alias Excache.Config + alias Excache.Eviction.Tracker + alias Excache.Store + + @ttlMs 200 + @holdMs 400 + + setup do + Tracker.clear(Tracker) + + on_exit(fn -> + Config.reset() + Tracker.clear(Tracker) + end) + + :ok + end + + describe "RENAME must not drop the source when the destination write is refused" do + test "an expired source is reported instead of being silently swallowed" do + source = "cons:rename:src" + dest = keyInOtherShard("cons:rename:dst-", source) + + assert :ok = Store.set(source, "payload", px: @ttlMs) + + assert {:error, :nokey} = + duringSuspendedShard(dest, fn -> Store.rename(source, dest) end) + + assert Store.exists([dest]) == 0 + assert Store.exists([source]) == 0 + end + + test "an existing destination is never reported as overwritten when it was not" do + source = "cons:rename:live-src" + dest = keyInOtherShard("cons:rename:live-dst-", source) + + assert :ok = Store.set(dest, "stale", []) + assert :ok = Store.set(source, "fresh", px: @ttlMs) + + assert {:error, :nokey} = + duringSuspendedShard(dest, fn -> Store.rename(source, dest) end) + + assert {:ok, "stale"} = Store.get(dest) + end + + test "a healthy rename still carries the value and the remaining ttl" do + source = "cons:rename:ok-src" + dest = keyInOtherShard("cons:rename:ok-dst-", source) + + assert :ok = Store.set(source, "payload", px: 60_000) + + assert {:ok, "OK"} = Store.rename(source, dest) + assert {:ok, "payload"} = Store.get(dest) + assert Store.exists([source]) == 0 + + remaining = Store.ttl(dest, :seconds) + assert remaining > 0 and remaining <= 60 + end + end + + describe "RENAMENX must not drop the source when the destination write is refused" do + test "an expired source is reported instead of counted as a rename" do + source = "cons:renamenx:src" + dest = keyInOtherShard("cons:renamenx:dst-", source) + + assert :ok = Store.set(source, "payload", px: @ttlMs) + + assert {:error, :nokey} = + duringSuspendedShard(dest, fn -> Store.renamenx(source, dest) end) + + assert Store.exists([dest]) == 0 + end + + test "an occupied destination still refuses the rename and keeps both keys" do + source = "cons:renamenx:busy-src" + dest = keyInOtherShard("cons:renamenx:busy-dst-", source) + + assert :ok = Store.set(source, "payload", []) + assert :ok = Store.set(dest, "taken", []) + + assert {:ok, 0} = Store.renamenx(source, dest) + assert {:ok, "payload"} = Store.get(source) + assert {:ok, "taken"} = Store.get(dest) + end + end + + describe "COPY must not destroy the destination when the write is refused" do + test "an expiring source leaves the existing destination intact" do + source = "cons:copy:src" + dest = keyInOtherShard("cons:copy:dst-", source) + + assert :ok = Store.set(dest, "keep-me", []) + assert :ok = Store.set(source, "payload", px: @ttlMs) + + assert {:ok, 0} = + duringSuspendedShard(dest, fn -> Store.copy(source, dest, true) end) + + assert {:ok, "keep-me"} = Store.get(dest) + end + + test "replacing a destination of a different type still overwrites it" do + source = "cons:copy:swap-src" + dest = keyInOtherShard("cons:copy:swap-dst-", source) + + assert {:ok, 1} = Store.rpush(dest, ["old"]) + assert :ok = Store.set(source, "fresh", []) + + assert {:ok, 1} = Store.copy(source, dest, true) + assert {:ok, "fresh"} = Store.get(dest) + assert Store.type(dest) == "string" + end + + test "copying onto an occupied destination without replace changes nothing" do + source = "cons:copy:keep-src" + dest = keyInOtherShard("cons:copy:keep-dst-", source) + + assert :ok = Store.set(source, "fresh", []) + assert :ok = Store.set(dest, "untouched", []) + + assert {:ok, 0} = Store.copy(source, dest, false) + assert {:ok, "untouched"} = Store.get(dest) + end + end + + describe "SPOP must reach the log as the removal it actually performed" do + test "the replayed log rebuilds exactly the surviving members", %{conn: conn} do + key = "cons:pop:members" + members = Enum.map(1..20, &"member#{&1}") + + assert {20, conn} = Client.command(conn, ["SADD", key | members]) + + {taken, conn} = Client.command(conn, ["SPOP", key, "5"]) + assert length(taken) == 5 + + {single, conn} = Client.command(conn, ["SPOP", key]) + assert single in members + + {survivors, conn} = Client.command(conn, ["SMEMBERS", key]) + assert length(survivors) == 14 + + log = flushedLog() + refute log =~ "SPOP" + assert log =~ "SREM" + + conn = replayFromScratch(conn) + + {replayed, _conn} = Client.command(conn, ["SMEMBERS", key]) + assert Enum.sort(replayed) == Enum.sort(survivors) + end + + test "draining the whole set replays as the key being gone", %{conn: conn} do + key = "cons:pop:drained" + + assert {3, conn} = Client.command(conn, ["SADD", key, "a", "b", "c"]) +