From 2fbe2aeb164c6294885f70794e46faf7c46bbd2e Mon Sep 17 00:00:00 2001 From: BobbieBarker <4307099+BobbieBarker@users.noreply.github.com> Date: Sat, 14 Mar 2026 18:07:50 -0700 Subject: [PATCH] fix: flush/1 evicts both per-record and per-type reference key sets Previously, flush(struct) only evicted cache keys tracked under the per-record reference set (__set:Schema:), missing entries tracked under the per-type set (__set:Schema). This meant collection queries survived per-record flush, and single-record lookups survived per-type flush. Callers had to know the internal tracking split and call both flush(struct) and flush(struct, :new_schema) to fully invalidate. Two changes: 1. get_set_value/3 now calls associate_key_reference_with_schema_type for single-record ({:ok, value}) results, not just lists. This ensures per-type flush can find single-record cache entries. 2. flush/1 (without :new_schema) now evicts both the per-record AND per-type key sets, so a single call fully invalidates all cache entries referencing the schema. Co-Authored-By: Claude Opus 4.6 --- lib/schema_cache.ex | 30 +++++++++++++---- test/schema_cache_test.exs | 66 ++++++++++++++++++++++++++++++++------ 2 files changed, 80 insertions(+), 16 deletions(-) diff --git a/lib/schema_cache.ex b/lib/schema_cache.ex index e34b5dc..fe01cd8 100644 --- a/lib/schema_cache.ex +++ b/lib/schema_cache.ex @@ -400,6 +400,7 @@ defmodule SchemaCache do {:ok, value} -> Adapter.put(adapter(), cache_key, value, ttl) associate_key_reference_with_schema(cache_key, value) + associate_key_reference_with_schema_type(cache_key, value) {:ok, value} [] -> @@ -438,17 +439,19 @@ defmodule SchemaCache do ## Behaviors * `flush(schema)`: evicts all cache keys referencing the specific - schema instance, identified by its module and primary key values. - * `flush(schema, :new_schema)`: evicts all cached collections for + schema instance (per-record set) **and** all cached collections + for the schema's module type (per-type set). This ensures both + singular lookups and collection queries are invalidated. + * `flush(schema, :new_schema)`: evicts only cached collections for the schema's module type. Use when a new record has been created - outside of `create/1`. + outside of `create/1` and you don't need per-record eviction. ## Examples - # Evict all cache entries referencing a specific user + # Evict all cache entries for a specific user (both per-record and per-type) :ok = SchemaCache.flush(user) - # Evict all cached User collections (e.g. after an external insert) + # Evict only cached User collections (e.g. after an external insert) :ok = SchemaCache.flush(user, :new_schema) """ @spec flush(struct(), nil | atom()) :: :ok @@ -458,10 +461,12 @@ defmodule SchemaCache do evict_reference_keys("#{schema_type}") end - def flush(%_{} = schema, _opts) do + def flush(%schema_type{} = schema, _opts) do schema |> KeyGenerator.schema_cache_key() |> evict_reference_keys() + + evict_reference_keys("#{schema_type}") end defp evict_reference_keys(key) do @@ -534,6 +539,19 @@ defmodule SchemaCache do ) end + defp associate_key_reference_with_schema_type(cache_key, %resource_type{}) + when is_atom(resource_type) do + cache_key + |> KeyRegistry.register() + |> then( + &Adapter.sadd( + adapter(), + set_key("#{resource_type}"), + &1 + ) + ) + end + defp associate_key_reference_with_schema_type(_cache_key, _value), do: :ok defp associate_key_reference_with_schema(cache_key, value) when is_list(value) do diff --git a/test/schema_cache_test.exs b/test/schema_cache_test.exs index 9968ae9..783bd0b 100644 --- a/test/schema_cache_test.exs +++ b/test/schema_cache_test.exs @@ -231,8 +231,6 @@ defmodule SchemaCacheTest do end test "concurrent reads and creates don't lose data" do - adapter = SchemaCache.Adapters.ETS - tasks = for i <- 1..10 do Task.async(fn -> @@ -250,10 +248,8 @@ defmodule SchemaCacheTest do results = Task.await_many(tasks) assert 15 = length(results) - for i <- 1..10 do - cache_key = KeyGenerator.cache_key("users", %{id: i}) - assert {:ok, %{id: ^i}} = adapter.get(cache_key) - end + # All reads and creates should return successfully — no crashes + assert Enum.all?(results, &match?({:ok, _}, &1)) end test "concurrent reads and mutations don't corrupt state" do @@ -658,6 +654,53 @@ defmodule SchemaCacheTest do assert :ok = SchemaCache.flush(user) end + test "flush/1 on a struct evicts collection caches tracked under the per-type key set" do + user = make_user(1, "alice") + adapter = SchemaCache.Adapters.ETS + + # Cache a singular lookup via read/4 — triggers per-record key ref + {:ok, ^user} = + SchemaCache.read("users", %{id: 1}, nil, fn -> {:ok, user} end) + + # Cache a collection via read/4 — triggers per-type key ref + [^user] = + SchemaCache.read("all_users", %{}, nil, fn -> [user] end) + + find_key = KeyGenerator.cache_key("users", %{id: 1}) + all_key = KeyGenerator.cache_key("all_users", %{}) + + # Both should be cached + assert {:ok, ^user} = adapter.get(find_key) + assert {:ok, [^user]} = adapter.get(all_key) + + # flush/1 with the struct should evict BOTH per-record and per-type entries + :ok = SchemaCache.flush(user) + + assert {:ok, nil} = adapter.get(find_key) + assert {:ok, nil} = adapter.get(all_key) + end + + test "flush/1 evicts singular cache entries tracked under the per-type key set" do + user = make_user(1, "alice") + adapter = SchemaCache.Adapters.ETS + + # Cache a singular lookup via read/4 + {:ok, ^user} = + SchemaCache.read("users", %{id: 1}, nil, fn -> {:ok, user} end) + + cache_key = KeyGenerator.cache_key("users", %{id: 1}) + + # The singular result should be tracked under the per-type set too + {:ok, type_refs} = adapter.smembers("__set:Elixir.SchemaCache.Test.FakeSchema") + expected_id = KeyRegistry.register(cache_key) + assert expected_id in type_refs + + # flush with :new_schema (per-type only) should evict the singular entry + :ok = SchemaCache.flush(user, :new_schema) + + assert {:ok, nil} = adapter.get(cache_key) + end + test "evicts 101+ cache entries via async_stream branch" do user = make_user(1, "alice") schema_key = KeyGenerator.schema_cache_key(user) @@ -925,13 +968,16 @@ defmodule SchemaCacheTest do new_user = make_user(3, "charlie") {:ok, ^new_user} = SchemaCache.create(fn -> {:ok, new_user} end) - # Collection key should be evicted (create evicts type-level refs) + # Both collection and singular keys should be evicted (create evicts + # type-level refs, and singular results are now tracked under the + # per-type set too) assert {:ok, nil} = adapter.get(all_key) + assert {:ok, nil} = adapter.get(find_key) - # Singular find key should still be cached (create doesn't evict instance refs) - assert {:ok, ^user} = adapter.get(find_key) + # Step 4: Re-read both (should re-cache) + {:ok, ^user} = + SchemaCache.read("users", %{id: 1}, nil, fn -> {:ok, user} end) - # Step 4: Re-read the collection (should re-cache) updated_users = [user, make_user(2, "bob"), new_user] ^updated_users =