diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d5cf00..39fbba8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,63 +8,142 @@ on: env: MIX_ENV: test - -permissions: - contents: read + ELIXIR_VERSION: '1.19' + OTP_VERSION: '28' jobs: - test: - name: Test (Elixir ${{ matrix.elixir }} / OTP ${{ matrix.otp }}) + quality: + name: Quality runs-on: ubuntu-latest - strategy: - matrix: - elixir: ['1.16'] - otp: ['26'] + steps: - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 with: - elixir-version: ${{ matrix.elixir }} - otp-version: ${{ matrix.otp }} + elixir-version: ${{ env.ELIXIR_VERSION }} + otp-version: ${{ env.OTP_VERSION }} + - name: Restore dependencies cache uses: actions/cache@v4 with: - path: deps + path: | + _build + deps key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} restore-keys: ${{ runner.os }}-mix- + - name: Install dependencies run: mix deps.get - - name: Compile + + - name: Compile (warnings as errors) run: mix compile --warnings-as-errors + - name: Check formatting run: mix format --check-formatted - - name: Run Credo + + - name: Credo run: mix credo --strict - - name: Run tests - run: mix test + + test: + name: Test + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:18 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: schema_cache_test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + redis: + image: redis:8 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + + steps: + - uses: actions/checkout@v4 + + - uses: erlef/setup-beam@v1 + with: + elixir-version: ${{ env.ELIXIR_VERSION }} + otp-version: ${{ env.OTP_VERSION }} + + - name: Restore dependencies cache + uses: actions/cache@v4 + with: + path: | + _build + deps + key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} + restore-keys: ${{ runner.os }}-mix- + + - name: Install dependencies + run: mix deps.get + + - name: Compile + run: mix compile + + - name: Setup database + run: mix ecto.setup + + - name: Run tests with coverage + run: mix coveralls.lcov + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: cover/lcov.info + fail_ci_if_error: true dialyzer: name: Dialyzer runs-on: ubuntu-latest + steps: - uses: actions/checkout@v4 + - uses: erlef/setup-beam@v1 + id: beam with: - elixir-version: '1.16' - otp-version: '26' + elixir-version: ${{ env.ELIXIR_VERSION }} + otp-version: ${{ env.OTP_VERSION }} + - name: Restore dependencies cache uses: actions/cache@v4 with: - path: deps + path: | + _build + deps key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} restore-keys: ${{ runner.os }}-mix- + - name: Restore PLT cache uses: actions/cache@v4 with: - path: priv/plts - key: ${{ runner.os }}-plt-${{ hashFiles('**/mix.lock') }} - restore-keys: ${{ runner.os }}-plt- + path: _build/test/dialyxir_*.plt* + key: ${{ runner.os }}-plt-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-${{ hashFiles('**/mix.lock') }} + restore-keys: ${{ runner.os }}-plt-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}- + - name: Install dependencies run: mix deps.get + + - name: Compile + run: mix compile + - name: Run Dialyzer run: mix dialyzer diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..d5b8124 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +erlang 28.3.2 +elixir 1.19.5-otp-28 diff --git a/README.md b/README.md index c288f80..44d8af6 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,17 @@ # SchemaCache -[![CI](https://github.com/BobbieBarker/schema_cache/actions/workflows/ci.yml/badge.svg)](https://github.com/BobbieBarker/schema_cache/actions/workflows/ci.yml) [![Hex.pm](https://img.shields.io/hexpm/v/schema_cache.svg)](https://hex.pm/packages/schema_cache) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) - -An Ecto-aware caching library implementing **Read Through**, **Write Through**, and **Schema Mutation Key Eviction Strategy (SMKES)** for intelligent cache invalidation. - -## Why SchemaCache? - -Most caching libraries give you a key-value store and leave invalidation up to you. SchemaCache takes a different approach — it understands your Ecto schemas and uses their structure to automatically manage cache keys. +[![Docs](https://img.shields.io/badge/hex-docs-blue.svg)](https://hexdocs.pm/schema_cache) +[![CI](https://github.com/BobbieBarker/schema_cache/actions/workflows/ci.yml/badge.svg)](https://github.com/BobbieBarker/schema_cache/actions/workflows/ci.yml) +[![codecov](https://codecov.io/gh/BobbieBarker/schema_cache/branch/main/graph/badge.svg)](https://codecov.io/gh/BobbieBarker/schema_cache) -**SMKES** (Schema Mutation Key Eviction Strategy) maintains a mapping between your Ecto schemas and every cache key where they appear. When a schema is mutated (created, updated, or deleted), SchemaCache knows exactly which cached values are affected and evicts or updates them automatically. +An Ecto-aware caching library implementing **Read Through**, **Write Through**, +and **Schema Mutation Key Eviction Strategy (SMKES)**. -This means: -- No stale data from forgotten cache invalidations -- No manual bookkeeping of which keys to evict on writes -- Write-through support that updates cached collections in place -- Automatic collection eviction when new records are created +SchemaCache understands the relationship between your Ecto schemas and cached +query results. When a schema is mutated, it knows exactly which cached values +are affected and can evict or update them automatically, requiring no manual +cache invalidation. ## Installation @@ -30,93 +25,309 @@ def deps do end ``` -## Configuration - -Configure your cache adapter: +Then configure your cache adapter: ```elixir # config/config.exs config :schema_cache, adapter: SchemaCache.Adapters.ETS ``` -SchemaCache ships with a built-in ETS adapter. You can implement the `SchemaCache.Adapter` behaviour to use any backend (Nebulex, ConCache, Redis, etc.). +Add `SchemaCache.Supervisor` to your application supervision tree: + +```elixir +children = [ + {SchemaCache.Supervisor, adapter: SchemaCache.Adapters.ETS}, + # ... other children +] +``` + +## Quick Start -## Usage +SchemaCache provides four core operations: **read**, **create**, **update**, +and **delete**. -### Read Through Caching +### Read Through + +On a cache miss, SchemaCache invokes your callback to fetch from the source, +caches the result, and records which schemas appear in it for later eviction. ```elixir -defmodule MyApp.Users do - @find_user_key "find_user" - @all_users_key "all_users" - @ttl :timer.minutes(15) - - def cached_find(params) do - SchemaCache.read(@find_user_key, params, @ttl, fn -> - find(params) - end) - end +# Cache a single record +{:ok, user} = + SchemaCache.read("find_user", %{id: 5}, :timer.minutes(15), fn -> + MyApp.Users.find(%{id: 5}) + end) + +# Cache a collection (prefix with "all_" for write-through support) +users = + SchemaCache.read("all_active_users", %{active: true}, :timer.minutes(5), fn -> + MyApp.Users.all(%{active: true}) + end) +``` - def cached_all(params \\ %{}) do - SchemaCache.read(@all_users_key, params, @ttl, fn -> - all(params) - end) - end -end +### Create + +After inserting a new record, evict all cached collections for that schema +type so they refresh on the next read and include the new record. + +```elixir +{:ok, new_user} = + SchemaCache.create(fn -> + %User{} + |> User.changeset(%{name: "Bob", email: "bob@example.com"}) + |> Repo.insert() + end) ``` -### Mutation with Automatic Eviction +### Update with Eviction (default) + +After an update, evict all cache keys that reference the mutated schema +instance. The next read will fetch fresh data from the source. ```elixir -defmodule MyApp.Users do - # Evicts all cached collections when a new user is created - def create(params) do - SchemaCache.mutate( - fn -> Actions.create(User, params) end, - behavior: :new_schema - ) - end +{:ok, updated_user} = + SchemaCache.update(fn -> + MyApp.Users.update_user(user, %{name: "Alice"}) + end) +``` - # Evicts all cache keys referencing the updated user - def update(%User{} = user, params) do - SchemaCache.mutate(fn -> Actions.update(User, user, params) end) - end +### Update with Write Through - # Or use write-through to update cached values in place - def update_write_through(%User{} = user, params) do - SchemaCache.mutate( - fn -> Actions.update(User, user, params) end, - behavior: :write_through - ) - end +Update all cached values referencing the schema in place, avoiding cache misses +entirely. Ideal for updates where you want zero-latency reads after the write. - def delete(user) do - SchemaCache.mutate(fn -> Actions.delete(User, user) end) - end -end +```elixir +{:ok, updated_user} = + SchemaCache.update( + fn -> MyApp.Users.update_user(user, %{name: "Alice"}) end, + strategy: :write_through + ) ``` -## Custom Adapters +### Delete -Implement the `SchemaCache.Adapter` behaviour to use your preferred caching backend: +After a deletion, evict all cache keys that reference the deleted schema +instance. ```elixir -defmodule MyApp.RedisAdapter do +{:ok, deleted_user} = + SchemaCache.delete(fn -> Repo.delete(user) end) +``` + +## How SMKES Works + +Schema Mutation Key Eviction Strategy (SMKES) is the core innovation of this +library. It maintains a reverse index from Ecto schemas to every cache key +where they appear. When a schema is mutated, SchemaCache looks up the reverse +index and takes action on every affected cache entry. + +### Key Reference Tracking + +When a query result is cached via `read/4`, SchemaCache inspects the result to +find Ecto structs and records two types of references: + +1. **Instance references**: maps a specific schema instance (e.g. `User#5`) + to every cache key containing it. +2. **Type references**: maps a schema module (e.g. `User`) to every cache key + holding a collection of that type. Used by `create/1` to evict collections + that need to include the newly created record. + +References are stored as compact integer IDs via `SchemaCache.KeyRegistry`, +reducing memory usage by approximately 10x compared to storing full cache key +strings. + +``` + SMKES: Key Reference Tracking + ========================================================================= + + SchemaCache.read("find_user", %{id: 5}, ttl, fn -> ... end) + SchemaCache.read("all_users", %{active: true}, ttl, fn -> ... end) + + Cache Storage Key Reference Sets + +------------------+ +-------------------------+ + | find_user:{...} | | User#5 | + | => %User{id:5} | .----->| => [1, 2] | + +------------------+ / | (integer IDs from | + | / | KeyRegistry) | + cache_key ----+--- sadd ---' +-------------------------+ + +-------------------------+ + +------------------+ .----->| User#8 | + | all_users:{...} | / | => [2] | + | => [%User{id:5}| / +-------------------------+ + | %User{id:8}|---' +-------------------------+ + | ] |- - sadd - ->| User (type) | + +------------------+ | => [2] | + +-------------------------+ +``` + +### Read Through Flow + +``` + SchemaCache.read("find_user", %{id: 5}, ttl, fn -> Repo.get(User, 5) end) + + +--------+ +-----------+ +----------+ + | Caller | -- 1 --> |SchemaCache| -- 2 -> | Adapter | + +--------+ +-----------+ +----------+ + ^ | | + | | cache miss (nil) | + | | <------- 3 ---------| + | | | + | | -- 4 -> callback() --> +------+ + | | | DB | + | | <- 5 -- {:ok, user} <-- +------+ + | | | + | | -- 6 -> put(key, | + | | user, ttl) | + | | | + | | -- 7 -> sadd( | + | | "User#5", | + | | key_id) | + | | | + | <-- 8 -- {:ok, user} | | + | | | + + Subsequent calls with the same key and params return + the cached value at step 3 without invoking the callback. +``` + +### Write Through Flow + +``` + SchemaCache.update(fn -> update_user(user, params) end, strategy: :write_through) + + +--------+ +-----------+ +----------+ + | Caller | -- 1 --> |SchemaCache| | Adapter | + +--------+ +-----------+ +----------+ + ^ | | + | | -- 2 -> callback() --> +------+ + | | | DB | + | | <- 3 -- {:ok, updated} +------+ + | | | + | | -- 4 -> smembers( | + | | "User#5") | + | | | + | | <- 5 -- [id1, id2] | + | | | + | | -- 6 -> resolve IDs, | + | | for each key: | + | | put(key, updated) | + | | | + | <- 7 -- {:ok, updated} | | + | | | + + For collection keys (prefixed with "all_"), SchemaCache + finds the specific item within the list by primary key + and replaces it in place. +``` + +### Eviction Flow (default and create) + +``` + SchemaCache.update(fn -> update_user(user, params) end) + + +--------+ +-----------+ +----------+ + | Caller | -- 1 --> |SchemaCache| | Adapter | + +--------+ +-----------+ +----------+ + ^ | | + | | -- 2 -> callback() --> +------+ + | | | DB | + | | <- 3 -- {:ok, updated} +------+ + | | | + | | -- 4 -> smembers( | + | | "User#5") | + | | | + | | <- 5 -- [id1, id2] | + | | | + | | -- 6 -> resolve IDs, | + | | for each key: | + | | delete(key) | + | | srem("User#5",id) | + | | | + | <- 7 -- {:ok, updated} | | + | | | + + create/1 behaves similarly but looks up keys by the + schema module name (e.g. "User") instead of the instance + identity (e.g. "User#5"), evicting all collections of + that type. +``` + +## Adapter Configuration + +SchemaCache is adapter-agnostic. Any module implementing the +`SchemaCache.Adapter` behaviour can be used as a backend. + +### Built-in: ETS Adapter + +A simple single-node adapter backed by ETS. Does not support TTL. +Suitable for development, testing, and single-node deployments. + +```elixir +config :schema_cache, adapter: SchemaCache.Adapters.ETS +``` + +### Custom Adapters + +Implement the `SchemaCache.Adapter` behaviour with three required callbacks: +`get/1`, `put/3`, and `delete/1`. SchemaCache builds its key reference +tracking on top of these primitives, so your adapter only needs basic +key-value operations. + +Adapters can optionally implement `sadd/2`, `srem/2`, `smembers/1`, and +`mget/1` for native set operations and batch reads. Adapters without these +callbacks automatically fall back to a partitioned lock mechanism via +`SchemaCache.SetLock`. + +```elixir +defmodule MyApp.SchemaCacheAdapter do @behaviour SchemaCache.Adapter @impl true - def get(key), do: # ... + def get(key) do + case MyApp.Cache.get(key) do + nil -> {:ok, nil} + value -> {:ok, value} + end + end @impl true - def put(key, ttl, value), do: # ... + def put(key, value, opts) do + ttl = Keyword.get(opts, :ttl) + MyApp.Cache.put(key, value, ttl: ttl) + :ok + end - # ... implement all callbacks + @impl true + def delete(key) do + MyApp.Cache.delete(key) + :ok + end end ``` -## Documentation +Then configure it: + +```elixir +config :schema_cache, adapter: MyApp.SchemaCacheAdapter +``` + +## Key Conventions + +When caching collections, prefix the key with `all_`. This convention is +required for write-through to correctly identify and update collections in +the cache versus singular values. + +```elixir +# Singular, no prefix +SchemaCache.read("find_user", %{id: 5}, ttl, fn -> ... end) + +# Collection, "all_" prefix +SchemaCache.read("all_users", %{active: true}, ttl, fn -> ... end) +``` + +## API Reference -Full documentation is available on [HexDocs](https://hexdocs.pm/schema_cache). +Full API documentation is available on [HexDocs](https://hexdocs.pm/schema_cache). ## License diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..6756d05 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,5 @@ +import Config + +config :schema_cache, adapter: SchemaCache.Adapters.ETS + +import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..becde76 --- /dev/null +++ b/config/dev.exs @@ -0,0 +1 @@ +import Config diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..becde76 --- /dev/null +++ b/config/prod.exs @@ -0,0 +1 @@ +import Config diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..9e37b85 --- /dev/null +++ b/config/test.exs @@ -0,0 +1,17 @@ +import Config + +config :schema_cache, SchemaCache.Test.Repo, + username: "postgres", + password: "postgres", + hostname: "localhost", + database: "schema_cache_test", + pool: Ecto.Adapters.SQL.Sandbox, + pool_size: 10 + +config :schema_cache, + ecto_repos: [SchemaCache.Test.Repo], + adapter: SchemaCache.Adapters.ETS + +config :schema_cache, :redis_url, "redis://localhost:6379" + +config :logger, level: :warning diff --git a/coveralls.json b/coveralls.json new file mode 100644 index 0000000..d206624 --- /dev/null +++ b/coveralls.json @@ -0,0 +1,5 @@ +{ + "skip_files": [ + "test/support" + ] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..34c9386 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,29 @@ +services: + postgres: + image: postgres:18 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: schema_cache_test + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:8 + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + +volumes: + pgdata: diff --git a/lib/schema_cache.ex b/lib/schema_cache.ex index efd4c77..14b8afd 100644 --- a/lib/schema_cache.ex +++ b/lib/schema_cache.ex @@ -2,5 +2,561 @@ defmodule SchemaCache do @moduledoc """ An Ecto-aware caching library implementing Read Through, Write Through, and Schema Mutation Key Eviction Strategy (SMKES). + + ## Schema Mutation Key Eviction Strategy (SMKES) + + SMKES is the core value of this library. It maintains a reverse index + from your Ecto schemas to every cache key where they appear. When a + schema is mutated (created, updated, or deleted), SchemaCache knows + exactly which cached values are affected and evicts or updates them + automatically, requiring no manual cache invalidation. + + SMKES works by storing cache key references in sets, keyed by schema + identity. When a query result is cached via `read/4`, SchemaCache + records which schemas appear in that result. On mutation, it looks up + all cache keys referencing the mutated schema and takes action: + + * **`create/1`**: evicts every cached collection for the schema + type, so the next read includes the new record. + * **`update/2`** with `:evict` (default): deletes every cached + entry referencing the mutated schema instance. + * **`update/2`** with `:write_through`: updates every cached entry + in place with the new schema value, avoiding cache misses entirely. + * **`delete/1`**: deletes every cached entry referencing the + deleted schema instance. + + ## Adapter + + SchemaCache is adapter-agnostic. Configure your cache backend: + + config :schema_cache, adapter: SchemaCache.Adapters.ETS + + Any module implementing `SchemaCache.Adapter` can be used. This makes + it easy to integrate with Nebulex, ConCache, Cachex, or any other + caching library. See `SchemaCache.Adapter` for the behaviour + specification and an example implementation. + + ## TTL + + TTL is passed through to the adapter. If your backend supports TTL, + it will be used. If not, it is ignored. SchemaCache does not implement + its own TTL mechanism. + + ## Read Through + + The cache is responsible for fetching data from the source on a miss: + + SchemaCache.read("find_user", %{id: 5}, :timer.minutes(15), fn -> + MyApp.Users.find(%{id: 5}) + end) + + On a cache hit, the stored value is returned without invoking the + callback. On a miss, the callback is invoked, the result is cached, + and schema key references are recorded for SMKES. + + ## Write Through + + Updates go through the cache, which updates cached values in place: + + SchemaCache.update( + fn -> MyApp.Users.update_user(user, params) end, + strategy: :write_through + ) + + For singular cache entries, the value is replaced directly. For + collections (keys prefixed with `all_`), SchemaCache locates the + item within the list by primary key and replaces it in place. + + ## Key Conventions + + When caching a collection, prefix the key with `all_`. This convention + is required for write-through to correctly identify and update + collections in the cache. + + SchemaCache.read("all_users", params, ttl, fn -> MyApp.Users.all(params) end) + SchemaCache.read("find_user", params, ttl, fn -> MyApp.Users.find(params) end) """ + + require Logger + + alias SchemaCache.Adapter + alias SchemaCache.KeyGenerator + alias SchemaCache.KeyRegistry + + @async_threshold 100 + + # --- Internal helpers --- + + defp set_key(key), do: "__set:#{key}" + + defp adapter do + with nil <- :persistent_term.get(:schema_cache_adapter, nil), + nil <- Application.get_env(:schema_cache, :adapter) do + raise """ + SchemaCache adapter not configured. + Set config :schema_cache, adapter: YourAdapter + """ + end + end + + defp maybe_async_each(collection, fun) do + if exceeds_threshold?(collection, @async_threshold) do + collection + |> Task.async_stream(fun) + |> Stream.run() + else + Enum.each(collection, fun) + end + end + + defp exceeds_threshold?([], _n), do: false + defp exceeds_threshold?(_list, n) when n <= 0, do: true + defp exceeds_threshold?([_ | rest], n), do: exceeds_threshold?(rest, n - 1) + + # --- Public API --- + + @doc """ + Executes a create callback and evicts all cached collections for the schema type. + + The callback function must return `{:ok, schema}` on success. Any other + return value passes through without triggering cache operations. + + ## Examples + + SchemaCache.create(fn -> + %User{} + |> User.changeset(params) + |> Repo.insert() + end) + """ + @spec create(function()) :: {:ok, struct()} | {:error, any()} + def create(fnc) do + with {:ok, schema} <- fnc.() do + schema + |> tap(&flush(&1, :new_schema)) + |> then(&{:ok, &1}) + end + end + + @doc """ + Executes an update callback and handles cache invalidation. + + By default, evicts all cache keys referencing the schema instance. + Pass `strategy: :write_through` to update cached values in place. + + ## Options + + * `:strategy` - Defines the cache update strategy: + * `:evict` - Evicts all cache keys referencing the mutated schema + instance. **(default)** + * `:write_through` - Updates all cached values referencing the schema + in place, including both singular entries and items within collections. + Collection updates are not atomic (see `write_to_cache/2`). + + * `:ttl` - TTL to pass through to the adapter when using `:write_through`. + Ignored for other strategies. + + ## Examples + + # Update with default eviction + SchemaCache.update(fn -> Actions.update(User, user, params) end) + + # Update with write-through + SchemaCache.update( + fn -> Actions.update(User, user, params) end, + strategy: :write_through + ) + """ + @spec update(function(), keyword()) :: {:ok, struct()} | {:error, any()} + def update(fnc, opts \\ []) do + case Keyword.get(opts, :strategy, :evict) do + :evict -> do_evict_keys(fnc) + :write_through -> do_write_through(fnc, opts) + end + end + + @doc """ + Executes a delete callback and evicts all cache keys referencing the schema. + + The callback function must return `{:ok, schema}` on success. Any other + return value passes through without triggering cache operations. + + ## Examples + + SchemaCache.delete(fn -> Repo.delete(user) end) + """ + @spec delete(function()) :: {:ok, struct()} | {:error, any()} + def delete(fnc) do + with {:ok, schema} <- fnc.() do + schema + |> tap(&flush/1) + |> then(&{:ok, &1}) + end + end + + defp do_evict_keys(fnc) do + with {:ok, schema} <- fnc.() do + schema + |> tap(&flush/1) + |> then(&{:ok, &1}) + end + end + + defp do_write_through(fnc, opts) do + with {:ok, schema} <- fnc.() do + schema + |> tap(&write_to_cache(&1, Keyword.get(opts, :ttl))) + |> then(&{:ok, &1}) + end + end + + @doc """ + Directly updates all cached values that reference the given schema. + + Unlike `update/2`, this function does not invoke a callback or perform + any database operation. It simply pushes the given struct into every + cache entry that references it, using the same write-through logic as + `update/2` with `strategy: :write_through`. + + Useful when a virtual field or computed attribute changes and only the + cache needs updating, without a database write. + + **Note:** write-through updates to collection cache entries are not + atomic. Between reading and writing the collection, another process + could modify it. This is a best-effort optimization; the next `read/4` + cache miss will always fetch the correct state from the source. + + ## Examples + + # Update the cache after computing a virtual field + user = %{user | online_status: :active} + :ok = SchemaCache.write_to_cache(user) + + # With explicit TTL + :ok = SchemaCache.write_to_cache(user, :timer.minutes(15)) + """ + @spec write_to_cache(struct(), any()) :: :ok + def write_to_cache(schema, ttl \\ nil) do + set_k = + schema + |> KeyGenerator.schema_cache_key() + |> set_key() + + a = adapter() + + case Adapter.smembers(a, set_k) do + {:ok, ids} when is_list(ids) -> + do_write_through_refs(a, set_k, ids, ttl, schema) + + _ -> + :ok + end + end + + defp do_write_through_refs(a, set_k, ids, ttl, schema) do + resolved = KeyRegistry.resolve(ids) + + clean_unresolved_ids(a, set_k, ids, resolved) + + cache_keys = Enum.map(resolved, &elem(&1, 1)) + + case Adapter.mget(a, cache_keys) do + {:ok, values} -> + {live, stale} = + resolved + |> Enum.zip(values) + |> Enum.split_with(fn {_, val} -> val != nil end) + + Enum.each(stale, fn {{id, _key}, _} -> + Adapter.srem(a, set_k, id) + KeyRegistry.unregister_id(id) + end) + + live + |> Enum.map(fn {{_id, key_ref}, _} -> key_ref end) + |> maybe_async_each(&update_key_ref(&1, ttl, schema)) + + {:error, reason} -> + Logger.warning("[SchemaCache] write-through mget failed: #{inspect(reason)}") + :ok + end + end + + defp update_key_ref( + "all_" <> _ = key_ref, + ttl, + %_{} = value + ) do + case adapter().get(key_ref) do + {:ok, nil} -> + :ok + + {:ok, cached_collection} when is_list(cached_collection) -> + maybe_update_cached_collection(cached_collection, key_ref, ttl, value) + + _ -> + :ok + end + end + + defp update_key_ref(key_ref, ttl, value) do + adapter().put(key_ref, value, ttl: ttl) + end + + defp maybe_update_cached_collection( + cached_collection, + key_ref, + ttl, + %schema{} = value + ) do + schema.__schema__(:primary_key) + |> Enum.reduce( + %{}, + &Map.put(&2, &1, Map.get(value, &1)) + ) + |> then( + &Enum.find_index( + cached_collection, + fn el -> match_schema_by_primary_keys(el, &1) end + ) + ) + |> case do + nil -> + :ok + + idx -> + cached_collection + |> List.replace_at(idx, value) + |> then(&adapter().put(key_ref, &1, ttl: ttl)) + end + end + + defp match_schema_by_primary_keys(collection_elem, pks_sig) do + Enum.all?(pks_sig, fn {k, v} -> + Map.get(collection_elem, k) === v + end) + end + + # --- Read --- + + @doc """ + Reads a value from the cache, falling back to the source on a miss. + + On a cache hit, the stored value is returned immediately. On a miss, + the callback is invoked, the result is cached, and SMKES key references + are recorded so that future mutations can evict or update this entry. + + The cache key is built from `key` and `params` using + `SchemaCache.KeyGenerator.cache_key/2`. Two calls with the same `key` + and `params` will resolve to the same cache entry. + + ## Arguments + + * `key` - A string prefix identifying the query. Use `"all_"` prefix + for collections so write-through can distinguish them from singular + values. + * `params` - A map of query parameters. Combined with `key` to form + the full cache key. + * `ttl` - Time-to-live passed through to the adapter. Pass `nil` to + cache without expiry (adapter-dependent). + * `fnc` - A zero-arity function that fetches the value from the source. + Should return `{:ok, struct}` for singular results or a list for + collections. + + ## Return Values + + * `{:ok, value}`: cache hit for a singular value, or freshly fetched + and cached singular value. + * `list`: cache hit for a collection, or freshly fetched and cached + collection. + * `[]`: empty list results are **not cached** and pass through directly. + * `{:error, reason}`: error results from the callback pass through + without being cached. + + ## Examples + + # Singular record + {:ok, user} = + SchemaCache.read("find_user", %{id: 5}, :timer.minutes(15), fn -> + MyApp.Users.find(%{id: 5}) + end) + + # Collection + users = + SchemaCache.read("all_users", %{active: true}, :timer.minutes(5), fn -> + MyApp.Users.all(%{active: true}) + end) + """ + @spec read(binary(), map(), nil | pos_integer(), function()) :: any() + def read(key, params, ttl \\ nil, fnc) do + cache_key = KeyGenerator.cache_key(key, params) + + case adapter().get(cache_key) do + {:ok, nil} -> + get_set_value(cache_key, ttl, fnc) + + {:ok, val} when is_list(val) -> + val + + {:ok, val} -> + {:ok, val} + + error -> + fetch_from_source(fnc, error) + end + end + + defp get_set_value(cache_key, ttl, fnc) do + case fnc.() do + {:ok, value} -> + adapter().put(cache_key, value, ttl: ttl) + associate_key_reference_with_schema(cache_key, value) + {:ok, value} + + [] -> + [] + + value when is_list(value) -> + adapter().put(cache_key, value, ttl: ttl) + associate_key_reference_with_schema(cache_key, value) + associate_key_reference_with_schema_type(cache_key, value) + value + + res -> + res + end + end + + defp fetch_from_source(fnc, error) do + Logger.error(""" + [SchemaCache] Unable to fetch from cache, falling back to source. + Error: #{inspect(error)} + """) + + fnc.() + end + + # --- Flush / eviction --- + + @doc """ + Evicts cached query results using SMKES. + + This is the low-level eviction function used internally by `update/2` + and `delete/1`. You can call it directly when you need to invalidate + cache entries for a schema without performing a mutation through + SchemaCache. + + ## 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 + the schema's module type. Use when a new record has been created + outside of `create/1`. + + ## Examples + + # Evict all cache entries referencing a specific user + :ok = SchemaCache.flush(user) + + # Evict all cached User collections (e.g. after an external insert) + :ok = SchemaCache.flush(user, :new_schema) + """ + @spec flush(struct(), nil | atom()) :: :ok + def flush(schema, opts \\ nil) + + def flush(%schema_type{} = _schema, :new_schema) do + evict_reference_keys("#{schema_type}") + end + + def flush(%_{} = schema, _opts) do + schema + |> KeyGenerator.schema_cache_key() + |> evict_reference_keys() + end + + defp evict_reference_keys(key) do + set_k = set_key(key) + a = adapter() + + case Adapter.smembers(a, set_k) do + {:ok, ids} when is_list(ids) -> + do_evict_resolved(a, set_k, ids) + + _ -> + :ok + end + end + + defp do_evict_resolved(a, set_k, ids) do + resolved = KeyRegistry.resolve(ids) + + clean_unresolved_ids(a, set_k, ids, resolved) + + cache_keys = Enum.map(resolved, &elem(&1, 1)) + + case Adapter.mget(a, cache_keys) do + {:ok, values} -> + {live, stale} = + resolved + |> Enum.zip(values) + |> Enum.split_with(fn {_, val} -> val != nil end) + + Enum.each(stale, fn {{id, _key}, _} -> + Adapter.srem(a, set_k, id) + KeyRegistry.unregister_id(id) + end) + + maybe_async_each(live, fn {{id, key_ref}, _} -> + a.delete(key_ref) + Adapter.srem(a, set_k, id) + KeyRegistry.unregister_id(id) + end) + + {:error, reason} -> + Logger.warning("[SchemaCache] eviction mget failed: #{inspect(reason)}") + :ok + end + end + + defp clean_unresolved_ids(a, set_k, ids, resolved) do + resolved_ids = MapSet.new(resolved, &elem(&1, 0)) + + ids + |> Enum.reject(&MapSet.member?(resolved_ids, &1)) + |> Enum.each(&Adapter.srem(a, set_k, &1)) + end + + # --- Key reference association --- + + defp associate_key_reference_with_schema_type( + cache_key, + [%resource_type{} | _] = value + ) + when is_list(value) 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 + maybe_async_each(value, &associate_key_reference_with_schema(cache_key, &1)) + end + + defp associate_key_reference_with_schema(cache_key, %_{} = value) do + value + |> KeyGenerator.schema_cache_key() + |> set_key() + |> then(&Adapter.sadd(adapter(), &1, KeyRegistry.register(cache_key))) + end + + defp associate_key_reference_with_schema(_, _), do: :ok end diff --git a/lib/schema_cache/adapter.ex b/lib/schema_cache/adapter.ex index 4dd1225..c7ec5ad 100644 --- a/lib/schema_cache/adapter.ex +++ b/lib/schema_cache/adapter.ex @@ -2,7 +2,209 @@ defmodule SchemaCache.Adapter do @moduledoc """ Behaviour for cache adapter implementations. - SchemaCache is adapter-agnostic. Implement this behaviour to use - your preferred caching backend (Nebulex, ConCache, ETS, etc.). + SchemaCache is adapter-agnostic. Implement this behaviour to plug in + any caching backend: Nebulex, ConCache, Cachex, Redis, ETS, etc. + + The behaviour requires three core operations: `get/1`, `put/3`, and + `delete/1`. SchemaCache builds its key reference tracking (SMKES) on + top of these primitives. + + Adapters can optionally implement `init/0`, `sadd/2`, `srem/2`, + `smembers/1`, and `mget/1`. `init/0` is called by + `SchemaCache.Supervisor` during startup, giving adapters a chance to + create tables, open connections, or perform other setup. Adapters with + native set operations (e.g., Redis SADD/SREM) should implement `sadd/2`, + `srem/2`, and `smembers/1` to avoid the partitioned lock fallback that + SchemaCache uses for adapters without them. The fallback is correct but + slower. + + ## Return Conventions + + * `get/1` must return `{:ok, value}` on a hit, `{:ok, nil}` on a + miss, or `{:error, reason}` on failure. SchemaCache falls back to + the source function when it receives an error tuple. + * `put/3` must return `:ok` or `{:error, reason}`. + * `delete/1` must return `:ok` or `{:error, reason}`. + + ## TTL + + TTL is passed as a `:ttl` option in the `opts` keyword list of `put/3`. + If your backend supports TTL, extract it with `Keyword.get(opts, :ttl)` + and use it. If not, ignore it. SchemaCache does not implement its own + TTL mechanism. + + ## Configuration + + Set the adapter in your application config: + + config :schema_cache, adapter: MyApp.SchemaCacheAdapter + + ## Example: Nebulex Adapter + + defmodule MyApp.SchemaCacheAdapter do + @behaviour SchemaCache.Adapter + + @impl true + def get(key) do + case MyApp.Cache.get(key) do + nil -> {:ok, nil} + value -> {:ok, value} + end + end + + @impl true + def put(key, value, opts) do + MyApp.Cache.put(key, value, opts) + :ok + end + + @impl true + def delete(key) do + MyApp.Cache.delete(key) + :ok + end + end + + ## Example: Cachex Adapter + + defmodule MyApp.CachexAdapter do + @behaviour SchemaCache.Adapter + + @impl true + def get(key) do + case Cachex.get(:my_cache, key) do + {:ok, nil} -> {:ok, nil} + {:ok, value} -> {:ok, value} + {:error, _} = err -> err + end + end + + @impl true + def put(key, value, opts) do + ttl = Keyword.get(opts, :ttl) + Cachex.put(:my_cache, key, value, ttl: ttl) + :ok + end + + @impl true + def delete(key) do + Cachex.del(:my_cache, key) + :ok + end + end """ + + @doc """ + Fetches a value by key. + + Returns `{:ok, value}` on a cache hit, `{:ok, nil}` on a cache miss, + or `{:error, reason}` if the backend is unavailable. When SchemaCache + receives an error tuple, it logs a warning and falls back to the source + function. + """ + @callback get(key :: String.t()) :: {:ok, any()} | {:error, any()} + + @doc """ + Stores a value at the given key. + + The `opts` keyword list may include `:ttl` (time-to-live in + milliseconds). If your backend supports TTL, use it. If not, ignore it. + """ + @callback put(key :: String.t(), value :: any(), opts :: keyword()) :: :ok | {:error, any()} + + @doc """ + Deletes a key from the cache. + + Called during SMKES eviction to remove cached query results that + reference a mutated schema. Should return `:ok` even if the key + does not exist. + """ + @callback delete(key :: String.t()) :: :ok | {:error, any()} + + @doc """ + Optional initialization callback invoked by `SchemaCache.Supervisor` + at startup. + + Use this to create ETS tables, open connections, or perform other setup + that should happen once when the application starts. Must be idempotent + to handle supervisor restarts gracefully. + """ + @callback init() :: :ok + + @doc "Atomically adds a member to a set identified by key." + @callback sadd(key :: String.t(), member :: term()) :: :ok | {:error, any()} + + @doc "Atomically removes a member from a set identified by key." + @callback srem(key :: String.t(), member :: term()) :: :ok | {:error, any()} + + @doc "Returns all members of a set." + @callback smembers(key :: String.t()) :: {:ok, list() | nil} | {:error, any()} + + @doc "Fetches multiple keys in a single operation." + @callback mget(keys :: [String.t()]) :: {:ok, [any() | nil]} | {:error, any()} + + @optional_callbacks [init: 0, sadd: 2, srem: 2, smembers: 1, mget: 1] + + # --- Boot-time setup --- + + @doc false + def init(adapter) do + if function_exported?(adapter, :init, 0) do + adapter.init() + else + :ok + end + end + + @doc false + def resolve_capabilities(adapter) do + :persistent_term.put(:schema_cache_adapter_caps, %{ + sadd: function_exported?(adapter, :sadd, 2), + srem: function_exported?(adapter, :srem, 2), + smembers: function_exported?(adapter, :smembers, 1), + mget: function_exported?(adapter, :mget, 1) + }) + end + + # --- Runtime dispatch --- + + @doc false + def sadd(adapter, key, member) do + if capability(:sadd) do + adapter.sadd(key, member) + else + SchemaCache.SetLock.sadd(key, member, adapter) + end + end + + @doc false + def srem(adapter, key, member) do + if capability(:srem) do + adapter.srem(key, member) + else + SchemaCache.SetLock.srem(key, member, adapter) + end + end + + @doc false + def smembers(adapter, key) do + if capability(:smembers) do + adapter.smembers(key) + else + SchemaCache.SetLock.smembers(key, adapter) + end + end + + @doc false + def mget(adapter, keys) do + if capability(:mget) do + adapter.mget(keys) + else + SchemaCache.SetLock.mget(keys, adapter) + end + end + + defp capability(name) do + :persistent_term.get(:schema_cache_adapter_caps)[name] + end end diff --git a/lib/schema_cache/adapters/ets.ex b/lib/schema_cache/adapters/ets.ex new file mode 100644 index 0000000..9a5d60d --- /dev/null +++ b/lib/schema_cache/adapters/ets.ex @@ -0,0 +1,104 @@ +defmodule SchemaCache.Adapters.ETS do + @moduledoc """ + A simple ETS-based cache adapter for single-node usage. + + This adapter uses two named ETS tables: + + * `:schema_cache_ets`: a `:set` table for regular key-value data + * `:schema_cache_ets_sets`: a `:bag` table for native set operations + + The set table provides atomic `sadd/2`, `srem/2`, and `smembers/1` + operations backed by ETS `:bag` insert and delete_object, which are + atomic per-object operations. This avoids the partitioned lock + fallback used by adapters without native set support. + + This adapter does **not** support TTL. The `:ttl` option in `put/3` is + accepted but ignored. If you need TTL, use an adapter backed by a + library that provides it (Nebulex, ConCache, Cachex, etc.). + + ## When to Use + + * Development and testing + * Single-node deployments where simplicity is preferred + * Prototyping before integrating a production cache backend + + ## Limitations + + * No TTL support: cached entries persist until explicitly evicted + * Single-node only, not shared across a cluster + * No memory limits; the table grows without bound + + ## Configuration + + config :schema_cache, adapter: SchemaCache.Adapters.ETS + """ + + @behaviour SchemaCache.Adapter + + @data_table :schema_cache_ets + @set_table :schema_cache_ets_sets + + @impl true + def init do + :ets.new(@data_table, [:set, :public, :named_table]) + :ets.new(@set_table, [:bag, :public, :named_table]) + :ok + rescue + ArgumentError -> :ok + end + + @impl true + def get(key) do + case :ets.lookup(@data_table, key) do + [{^key, value}] -> {:ok, value} + [] -> {:ok, nil} + end + end + + @impl true + def put(key, value, _opts \\ []) do + :ets.insert(@data_table, {key, value}) + :ok + end + + @impl true + def delete(key) do + :ets.delete(@data_table, key) + :ok + end + + @impl true + def sadd(key, member) do + :ets.insert(@set_table, {key, member}) + :ok + end + + @impl true + def srem(key, member) do + :ets.delete_object(@set_table, {key, member}) + :ok + end + + @impl true + def smembers(key) do + @set_table + |> :ets.lookup(key) + |> Enum.map(&elem(&1, 1)) + |> case do + [] -> {:ok, nil} + members -> {:ok, members} + end + end + + @impl true + def mget(keys) do + keys + |> Enum.map(fn key -> + case :ets.lookup(@data_table, key) do + [{^key, value}] -> value + [] -> nil + end + end) + |> then(&{:ok, &1}) + end +end diff --git a/lib/schema_cache/key_generator.ex b/lib/schema_cache/key_generator.ex index dc9c61f..83b291e 100644 --- a/lib/schema_cache/key_generator.ex +++ b/lib/schema_cache/key_generator.ex @@ -1,5 +1,78 @@ defmodule SchemaCache.KeyGenerator do @moduledoc """ - Generates cache keys from Ecto schema structs and query parameters. + Generates deterministic cache keys from Ecto schema structs and query + parameters. + + This module is used internally by `SchemaCache` to produce two types + of keys: + + * **Cache keys** (`cache_key/2`): combine a caller-provided string + prefix with JSON-encoded query parameters to uniquely identify a + cached query result. For example, `"find_user"` + `%{id: 5}` + produces `"find_user:{\"id\":5}"`. + + * **Schema cache keys** (`schema_cache_key/1`): identify a specific + Ecto schema instance by its module name and primary key values. + These keys are used as set identifiers in SMKES to track which + cache keys reference a given schema. For example, + `%MyApp.User{id: 5}` produces `"Elixir.MyApp.User:5"`. Composite + primary keys are joined with colons. """ + + @doc ~S""" + Builds a cache key from a string prefix and a map of query parameters. + + The params map is JSON-encoded and appended to the prefix, separated + by a colon. Keyword list values under the `:order_by` key are + automatically converted to maps before encoding to ensure consistent + key generation regardless of keyword list ordering. + + ## Examples + + iex> SchemaCache.KeyGenerator.cache_key("find_user", %{id: 5}) + "find_user:{\"id\":5}" + + iex> SchemaCache.KeyGenerator.cache_key("foo", %{order_by: [fiz: "buz"]}) + "foo:{\"order_by\":{\"fiz\":\"buz\"}}" + + iex> SchemaCache.KeyGenerator.cache_key("all_users", %{}) + "all_users:{}" + + """ + @spec cache_key(String.t(), map()) :: String.t() + def cache_key(key, params) do + params + |> maybe_convert_order_by_into_map() + |> then(&"#{key}:#{Jason.encode!(&1)}") + end + + @doc ~S""" + Builds a schema identity key from an Ecto struct. + + The key is formed by concatenating the full module name with the + primary key value(s), separated by colons. This key uniquely + identifies a schema instance and is used as the set identifier in + SMKES to track which cache keys reference this specific record. + + Composite primary keys are supported; each primary key field value + is joined with a colon in the order defined by the schema. + + ## Examples + + iex> SchemaCache.KeyGenerator.schema_cache_key(%SchemaCache.Test.FakeSchema{id: 1}) + "Elixir.SchemaCache.Test.FakeSchema:1" + + """ + @spec schema_cache_key(struct()) :: String.t() + def schema_cache_key(%schema{} = value) do + schema.__schema__(:primary_key) + |> Enum.map_join(":", &Map.get(value, &1)) + |> then(&"#{schema}:#{&1}") + end + + defp maybe_convert_order_by_into_map(%{order_by: order_by} = params) do + Map.put(params, :order_by, Enum.into(order_by, %{})) + end + + defp maybe_convert_order_by_into_map(params), do: params end diff --git a/lib/schema_cache/key_registry.ex b/lib/schema_cache/key_registry.ex new file mode 100644 index 0000000..89fba60 --- /dev/null +++ b/lib/schema_cache/key_registry.ex @@ -0,0 +1,118 @@ +defmodule SchemaCache.KeyRegistry do + @moduledoc """ + Bidirectional mapping between cache key strings and compact integer IDs. + + SMKES reference sets store integer IDs instead of full cache key strings, + reducing memory usage significantly at scale. A cache key like + `"all_users:{\"active\":true,\"order_by\":{\"name\":\"asc\"}}"` consumes + ~80 bytes per set reference; an integer ID consumes ~8 bytes. + + The registry uses two named ETS tables for O(1) lookups in both + directions: + + * `:schema_cache_key_to_id`: `{cache_key, id}` + * `:schema_cache_id_to_key`: `{id, cache_key}` + + ID generation uses `:atomics.add_get/3` for lock-free monotonic + assignment. Concurrent registrations of the same key are safe: the + first writer wins via `:ets.insert_new/2`, and subsequent callers + return the existing ID. The losing caller's counter increment is + never stored, but this is cosmetic; the 64-bit counter space is + effectively inexhaustible. + + Tables are created by `SchemaCache.Supervisor` during init and owned + by the supervisor process. + """ + + @key_to_id :schema_cache_key_to_id + @id_to_key :schema_cache_id_to_key + + @doc false + def init do + ensure_table(@key_to_id, :set) + ensure_table(@id_to_key, :set) + + unless :persistent_term.get(:schema_cache_key_registry_counter, nil) do + :persistent_term.put( + :schema_cache_key_registry_counter, + :atomics.new(1, signed: true) + ) + end + + :ok + end + + @spec register(String.t()) :: integer() + def register(cache_key) do + :schema_cache_key_registry_counter + |> :persistent_term.get() + |> :atomics.add_get(1, 1) + |> try_insert(cache_key) + end + + defp try_insert(id, cache_key) do + case :ets.insert_new(@key_to_id, {cache_key, id}) do + true -> + :ets.insert(@id_to_key, {id, cache_key}) + id + + false -> + case :ets.lookup(@key_to_id, cache_key) do + [{^cache_key, existing_id}] -> existing_id + [] -> try_insert(id, cache_key) + end + end + end + + @spec lookup(integer()) :: {:ok, String.t() | nil} + def lookup(id) do + case :ets.lookup(@id_to_key, id) do + [{^id, cache_key}] -> {:ok, cache_key} + [] -> {:ok, nil} + end + end + + @spec resolve([integer()]) :: [{integer(), String.t()}] + def resolve(ids) do + Enum.reduce(ids, [], fn id, acc -> + case :ets.lookup(@id_to_key, id) do + [{^id, cache_key}] -> [{id, cache_key} | acc] + [] -> acc + end + end) + end + + @spec unregister(String.t()) :: :ok + def unregister(cache_key) do + case :ets.lookup(@key_to_id, cache_key) do + [{^cache_key, id}] -> + :ets.delete(@key_to_id, cache_key) + :ets.delete(@id_to_key, id) + + [] -> + :ok + end + + :ok + end + + @spec unregister_id(integer()) :: :ok + def unregister_id(id) do + case :ets.lookup(@id_to_key, id) do + [{^id, cache_key}] -> + :ets.delete(@id_to_key, id) + :ets.delete(@key_to_id, cache_key) + + [] -> + :ok + end + + :ok + end + + defp ensure_table(name, type) do + :ets.new(name, [type, :public, :named_table]) + rescue + ArgumentError -> name + end +end diff --git a/lib/schema_cache/set_lock.ex b/lib/schema_cache/set_lock.ex new file mode 100644 index 0000000..12f30dd --- /dev/null +++ b/lib/schema_cache/set_lock.ex @@ -0,0 +1,97 @@ +defmodule SchemaCache.SetLock do + @moduledoc """ + Partitioned lock fallback for adapters without native set operations. + + When an adapter does not implement the optional `sadd/2`, `srem/2`, and + `smembers/1` callbacks, SchemaCache routes set operations through this + module. It serializes read-modify-write cycles using a partitioned + `Registry` as a lock pool, storing sets as `MapSet` values in the + adapter's key-value store. + + The lock is partitioned by `System.schedulers_online/0` to reduce + contention. Concurrent writes to different set keys rarely contend, + while writes to the same key are serialized by hashing to the same + partition. + """ + + @registry SchemaCache.SetLock.Registry + @max_retries 100 + @retry_sleep 1 + @lock_partition_multiplier 4 + + @spec locked_update(String.t(), module(), (MapSet.t() -> MapSet.t())) :: :ok + def locked_update(set_key, adapter, update_fn) do + set_key + |> :erlang.phash2(System.schedulers_online() * @lock_partition_multiplier) + |> do_locked_update(set_key, adapter, update_fn, 0) + end + + defp do_locked_update(_lock_key, _set_key, _adapter, _update_fn, @max_retries) do + raise "SchemaCache.SetLock: timed out acquiring lock" + end + + defp do_locked_update(lock_key, set_key, adapter, update_fn, attempt) do + case Registry.register(@registry, lock_key, nil) do + {:ok, _} -> + try do + set_key + |> adapter.get() + |> current_set() + |> update_fn.() + |> then(&adapter.put(set_key, &1, [])) + + :ok + after + Registry.unregister(@registry, lock_key) + end + + {:error, _} -> + Process.sleep(@retry_sleep) + do_locked_update(lock_key, set_key, adapter, update_fn, attempt + 1) + end + end + + defp current_set({:ok, %MapSet{} = set}), do: set + defp current_set(_), do: MapSet.new() + + @spec sadd(String.t(), term(), module()) :: :ok + def sadd(set_key, member, adapter) do + locked_update(set_key, adapter, &MapSet.put(&1, member)) + end + + @spec srem(String.t(), term(), module()) :: :ok + def srem(set_key, member, adapter) do + locked_update(set_key, adapter, &MapSet.delete(&1, member)) + end + + @spec smembers(String.t(), module()) :: {:ok, list() | nil} | {:error, any()} + def smembers(set_key, adapter) do + set_key + |> adapter.get() + |> format_smembers_result() + end + + defp format_smembers_result({:ok, %MapSet{} = set}) do + set + |> MapSet.to_list() + |> case do + [] -> {:ok, nil} + members -> {:ok, members} + end + end + + defp format_smembers_result({:ok, nil}), do: {:ok, nil} + defp format_smembers_result(error), do: error + + @spec mget([String.t()], module()) :: {:ok, [any() | nil]} + def mget(keys, adapter) do + keys + |> Enum.map(fn key -> + case adapter.get(key) do + {:ok, value} -> value + _ -> nil + end + end) + |> then(&{:ok, &1}) + end +end diff --git a/lib/schema_cache/supervisor.ex b/lib/schema_cache/supervisor.ex new file mode 100644 index 0000000..0afb0f1 --- /dev/null +++ b/lib/schema_cache/supervisor.ex @@ -0,0 +1,34 @@ +defmodule SchemaCache.Supervisor do + @moduledoc false + + use Supervisor + + alias SchemaCache.Adapter + + def start_link(opts \\ []) do + Supervisor.start_link(__MODULE__, opts, name: __MODULE__) + end + + @impl true + def init(opts) do + adapter = + with nil <- Keyword.get(opts, :adapter), + nil <- Application.get_env(:schema_cache, :adapter) do + raise "SchemaCache adapter not configured. Set config :schema_cache, adapter: YourAdapter" + end + + :persistent_term.put(:schema_cache_adapter, adapter) + + Adapter.init(adapter) + Adapter.resolve_capabilities(adapter) + + SchemaCache.KeyRegistry.init() + + children = [ + {Registry, + keys: :unique, name: SchemaCache.SetLock.Registry, partitions: System.schedulers_online()} + ] + + Supervisor.init(children, strategy: :one_for_one) + end +end diff --git a/mix.exs b/mix.exs index 8432d48..2af9f5c 100644 --- a/mix.exs +++ b/mix.exs @@ -9,14 +9,31 @@ defmodule SchemaCache.MixProject do app: :schema_cache, version: @version, elixir: "~> 1.14", + elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, deps: deps(), + aliases: aliases(), package: package(), docs: docs(), name: "SchemaCache", source_url: @source_url, - description: "An Ecto-aware caching library implementing Read Through, Write Through, and Schema Mutation Key Eviction Strategy (SMKES).", - dialyzer: [plt_add_apps: [:ecto]] + description: + "An Ecto-aware caching library implementing Read Through, Write Through, and Schema Mutation Key Eviction Strategy (SMKES).", + dialyzer: [plt_add_apps: [:ecto, :ex_unit]], + test_coverage: [tool: ExCoveralls] + ] + end + + def cli do + [ + preferred_envs: [ + coveralls: :test, + "coveralls.detail": :test, + "coveralls.post": :test, + "coveralls.html": :test, + "coveralls.json": :test, + "coveralls.lcov": :test + ] ] end @@ -26,13 +43,27 @@ defmodule SchemaCache.MixProject do ] end + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + defp deps do [ {:ecto, "~> 3.10"}, + {:ecto_sql, "~> 3.10", only: :test}, + {:postgrex, "~> 0.19", only: :test}, {:jason, "~> 1.4"}, {:ex_doc, "~> 0.31", only: :dev, runtime: false}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, - {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false} + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, + {:excoveralls, "~> 0.18", only: :test}, + {:redix, "~> 1.5", only: :test} + ] + end + + defp aliases do + [ + "ecto.setup": ["ecto.create --quiet", "ecto.migrate --migrations-path priv/test/migrations"], + "ecto.reset": ["ecto.drop", "ecto.setup"] ] end @@ -45,9 +76,16 @@ defmodule SchemaCache.MixProject do defp docs do [ - main: "SchemaCache", + main: "readme", source_ref: "v#{@version}", - source_url: @source_url + source_url: @source_url, + extras: ["README.md"], + groups_for_modules: [ + Core: [SchemaCache], + "Adapter Behaviour": [SchemaCache.Adapter], + "Built-in Adapters": [SchemaCache.Adapters.ETS], + Internals: [SchemaCache.KeyGenerator] + ] ] end end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..93f2fb9 --- /dev/null +++ b/mix.lock @@ -0,0 +1,23 @@ +%{ + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "credo": {:hex, :credo, "1.7.16", "a9f1389d13d19c631cb123c77a813dbf16449a2aebf602f590defa08953309d4", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "d0562af33756b21f248f066a9119e3890722031b6d199f22e3cf95550e4f1579"}, + "db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"}, + "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, + "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, + "ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"}, + "ecto_sql": {:hex, :ecto_sql, "3.13.4", "b6e9d07557ddba62508a9ce4a484989a5bb5e9a048ae0e695f6d93f095c25d60", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2b38cf0749ca4d1c5a8bcbff79bbe15446861ca12a61f9fba604486cb6b62a14"}, + "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, + "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"}, + "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, + "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"}, + "redix": {:hex, :redix, "1.5.3", "4eaae29c75e3285c0ff9957046b7c209aa7f72a023a17f0a9ea51c2a50ab5b0f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7b06fb5246373af41f5826b03334dfa3f636347d4d5d98b4d455b699d425ae7e"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, +} diff --git a/priv/test/migrations/20260220000001_create_users.exs b/priv/test/migrations/20260220000001_create_users.exs new file mode 100644 index 0000000..3623e1f --- /dev/null +++ b/priv/test/migrations/20260220000001_create_users.exs @@ -0,0 +1,14 @@ +defmodule SchemaCache.Test.Repo.Migrations.CreateUsers do + use Ecto.Migration + + def change do + create table(:users) do + add :name, :string, null: false + add :email, :string, null: false + + timestamps() + end + + create unique_index(:users, [:email]) + end +end diff --git a/test/schema_cache/adapter_dispatch_test.exs b/test/schema_cache/adapter_dispatch_test.exs new file mode 100644 index 0000000..b4e22a3 --- /dev/null +++ b/test/schema_cache/adapter_dispatch_test.exs @@ -0,0 +1,192 @@ +defmodule SchemaCache.AdapterDispatchTest do + @moduledoc false + + use ExUnit.Case, async: false + + alias SchemaCache.Adapter + + @ets_tables [ + :schema_cache_ets, + :schema_cache_ets_sets, + :schema_cache_key_to_id, + :schema_cache_id_to_key + ] + + setup do + for table <- @ets_tables do + if :ets.whereis(table) != :undefined do + :ets.delete_all_objects(table) + end + end + + :ok + end + + defmodule MinimalAdapter do + @moduledoc false + @behaviour SchemaCache.Adapter + + @impl true + def get(key) do + case :ets.lookup(:schema_cache_ets, key) do + [{^key, value}] -> {:ok, value} + [] -> {:ok, nil} + end + end + + @impl true + def put(key, value, _opts) do + :ets.insert(:schema_cache_ets, {key, value}) + :ok + end + + @impl true + def delete(key) do + :ets.delete(:schema_cache_ets, key) + :ok + end + end + + describe "sadd/3" do + setup do + original_caps = :persistent_term.get(:schema_cache_adapter_caps) + + on_exit(fn -> + :persistent_term.put(:schema_cache_adapter_caps, original_caps) + end) + + :ok + end + + test "routes through SetLock when adapter lacks native sadd" do + Adapter.resolve_capabilities(MinimalAdapter) + + assert :ok = Adapter.sadd(MinimalAdapter, "test_set", "member_1") + + # Verify the member was stored via SetLock (as a MapSet in the KV store) + assert {:ok, %MapSet{} = set} = MinimalAdapter.get("test_set") + assert MapSet.member?(set, "member_1") + end + + test "goes native with ETS adapter" do + adapter = SchemaCache.Adapters.ETS + Adapter.resolve_capabilities(adapter) + + assert :ok = Adapter.sadd(adapter, "native_set", "member_1") + + # Native ETS adapter uses the :bag table, not a MapSet in the :set table. + # Verify through smembers which also goes native. + assert {:ok, members} = Adapter.smembers(adapter, "native_set") + assert "member_1" in members + end + end + + describe "srem/3" do + setup do + original_caps = :persistent_term.get(:schema_cache_adapter_caps) + + on_exit(fn -> + :persistent_term.put(:schema_cache_adapter_caps, original_caps) + end) + + :ok + end + + test "routes through SetLock when adapter lacks native srem" do + Adapter.resolve_capabilities(MinimalAdapter) + + Adapter.sadd(MinimalAdapter, "test_set", "member_1") + Adapter.sadd(MinimalAdapter, "test_set", "member_2") + + assert :ok = Adapter.srem(MinimalAdapter, "test_set", "member_1") + + assert {:ok, %MapSet{} = set} = MinimalAdapter.get("test_set") + refute MapSet.member?(set, "member_1") + assert MapSet.member?(set, "member_2") + end + + test "goes native with ETS adapter" do + adapter = SchemaCache.Adapters.ETS + Adapter.resolve_capabilities(adapter) + + Adapter.sadd(adapter, "native_set", "member_1") + Adapter.sadd(adapter, "native_set", "member_2") + + assert :ok = Adapter.srem(adapter, "native_set", "member_1") + + assert {:ok, members} = Adapter.smembers(adapter, "native_set") + refute "member_1" in members + assert "member_2" in members + end + end + + describe "smembers/2" do + setup do + original_caps = :persistent_term.get(:schema_cache_adapter_caps) + + on_exit(fn -> + :persistent_term.put(:schema_cache_adapter_caps, original_caps) + end) + + :ok + end + + test "routes through SetLock when adapter lacks native smembers" do + Adapter.resolve_capabilities(MinimalAdapter) + + Adapter.sadd(MinimalAdapter, "test_set", "a") + Adapter.sadd(MinimalAdapter, "test_set", "b") + + assert {:ok, members} = Adapter.smembers(MinimalAdapter, "test_set") + assert is_list(members) + assert "a" in members + assert "b" in members + end + + test "goes native with ETS adapter" do + adapter = SchemaCache.Adapters.ETS + Adapter.resolve_capabilities(adapter) + + adapter.sadd("native_set", "x") + adapter.sadd("native_set", "y") + + assert {:ok, members} = Adapter.smembers(adapter, "native_set") + assert is_list(members) + assert "x" in members + assert "y" in members + end + end + + describe "mget/2" do + setup do + original_caps = :persistent_term.get(:schema_cache_adapter_caps) + + on_exit(fn -> + :persistent_term.put(:schema_cache_adapter_caps, original_caps) + end) + + :ok + end + + test "routes through SetLock when adapter lacks native mget" do + Adapter.resolve_capabilities(MinimalAdapter) + + MinimalAdapter.put("key_1", "value_1", []) + MinimalAdapter.put("key_2", "value_2", []) + + assert {:ok, results} = Adapter.mget(MinimalAdapter, ["key_1", "missing", "key_2"]) + assert results == ["value_1", nil, "value_2"] + end + + test "goes native with ETS adapter" do + adapter = SchemaCache.Adapters.ETS + Adapter.resolve_capabilities(adapter) + + adapter.put("k1", "v1", []) + adapter.put("k2", "v2", []) + + assert {:ok, results} = Adapter.mget(adapter, ["k1", "missing", "k2"]) + assert results == ["v1", nil, "v2"] + end + end +end diff --git a/test/schema_cache/adapters/ets_set_ops_test.exs b/test/schema_cache/adapters/ets_set_ops_test.exs new file mode 100644 index 0000000..58346d6 --- /dev/null +++ b/test/schema_cache/adapters/ets_set_ops_test.exs @@ -0,0 +1,103 @@ +defmodule SchemaCache.Adapters.ETSSetOpsTest do + @moduledoc false + + use ExUnit.Case, async: false + + alias SchemaCache.Adapters.ETS + + @ets_tables [ + :schema_cache_ets, + :schema_cache_ets_sets, + :schema_cache_key_to_id, + :schema_cache_id_to_key + ] + + setup do + for table <- @ets_tables do + if :ets.whereis(table) != :undefined do + :ets.delete_all_objects(table) + end + end + + :ok + end + + describe "sadd/2" do + test "adds a member to a set" do + assert :ok = ETS.sadd("my_set", "member_1") + + assert {:ok, members} = ETS.smembers("my_set") + assert "member_1" in members + end + + test "with duplicate member is idempotent" do + ETS.sadd("my_set", "member_1") + ETS.sadd("my_set", "member_1") + + # ETS bag allows duplicate objects, so we may get duplicates. + # Verify we get at least one member_1 back. + assert {:ok, members} = ETS.smembers("my_set") + assert "member_1" in members + end + end + + describe "srem/2" do + test "removes a specific member" do + ETS.sadd("my_set", "member_1") + ETS.sadd("my_set", "member_2") + + assert :ok = ETS.srem("my_set", "member_1") + + assert {:ok, members} = ETS.smembers("my_set") + assert "member_2" in members + refute "member_1" in members + end + + test "on non-existent member returns :ok" do + ETS.sadd("my_set", "member_1") + + assert :ok = ETS.srem("my_set", "nonexistent") + + assert {:ok, members} = ETS.smembers("my_set") + assert "member_1" in members + end + end + + describe "smembers/1" do + test "returns {:ok, members} for populated set" do + ETS.sadd("my_set", "a") + ETS.sadd("my_set", "b") + ETS.sadd("my_set", "c") + + assert {:ok, members} = ETS.smembers("my_set") + assert is_list(members) + assert length(members) == 3 + assert Enum.sort(members) == ["a", "b", "c"] + end + + test "returns {:ok, nil} for empty/non-existent set" do + assert {:ok, nil} = ETS.smembers("nonexistent_set") + end + end + + describe "mget/1" do + test "returns values for existing keys and nil for missing keys" do + ETS.put("key_1", "value_1", []) + ETS.put("key_2", "value_2", []) + + assert {:ok, results} = ETS.mget(["key_1", "missing_key", "key_2"]) + assert results == ["value_1", nil, "value_2"] + end + + test "with empty list returns {:ok, []}" do + assert {:ok, []} = ETS.mget([]) + end + end + + describe "init/0" do + test "is idempotent (calling it twice does not crash)" do + assert :ok = ETS.init() + assert :ok = ETS.init() + end + end +end diff --git a/test/schema_cache/adapters/ets_test.exs b/test/schema_cache/adapters/ets_test.exs new file mode 100644 index 0000000..5118848 --- /dev/null +++ b/test/schema_cache/adapters/ets_test.exs @@ -0,0 +1,49 @@ +defmodule SchemaCache.Adapters.ETSTest do + use ExUnit.Case, async: false + + alias SchemaCache.Adapters.ETS + + setup do + if :ets.whereis(:schema_cache_ets) != :undefined do + :ets.delete_all_objects(:schema_cache_ets) + end + + :ok + end + + describe "get/1" do + test "returns {:ok, nil} for missing keys" do + assert {:ok, nil} = ETS.get("nonexistent") + end + + test "returns {:ok, value} for existing keys" do + ETS.put("key", "value", []) + assert {:ok, "value"} = ETS.get("key") + end + end + + describe "put/3" do + test "stores and retrieves values" do + assert :ok = ETS.put("key", %{data: true}, []) + assert {:ok, %{data: true}} = ETS.get("key") + end + + test "overwrites existing values" do + ETS.put("key", "first", []) + ETS.put("key", "second", []) + assert {:ok, "second"} = ETS.get("key") + end + end + + describe "delete/1" do + test "removes a key" do + ETS.put("key", "value", []) + assert :ok = ETS.delete("key") + assert {:ok, nil} = ETS.get("key") + end + + test "returns :ok for nonexistent keys" do + assert :ok = ETS.delete("nonexistent") + end + end +end diff --git a/test/schema_cache/key_generator_test.exs b/test/schema_cache/key_generator_test.exs new file mode 100644 index 0000000..c9ba54c --- /dev/null +++ b/test/schema_cache/key_generator_test.exs @@ -0,0 +1,38 @@ +defmodule SchemaCache.KeyGeneratorTest do + use ExUnit.Case, async: true + + alias SchemaCache.KeyGenerator + alias SchemaCache.Test.{FakeCompositeSchema, FakeSchema} + + doctest SchemaCache.KeyGenerator + + describe "cache_key/2" do + test "generates a key from prefix and params" do + assert "find_user:{\"id\":5}" = KeyGenerator.cache_key("find_user", %{id: 5}) + end + + test "converts order_by keyword list to map for encoding" do + key = KeyGenerator.cache_key("all_users", %{order_by: [name: :asc]}) + assert key =~ "order_by" + assert key =~ "name" + end + + test "handles empty params" do + assert "all_users:{}" = KeyGenerator.cache_key("all_users", %{}) + end + end + + describe "schema_cache_key/1" do + test "generates key from schema module and primary key" do + user = %FakeSchema{id: 42, name: "test"} + assert "Elixir.SchemaCache.Test.FakeSchema:42" = KeyGenerator.schema_cache_key(user) + end + + test "handles composite primary keys" do + record = %FakeCompositeSchema{tenant_id: 1, resource_id: 2, label: "test"} + + assert "Elixir.SchemaCache.Test.FakeCompositeSchema:1:2" = + KeyGenerator.schema_cache_key(record) + end + end +end diff --git a/test/schema_cache/key_registry_test.exs b/test/schema_cache/key_registry_test.exs new file mode 100644 index 0000000..662fc59 --- /dev/null +++ b/test/schema_cache/key_registry_test.exs @@ -0,0 +1,119 @@ +defmodule SchemaCache.KeyRegistryTest do + @moduledoc false + use ExUnit.Case, async: true + + alias SchemaCache.KeyRegistry + + # NOTE: These tests will fail until KeyRegistry is implemented and its + # GenServer is started via SchemaCache.Supervisor. They define the + # expected contract for the bidirectional cache_key <-> integer ID mapping. + + describe "register/1" do + test "returns an integer ID for a cache key" do + id = KeyRegistry.register("find_user:{\"id\":1}") + assert is_integer(id) + end + + test "returns the same ID for the same key" do + id1 = KeyRegistry.register("find_user:{\"id\":1}") + id2 = KeyRegistry.register("find_user:{\"id\":1}") + assert id1 == id2 + end + + test "returns different IDs for different keys" do + id1 = KeyRegistry.register("find_user:{\"id\":1}") + id2 = KeyRegistry.register("find_user:{\"id\":2}") + assert id1 != id2 + end + + test "handles concurrent registrations of the same key" do + tasks = + for _ <- 1..100 do + Task.async(fn -> KeyRegistry.register("concurrent_key") end) + end + + ids = Task.await_many(tasks) + # All should get the same ID + assert Enum.uniq(ids) |> length() == 1 + end + + test "handles concurrent registrations of different keys" do + tasks = + for i <- 1..100 do + Task.async(fn -> KeyRegistry.register("key_#{i}") end) + end + + ids = Task.await_many(tasks) + # All should be unique + assert Enum.uniq(ids) |> length() == 100 + end + end + + describe "lookup/1" do + test "returns the cache key for a registered ID" do + id = KeyRegistry.register("find_user:{\"id\":5}") + assert {:ok, "find_user:{\"id\":5}"} = KeyRegistry.lookup(id) + end + + test "returns {:ok, nil} for unknown ID" do + assert {:ok, nil} = KeyRegistry.lookup(999_999_999) + end + end + + describe "resolve/1" do + test "resolves a list of IDs to {id, key} tuples" do + id1 = KeyRegistry.register("key_a") + id2 = KeyRegistry.register("key_b") + id3 = KeyRegistry.register("key_c") + + result = KeyRegistry.resolve([id1, id2, id3]) + assert {id1, "key_a"} in result + assert {id2, "key_b"} in result + assert {id3, "key_c"} in result + end + + test "filters out stale/unregistered IDs" do + id1 = KeyRegistry.register("key_a") + result = KeyRegistry.resolve([id1, 999_999_999]) + assert length(result) == 1 + assert {id1, "key_a"} in result + end + end + + describe "unregister/1" do + test "removes both mappings" do + id = KeyRegistry.register("temp_key") + assert {:ok, "temp_key"} = KeyRegistry.lookup(id) + + KeyRegistry.unregister("temp_key") + assert {:ok, nil} = KeyRegistry.lookup(id) + end + end + + describe "unregister_id/1" do + test "removes entries from both tables" do + id = KeyRegistry.register("temp_id_key") + assert {:ok, "temp_id_key"} = KeyRegistry.lookup(id) + + assert :ok = KeyRegistry.unregister_id(id) + + # ID -> key mapping should be gone + assert {:ok, nil} = KeyRegistry.lookup(id) + + # key -> ID mapping should also be gone (re-registering gives a new ID) + new_id = KeyRegistry.register("temp_id_key") + assert new_id != id + end + + test "with non-existent ID returns :ok" do + assert :ok = KeyRegistry.unregister_id(888_888_888) + end + end + + describe "init/0" do + test "is idempotent (calling twice does not crash)" do + assert :ok = KeyRegistry.init() + assert :ok = KeyRegistry.init() + end + end +end diff --git a/test/schema_cache/set_lock_test.exs b/test/schema_cache/set_lock_test.exs new file mode 100644 index 0000000..65c45ff --- /dev/null +++ b/test/schema_cache/set_lock_test.exs @@ -0,0 +1,165 @@ +defmodule SchemaCache.SetLockTest do + @moduledoc false + use ExUnit.Case, async: false + + alias SchemaCache.SetLock + + # These test the fallback serialization mechanism for adapters + # without native set operations. SetLock uses a partitioned lock + # Registry to serialize read-modify-write cycles on sets stored + # as MapSets in the adapter's key-value store. + + setup do + if :ets.whereis(:schema_cache_ets) != :undefined do + :ets.delete_all_objects(:schema_cache_ets) + end + + Application.put_env(:schema_cache, :adapter, SchemaCache.Adapters.ETS) + :ok + end + + describe "sadd/3" do + test "adds a member to a set" do + adapter = SchemaCache.Adapters.ETS + SetLock.sadd("my_set", "member_1", adapter) + + assert {:ok, members} = SetLock.smembers("my_set", adapter) + assert "member_1" in members + end + + test "does not duplicate members" do + adapter = SchemaCache.Adapters.ETS + SetLock.sadd("my_set", "member_1", adapter) + SetLock.sadd("my_set", "member_1", adapter) + + assert {:ok, members} = SetLock.smembers("my_set", adapter) + assert length(members) == 1 + end + + test "adds multiple different members" do + adapter = SchemaCache.Adapters.ETS + SetLock.sadd("my_set", "member_1", adapter) + SetLock.sadd("my_set", "member_2", adapter) + SetLock.sadd("my_set", "member_3", adapter) + + assert {:ok, members} = SetLock.smembers("my_set", adapter) + assert length(members) == 3 + assert "member_1" in members + assert "member_2" in members + assert "member_3" in members + end + + test "handles concurrent adds to the same set without lost writes" do + adapter = SchemaCache.Adapters.ETS + + # Spawn 50 tasks all adding different members to the same set + tasks = + for i <- 1..50 do + Task.async(fn -> + SetLock.sadd("concurrent_set", "member_#{i}", adapter) + end) + end + + Task.await_many(tasks) + + assert {:ok, members} = SetLock.smembers("concurrent_set", adapter) + # ALL 50 members should be present -- no lost writes + assert length(members) == 50 + end + end + + describe "srem/3" do + test "removes a member from a set" do + adapter = SchemaCache.Adapters.ETS + SetLock.sadd("my_set", "member_1", adapter) + SetLock.sadd("my_set", "member_2", adapter) + + SetLock.srem("my_set", "member_1", adapter) + + assert {:ok, members} = SetLock.smembers("my_set", adapter) + assert "member_2" in members + refute "member_1" in members + end + + test "removing last member results in nil (empty set)" do + adapter = SchemaCache.Adapters.ETS + SetLock.sadd("my_set", "only_member", adapter) + + SetLock.srem("my_set", "only_member", adapter) + + assert {:ok, nil} = SetLock.smembers("my_set", adapter) + end + + test "removing a non-existent member is a no-op" do + adapter = SchemaCache.Adapters.ETS + SetLock.sadd("my_set", "member_1", adapter) + + SetLock.srem("my_set", "nonexistent", adapter) + + assert {:ok, members} = SetLock.smembers("my_set", adapter) + assert length(members) == 1 + assert "member_1" in members + end + + test "handles concurrent adds and removes without corruption" do + adapter = SchemaCache.Adapters.ETS + + # Pre-populate with 50 members + for i <- 1..50 do + SetLock.sadd("race_set", "member_#{i}", adapter) + end + + # Concurrently: remove evens, add 50 more + tasks = + for i <- 1..50, rem(i, 2) == 0 do + Task.async(fn -> SetLock.srem("race_set", "member_#{i}", adapter) end) + end ++ + for i <- 51..100 do + Task.async(fn -> SetLock.sadd("race_set", "member_#{i}", adapter) end) + end + + Task.await_many(tasks) + + assert {:ok, members} = SetLock.smembers("race_set", adapter) + # Should have: 25 odd originals + 50 new = 75 + assert length(members) == 75 + end + end + + describe "smembers/2" do + test "returns {:ok, nil} for non-existent set" do + adapter = SchemaCache.Adapters.ETS + assert {:ok, nil} = SetLock.smembers("nonexistent_set", adapter) + end + + test "returns {:ok, list} for populated set" do + adapter = SchemaCache.Adapters.ETS + SetLock.sadd("my_set", "a", adapter) + SetLock.sadd("my_set", "b", adapter) + + assert {:ok, members} = SetLock.smembers("my_set", adapter) + assert is_list(members) + assert length(members) == 2 + end + end + + describe "mget/2" do + test "fetches multiple keys in a single operation" do + adapter = SchemaCache.Adapters.ETS + adapter.put("key_1", "value_1", []) + adapter.put("key_2", "value_2", []) + adapter.put("key_3", "value_3", []) + + assert {:ok, results} = SetLock.mget(["key_1", "key_2", "key_3"], adapter) + assert results == ["value_1", "value_2", "value_3"] + end + + test "returns nil for missing keys" do + adapter = SchemaCache.Adapters.ETS + adapter.put("key_1", "value_1", []) + + assert {:ok, results} = SetLock.mget(["key_1", "missing_key"], adapter) + assert results == ["value_1", nil] + end + end +end diff --git a/test/schema_cache/supervisor_test.exs b/test/schema_cache/supervisor_test.exs new file mode 100644 index 0000000..a8c30e2 --- /dev/null +++ b/test/schema_cache/supervisor_test.exs @@ -0,0 +1,43 @@ +defmodule SchemaCache.SupervisorTest do + @moduledoc false + + use ExUnit.Case, async: false + + @ets_tables [ + :schema_cache_ets, + :schema_cache_ets_sets, + :schema_cache_key_to_id, + :schema_cache_id_to_key + ] + + setup do + for table <- @ets_tables do + if :ets.whereis(table) != :undefined do + :ets.delete_all_objects(table) + end + end + + :ok + end + + describe "start_link/1" do + test "adapter is set in persistent_term after startup" do + assert SchemaCache.Adapters.ETS = :persistent_term.get(:schema_cache_adapter) + end + + test "adapter capabilities are set with correct flags for ETS adapter" do + caps = :persistent_term.get(:schema_cache_adapter_caps) + + assert %{sadd: true, srem: true, smembers: true, mget: true} = caps + end + + test "KeyRegistry ETS tables exist after startup" do + assert :ets.whereis(:schema_cache_key_to_id) != :undefined + assert :ets.whereis(:schema_cache_id_to_key) != :undefined + end + + test "SetLock.Registry is running" do + assert Process.whereis(SchemaCache.SetLock.Registry) != nil + end + end +end diff --git a/test/schema_cache_test.exs b/test/schema_cache_test.exs index f0e72e7..e675c2e 100644 --- a/test/schema_cache_test.exs +++ b/test/schema_cache_test.exs @@ -1,4 +1,994 @@ defmodule SchemaCacheTest do - use ExUnit.Case - doctest SchemaCache + @moduledoc false + + use SchemaCache.Test.DataCase, async: false + + import ExUnit.CaptureLog + + alias SchemaCache.Adapters.ETS + alias SchemaCache.KeyGenerator + alias SchemaCache.KeyRegistry + alias SchemaCache.Test.FakeSchema + + @ets_tables [ + :schema_cache_ets, + :schema_cache_ets_sets, + :schema_cache_key_to_id, + :schema_cache_id_to_key + ] + + setup do + for table <- @ets_tables do + if :ets.whereis(table) != :undefined do + :ets.delete_all_objects(table) + end + end + + Application.put_env(:schema_cache, :adapter, SchemaCache.Adapters.ETS) + :ok + end + + defp make_user(id, name) do + %FakeSchema{id: id, name: name, email: "#{name}@test.com"} + end + + defp register_key_reference(set_key, cache_key) do + id = KeyRegistry.register(cache_key) + ETS.sadd(set_key, id) + id + end + + describe "read/4" do + test "fetches from source on cache miss and caches the result" do + user = make_user(1, "alice") + + result = SchemaCache.read("find_user", %{id: 1}, nil, fn -> {:ok, user} end) + assert {:ok, ^user} = result + + # Second call should hit cache (function raises if called) + assert {:ok, ^user} = + SchemaCache.read("find_user", %{id: 1}, nil, fn -> + raise "should not be called" + end) + end + + test "caches and returns list results" do + users = [make_user(1, "alice"), make_user(2, "bob")] + + result = SchemaCache.read("all_users", %{active: true}, nil, fn -> users end) + assert ^users = result + + # Second call hits cache + assert ^users = + SchemaCache.read("all_users", %{active: true}, nil, fn -> + raise "should not be called" + end) + end + + test "does not cache empty list results" do + call_count = :counters.new(1, [:atomics]) + + fetch = fn -> + :counters.add(call_count, 1, 1) + [] + end + + assert [] = SchemaCache.read("all_users", %{}, nil, fetch) + assert [] = SchemaCache.read("all_users", %{}, nil, fetch) + + # Function was called twice (not cached) + assert :counters.get(call_count, 1) == 2 + end + + test "passes through error results without caching" do + error = {:error, :not_found} + + assert ^error = SchemaCache.read("find_user", %{id: 99}, nil, fn -> error end) + end + + test "falls back to source when adapter returns an error" do + defmodule FailAdapter do + @behaviour SchemaCache.Adapter + @impl true + def get(_key), do: {:error, :connection_refused} + @impl true + def put(_key, _value, _opts), do: :ok + @impl true + def delete(_key), do: :ok + end + + original = :persistent_term.get(:schema_cache_adapter) + :persistent_term.put(:schema_cache_adapter, FailAdapter) + + try do + assert capture_log(fn -> + assert {:ok, %{id: 1}} = + SchemaCache.read("find_user", %{id: 1}, nil, fn -> + {:ok, make_user(1, "user")} + end) + end) =~ "Unable to fetch from cache, falling back to source" + after + :persistent_term.put(:schema_cache_adapter, original) + end + end + + test "creates key references for singular results" do + user = make_user(1, "alice") + schema_key = KeyGenerator.schema_cache_key(user) + + SchemaCache.read("find_user", %{id: 1}, nil, fn -> {:ok, user} end) + + adapter = SchemaCache.Adapters.ETS + set_key = "__set:#{schema_key}" + {:ok, refs} = adapter.smembers(set_key) + + cache_key = KeyGenerator.cache_key("find_user", %{id: 1}) + expected_id = KeyRegistry.register(cache_key) + assert expected_id in refs + end + + test "creates key references for collection results and schema type" do + users = [make_user(1, "alice"), make_user(2, "bob")] + + SchemaCache.read("all_users", %{active: true}, nil, fn -> users end) + + adapter = SchemaCache.Adapters.ETS + cache_key = KeyGenerator.cache_key("all_users", %{active: true}) + expected_id = KeyRegistry.register(cache_key) + + # Each user should have a key reference + for user <- users do + schema_key = KeyGenerator.schema_cache_key(user) + {:ok, refs} = adapter.smembers("__set:#{schema_key}") + assert expected_id in refs + end + + # Schema type should also have a key reference + {:ok, type_refs} = adapter.smembers("__set:Elixir.SchemaCache.Test.FakeSchema") + assert expected_id in type_refs + end + + test "caches Repo.get, second call serves from cache" do + user = insert_user!(%{name: "alice", email: "alice@test.com"}) + call_count = :counters.new(1, [:atomics]) + + fetch = fn -> + :counters.add(call_count, 1, 1) + {:ok, Repo.get(User, user.id)} + end + + user_id = user.id + user_name = user.name + + assert { + :ok, + %{ + id: ^user_id, + name: ^user_name + } + } = SchemaCache.read("find_user", %{id: user.id}, nil, fetch) + + assert :counters.get(call_count, 1) == 1 + + assert {:ok, %{id: ^user_id}} = + SchemaCache.read("find_user", %{id: user.id}, nil, fetch) + + assert :counters.get(call_count, 1) == 1 + end + + test "caches Repo.all collection, second call serves from cache" do + user1 = insert_user!(%{name: "alice", email: "alice@test.com"}) + user2 = insert_user!(%{name: "bob", email: "bob@test.com"}) + call_count = :counters.new(1, [:atomics]) + + fetch = fn -> + :counters.add(call_count, 1, 1) + Repo.all(User) + end + + assert [_, _] = SchemaCache.read("all_users", %{}, nil, fetch) + assert :counters.get(call_count, 1) == 1 + + result = SchemaCache.read("all_users", %{}, nil, fetch) + ids = Enum.map(result, & &1.id) + assert user1.id in ids + assert user2.id in ids + assert :counters.get(call_count, 1) == 1 + end + + test "concurrent cache misses preserve all key references" do + user = make_user(1, "alice") + + tasks = + for i <- 1..20 do + Task.async(fn -> + SchemaCache.read("query_#{i}", %{id: 1}, nil, fn -> {:ok, user} end) + end) + end + + Task.await_many(tasks) + + adapter = SchemaCache.Adapters.ETS + schema_key = KeyGenerator.schema_cache_key(user) + {:ok, refs} = adapter.smembers("__set:#{schema_key}") + + assert length(refs) == 20 + end + + test "concurrent reads for different schemas build correct key references" do + users = for i <- 1..10, do: make_user(i, "user_#{i}") + + tasks = + for user <- users do + Task.async(fn -> + SchemaCache.read("find_user", %{id: user.id}, nil, fn -> {:ok, user} end) + end) + end + + Task.await_many(tasks) + + adapter = SchemaCache.Adapters.ETS + + for user <- users do + schema_key = KeyGenerator.schema_cache_key(user) + expected_cache_key = KeyGenerator.cache_key("find_user", %{id: user.id}) + expected_id = SchemaCache.KeyRegistry.register(expected_cache_key) + {:ok, refs} = adapter.smembers("__set:#{schema_key}") + assert expected_id in refs + end + end + + test "concurrent reads and creates don't lose data" do + adapter = SchemaCache.Adapters.ETS + + tasks = + for i <- 1..10 do + Task.async(fn -> + user = make_user(i, "reader_#{i}") + SchemaCache.read("find_user", %{id: i}, nil, fn -> {:ok, user} end) + end) + end ++ + for i <- 11..15 do + Task.async(fn -> + new_user = make_user(i, "created_#{i}") + SchemaCache.create(fn -> {:ok, new_user} end) + end) + end + + results = Task.await_many(tasks) + assert length(results) == 15 + + for i <- 1..10 do + cache_key = KeyGenerator.cache_key("find_user", %{id: i}) + assert {:ok, %{id: ^i}} = adapter.get(cache_key) + end + end + + test "concurrent reads and mutations don't corrupt state" do + user = make_user(1, "alice") + SchemaCache.read("find_user", %{id: 1}, nil, fn -> {:ok, user} end) + + tasks = + for _ <- 1..20 do + Task.async(fn -> + SchemaCache.read("find_user", %{id: 1}, nil, fn -> {:ok, user} end) + end) + end ++ + for i <- 1..5 do + Task.async(fn -> + updated = %{user | name: "alice_v#{i}"} + SchemaCache.update(fn -> {:ok, updated} end) + end) + end + + results = Task.await_many(tasks) + assert length(results) == 25 + end + + test "concurrent reads and write_through updates don't corrupt state" do + user = make_user(1, "alice") + SchemaCache.read("find_user", %{id: 1}, nil, fn -> {:ok, user} end) + + tasks = + for _ <- 1..20 do + Task.async(fn -> + SchemaCache.read("find_user", %{id: 1}, nil, fn -> {:ok, user} end) + end) + end ++ + for i <- 1..5 do + Task.async(fn -> + updated = %{user | name: "alice_v#{i}"} + SchemaCache.update(fn -> {:ok, updated} end, strategy: :write_through) + end) + end + + results = Task.await_many(tasks) + assert length(results) == 25 + end + end + + describe "create/1" do + test "evicts all collection cache keys for the schema type" do + user = make_user(1, "alice") + all_key = KeyGenerator.cache_key("all_users", %{active: true}) + find_key = KeyGenerator.cache_key("find_user", %{id: 1}) + + adapter = SchemaCache.Adapters.ETS + + adapter.put(all_key, [user], []) + adapter.put(find_key, user, []) + + # Collection key reference under the schema type + register_key_reference("__set:Elixir.SchemaCache.Test.FakeSchema", all_key) + + # Instance key references + schema_key = KeyGenerator.schema_cache_key(user) + register_key_reference("__set:#{schema_key}", find_key) + register_key_reference("__set:#{schema_key}", all_key) + + new_user = make_user(2, "bob") + + {:ok, ^new_user} = SchemaCache.create(fn -> {:ok, new_user} end) + + # Collection key should be evicted + assert {:ok, nil} = adapter.get(all_key) + # Find key should NOT be evicted (create only targets type-level refs) + assert {:ok, ^user} = adapter.get(find_key) + end + + test "passes through error results without cache operations" do + error = {:error, %{code: :validation, message: "invalid"}} + + assert ^error = SchemaCache.create(fn -> error end) + end + + test "evicts collection cache so next read includes new record" do + user1 = insert_user!(%{name: "alice", email: "alice@test.com"}) + + SchemaCache.read("all_users", %{}, nil, fn -> Repo.all(User) end) + + assert {:ok, user2} = + SchemaCache.create(fn -> + %User{} + |> User.changeset(%{name: "bob", email: "bob@test.com"}) + |> Repo.insert() + end) + + call_count = :counters.new(1, [:atomics]) + + result = + SchemaCache.read("all_users", %{}, nil, fn -> + :counters.add(call_count, 1, 1) + Repo.all(User) + end) + + ids = Enum.map(result, & &1.id) + assert user1.id in ids + assert user2.id in ids + assert :counters.get(call_count, 1) == 1 + end + + test "concurrent creates evict collection caches" do + existing_user = make_user(1, "alice") + adapter = SchemaCache.Adapters.ETS + + SchemaCache.read("all_users", %{}, nil, fn -> [existing_user] end) + + tasks = + for i <- 2..11 do + Task.async(fn -> + new_user = make_user(i, "new_user_#{i}") + SchemaCache.create(fn -> {:ok, new_user} end) + end) + end + + Task.await_many(tasks) + + assert {:ok, nil} = adapter.get(KeyGenerator.cache_key("all_users", %{})) + end + end + + describe "update/2" do + test "evicts all cache keys referencing the mutated schema" do + user = make_user(1, "alice") + schema_key = KeyGenerator.schema_cache_key(user) + find_key = KeyGenerator.cache_key("find_user", %{id: 1}) + all_key = KeyGenerator.cache_key("all_users", %{active: true}) + + adapter = SchemaCache.Adapters.ETS + + # Simulate cached state: user is in both a find and an all result + adapter.put(find_key, user, []) + adapter.put(all_key, [user], []) + + # Set up key references via KeyRegistry + register_key_reference("__set:#{schema_key}", find_key) + register_key_reference("__set:#{schema_key}", all_key) + + updated_user = %{user | name: "alice_updated"} + + {:ok, ^updated_user} = SchemaCache.update(fn -> {:ok, updated_user} end) + + # Both cache entries should be evicted + assert {:ok, nil} = adapter.get(find_key) + assert {:ok, nil} = adapter.get(all_key) + end + + test "passes through error results without cache operations" do + error = {:error, %{code: :not_found, message: "not found"}} + + assert ^error = SchemaCache.update(fn -> error end) + end + + test "write_through updates cached singular values in place" do + user = make_user(1, "alice") + schema_key = KeyGenerator.schema_cache_key(user) + find_key = KeyGenerator.cache_key("find_user", %{id: 1}) + + adapter = SchemaCache.Adapters.ETS + + adapter.put(find_key, user, []) + register_key_reference("__set:#{schema_key}", find_key) + + updated_user = %{user | name: "alice_updated"} + + {:ok, ^updated_user} = + SchemaCache.update(fn -> {:ok, updated_user} end, strategy: :write_through) + + assert {:ok, ^updated_user} = adapter.get(find_key) + end + + test "write_through updates cached collections in place" do + user = make_user(1, "alice") + schema_key = KeyGenerator.schema_cache_key(user) + all_key = KeyGenerator.cache_key("all_users", %{active: true}) + + adapter = SchemaCache.Adapters.ETS + + adapter.put(all_key, [user], []) + register_key_reference("__set:#{schema_key}", all_key) + + updated_user = %{user | name: "alice_updated"} + + {:ok, ^updated_user} = + SchemaCache.update(fn -> {:ok, updated_user} end, strategy: :write_through) + + assert {:ok, [^updated_user]} = adapter.get(all_key) + end + + test "write_through passes through error results without cache operations" do + error = {:error, %{code: :conflict, message: "conflict"}} + + assert ^error = SchemaCache.update(fn -> error end, strategy: :write_through) + end + + test "default eviction against DB, next read gets fresh data" do + user = insert_user!(%{name: "alice", email: "alice@test.com"}) + + {:ok, _} = + SchemaCache.read("find_user", %{id: user.id}, nil, fn -> + {:ok, Repo.get(User, user.id)} + end) + + assert {:ok, %{name: "alice_updated"}} = + SchemaCache.update(fn -> + user + |> User.changeset(%{name: "alice_updated"}) + |> Repo.update() + end) + + call_count = :counters.new(1, [:atomics]) + + assert {:ok, %{name: "alice_updated"}} = + SchemaCache.read("find_user", %{id: user.id}, nil, fn -> + :counters.add(call_count, 1, 1) + {:ok, Repo.get(User, user.id)} + end) + + assert :counters.get(call_count, 1) == 1 + end + + test "write_through singular updates cache in place without re-fetching" do + user = insert_user!(%{name: "alice", email: "alice@test.com"}) + + {:ok, _} = + SchemaCache.read("find_user", %{id: user.id}, nil, fn -> + {:ok, Repo.get(User, user.id)} + end) + + assert {:ok, %{name: "alice_updated"}} = + SchemaCache.update( + fn -> + user + |> User.changeset(%{name: "alice_updated"}) + |> Repo.update() + end, + strategy: :write_through + ) + + call_count = :counters.new(1, [:atomics]) + + assert {:ok, %{name: "alice_updated"}} = + SchemaCache.read("find_user", %{id: user.id}, nil, fn -> + :counters.add(call_count, 1, 1) + {:ok, Repo.get(User, user.id)} + end) + + assert :counters.get(call_count, 1) == 0 + end + + test "write_through collection updates cache in place" do + user = insert_user!(%{name: "alice", email: "alice@test.com"}) + + SchemaCache.read("all_users", %{}, nil, fn -> Repo.all(User) end) + + assert {:ok, %{name: "alice_updated"}} = + SchemaCache.update( + fn -> + user + |> User.changeset(%{name: "alice_updated"}) + |> Repo.update() + end, + strategy: :write_through + ) + + call_count = :counters.new(1, [:atomics]) + + assert [%{name: "alice_updated"}] = + SchemaCache.read("all_users", %{}, nil, fn -> + :counters.add(call_count, 1, 1) + Repo.all(User) + end) + + assert :counters.get(call_count, 1) == 0 + end + + test "concurrent updates don't lose evictions" do + users = for i <- 1..10, do: make_user(i, "user_#{i}") + adapter = SchemaCache.Adapters.ETS + + SchemaCache.read("all_users", %{}, nil, fn -> users end) + + tasks = + for user <- users do + Task.async(fn -> + updated = %{user | name: "#{user.name}_updated"} + SchemaCache.update(fn -> {:ok, updated} end) + end) + end + + Task.await_many(tasks) + + assert {:ok, nil} = adapter.get(KeyGenerator.cache_key("all_users", %{})) + end + end + + describe "delete/1" do + test "evicts all cache keys referencing the deleted schema" do + user = make_user(1, "alice") + schema_key = KeyGenerator.schema_cache_key(user) + find_key = KeyGenerator.cache_key("find_user", %{id: 1}) + all_key = KeyGenerator.cache_key("all_users", %{active: true}) + adapter = SchemaCache.Adapters.ETS + + adapter.put(find_key, user, []) + adapter.put(all_key, [user], []) + + register_key_reference("__set:#{schema_key}", find_key) + register_key_reference("__set:#{schema_key}", all_key) + + {:ok, ^user} = SchemaCache.delete(fn -> {:ok, user} end) + + assert {:ok, nil} = adapter.get(find_key) + assert {:ok, nil} = adapter.get(all_key) + end + + test "passes through error results without cache operations" do + error = {:error, %{code: :not_found, message: "not found"}} + + assert ^error = SchemaCache.delete(fn -> error end) + end + + test "evicts cache, next read reflects deletion from DB" do + user = insert_user!(%{name: "alice", email: "alice@test.com"}) + + {:ok, _} = + SchemaCache.read("find_user", %{id: user.id}, nil, fn -> + {:ok, Repo.get(User, user.id)} + end) + + SchemaCache.read("all_users", %{}, nil, fn -> Repo.all(User) end) + + user_id = user.id + + assert {:ok, %{id: ^user_id}} = + SchemaCache.delete(fn -> + Repo.delete(user) + end) + + call_count = :counters.new(1, [:atomics]) + + assert {:ok, nil} = + SchemaCache.read("find_user", %{id: user.id}, nil, fn -> + :counters.add(call_count, 1, 1) + {:ok, Repo.get(User, user.id)} + end) + + assert :counters.get(call_count, 1) == 1 + end + + test "concurrent deletes evict all referenced keys" do + users = for i <- 1..5, do: make_user(i, "user_#{i}") + adapter = SchemaCache.Adapters.ETS + + for user <- users do + SchemaCache.read("find_user", %{id: user.id}, nil, fn -> {:ok, user} end) + end + + tasks = + for user <- users do + Task.async(fn -> + SchemaCache.delete(fn -> {:ok, user} end) + end) + end + + Task.await_many(tasks) + + for user <- users do + cache_key = KeyGenerator.cache_key("find_user", %{id: user.id}) + assert {:ok, nil} = adapter.get(cache_key) + end + end + end + + describe "flush/2" do + test "flushes all key references for a schema instance" do + user = make_user(1, "alice") + schema_key = KeyGenerator.schema_cache_key(user) + key_1 = "cache_key_1" + key_2 = "cache_key_2" + + adapter = SchemaCache.Adapters.ETS + + adapter.put(key_1, "value_1", []) + adapter.put(key_2, "value_2", []) + register_key_reference("__set:#{schema_key}", key_1) + register_key_reference("__set:#{schema_key}", key_2) + + assert :ok = SchemaCache.flush(user) + + assert {:ok, nil} = adapter.get(key_1) + assert {:ok, nil} = adapter.get(key_2) + end + + test "returns :ok when no key references exist" do + user = make_user(99, "nobody") + assert :ok = SchemaCache.flush(user) + end + + test "evicts 101+ cache entries via async_stream branch" do + user = make_user(1, "alice") + schema_key = KeyGenerator.schema_cache_key(user) + set_key = "__set:#{schema_key}" + adapter = SchemaCache.Adapters.ETS + + # Register 105 key references for a single schema instance + cache_keys = + for i <- 1..105 do + cache_key = "async_test_key_#{i}" + adapter.put(cache_key, "value_#{i}", []) + register_key_reference(set_key, cache_key) + cache_key + end + + # Verify entries exist before flush + for key <- cache_keys do + assert {:ok, value} = adapter.get(key) + assert value != nil + end + + assert :ok = SchemaCache.flush(user) + + # All 105 cache entries should be evicted + for key <- cache_keys do + assert {:ok, nil} = adapter.get(key) + end + end + + test "removes orphaned IDs from the adapter set during eviction" do + user = make_user(1, "alice") + schema_key = KeyGenerator.schema_cache_key(user) + set_key = "__set:#{schema_key}" + adapter = SchemaCache.Adapters.ETS + + # Register a cache key in KeyRegistry, get its ID + cache_key = "orphan_test_key" + id = KeyRegistry.register(cache_key) + + # Add the ID to the adapter set + adapter.sadd(set_key, id) + + # Put a value so the set has something to track + adapter.put(cache_key, "some_value", []) + + # Unregister the ID from KeyRegistry to simulate an orphaned ID + KeyRegistry.unregister_id(id) + + # Trigger eviction + assert :ok = SchemaCache.flush(user) + + # The orphaned ID should be removed from the adapter set + case adapter.smembers(set_key) do + {:ok, nil} -> :ok + {:ok, members} -> refute id in members + end + end + + test "cleans up stale IDs from the set and KeyRegistry after TTL expiration" do + user = make_user(1, "alice") + adapter = SchemaCache.Adapters.ETS + + # Cache the value via read/4 to create cache entry + key references + {:ok, ^user} = + SchemaCache.read("find_user_stale", %{id: 1}, nil, fn -> {:ok, user} end) + + cache_key = KeyGenerator.cache_key("find_user_stale", %{id: 1}) + schema_key = KeyGenerator.schema_cache_key(user) + set_key = "__set:#{schema_key}" + + # Verify the cache entry and references exist + assert {:ok, ^user} = adapter.get(cache_key) + {:ok, refs} = adapter.smembers(set_key) + id = KeyRegistry.register(cache_key) + assert id in refs + + # Manually delete the cached value to simulate TTL expiration + adapter.delete(cache_key) + assert {:ok, nil} = adapter.get(cache_key) + + # Trigger eviction + assert :ok = SchemaCache.flush(user) + + # The stale ID should be cleaned up from the set + case adapter.smembers(set_key) do + {:ok, nil} -> :ok + {:ok, members} -> refute id in members + end + + # The stale ID should be cleaned up from KeyRegistry + assert {:ok, nil} = KeyRegistry.lookup(id) + end + + test "does not crash and logs a warning when mget returns an error" do + user = make_user(1, "alice") + schema_key = KeyGenerator.schema_cache_key(user) + set_key = "__set:#{schema_key}" + adapter = SchemaCache.Adapters.ETS + + # Set up a cache reference so there are IDs to resolve + cache_key = "mget_fail_test_key" + adapter.put(cache_key, "some_value", []) + register_key_reference(set_key, cache_key) + + original_adapter = :persistent_term.get(:schema_cache_adapter) + original_caps = :persistent_term.get(:schema_cache_adapter_caps) + + :persistent_term.put(:schema_cache_adapter, SchemaCache.Test.FailMgetAdapter) + + :persistent_term.put(:schema_cache_adapter_caps, %{ + sadd: true, + srem: true, + smembers: true, + mget: true + }) + + try do + log = + capture_log(fn -> + assert :ok = SchemaCache.flush(user) + end) + + assert log =~ "eviction mget failed" + after + :persistent_term.put(:schema_cache_adapter, original_adapter) + :persistent_term.put(:schema_cache_adapter_caps, original_caps) + end + end + + test "evicts all cached queries for a schema instance from DB" do + user = insert_user!(%{name: "alice", email: "alice@test.com"}) + + {:ok, _} = + SchemaCache.read("find_user", %{id: user.id}, nil, fn -> + {:ok, Repo.get(User, user.id)} + end) + + SchemaCache.read("all_users", %{}, nil, fn -> Repo.all(User) end) + + :ok = SchemaCache.flush(user) + + call_count = :counters.new(1, [:atomics]) + + {:ok, _} = + SchemaCache.read("find_user", %{id: user.id}, nil, fn -> + :counters.add(call_count, 1, 1) + {:ok, Repo.get(User, user.id)} + end) + + SchemaCache.read("all_users", %{}, nil, fn -> + :counters.add(call_count, 1, 1) + Repo.all(User) + end) + + assert :counters.get(call_count, 1) == 2 + end + end + + describe "write_to_cache/2" do + test "updates all key ref values directly" do + user = make_user(1, "alice") + schema_key = KeyGenerator.schema_cache_key(user) + find_key = KeyGenerator.cache_key("find_user", %{id: 1}) + all_key = KeyGenerator.cache_key("all_users", %{active: true}) + + adapter = SchemaCache.Adapters.ETS + + adapter.put(find_key, user, []) + adapter.put(all_key, [user], []) + register_key_reference("__set:#{schema_key}", find_key) + register_key_reference("__set:#{schema_key}", all_key) + + updated = %{user | name: "updated"} + assert :ok = SchemaCache.write_to_cache(updated) + + assert {:ok, ^updated} = adapter.get(find_key) + assert {:ok, [^updated]} = adapter.get(all_key) + end + + test "returns :ok silently when smembers returns {:ok, nil}" do + user = make_user(99, "no_refs") + + # No references exist for this schema, so smembers returns {:ok, nil} + assert :ok = SchemaCache.write_to_cache(user) + end + + test "cleans up stale refs when cache entry has expired" do + user = make_user(1, "alice") + adapter = SchemaCache.Adapters.ETS + + # Cache the value via read/4 + {:ok, ^user} = + SchemaCache.read("find_user_wt", %{id: 1}, nil, fn -> {:ok, user} end) + + cache_key = KeyGenerator.cache_key("find_user_wt", %{id: 1}) + schema_key = KeyGenerator.schema_cache_key(user) + set_key = "__set:#{schema_key}" + + # Verify references exist + id = KeyRegistry.register(cache_key) + {:ok, refs} = adapter.smembers(set_key) + assert id in refs + + # Simulate cache entry expiration by deleting from adapter + adapter.delete(cache_key) + + # write_to_cache should clean up the stale ref + updated_user = %{user | name: "alice_updated"} + assert :ok = SchemaCache.write_to_cache(updated_user) + + # Stale ID should be removed from the set + case adapter.smembers(set_key) do + {:ok, nil} -> :ok + {:ok, members} -> refute id in members + end + + # Stale ID should be removed from KeyRegistry + assert {:ok, nil} = KeyRegistry.lookup(id) + end + + test "updates cache without DB write, DB still has original" do + user = insert_user!(%{name: "alice", email: "alice@test.com"}) + + {:ok, _} = + SchemaCache.read("find_user", %{id: user.id}, nil, fn -> + {:ok, Repo.get(User, user.id)} + end) + + modified = %{user | name: "cache_only_name"} + :ok = SchemaCache.write_to_cache(modified) + + assert {:ok, %{name: "cache_only_name"}} = + SchemaCache.read("find_user", %{id: user.id}, nil, fn -> + {:ok, Repo.get(User, user.id)} + end) + + assert %{name: "alice"} = Repo.get(User, user.id) + end + end + + test "full lifecycle: read, create, update (write_through), update (evict), delete" do + adapter = SchemaCache.Adapters.ETS + + user = make_user(1, "alice") + + # Step 1: Cache a user via read/4 as singular + {:ok, ^user} = + SchemaCache.read("find_user", %{id: 1}, nil, fn -> {:ok, user} end) + + find_key = KeyGenerator.cache_key("find_user", %{id: 1}) + assert {:ok, ^user} = adapter.get(find_key) + + # Step 2: Cache a user list via read/4 as collection + users = [user, make_user(2, "bob")] + + ^users = + SchemaCache.read("all_users", %{active: true}, nil, fn -> users end) + + all_key = KeyGenerator.cache_key("all_users", %{active: true}) + assert {:ok, ^users} = adapter.get(all_key) + + # Step 3: Create a new user - verify collection cache is evicted + 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) + assert {:ok, nil} = adapter.get(all_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 the collection (should re-cache) + updated_users = [user, make_user(2, "bob"), new_user] + + ^updated_users = + SchemaCache.read("all_users", %{active: true}, nil, fn -> updated_users end) + + assert {:ok, ^updated_users} = adapter.get(all_key) + + # Step 5: Update the user via update/2 with :write_through + updated_user = %{user | name: "alice_updated"} + + {:ok, ^updated_user} = + SchemaCache.update(fn -> {:ok, updated_user} end, strategy: :write_through) + + # Singular cache should be updated in place + assert {:ok, ^updated_user} = adapter.get(find_key) + + # Collection cache should have the updated user in place + {:ok, cached_collection} = adapter.get(all_key) + assert Enum.any?(cached_collection, &(&1.name == "alice_updated")) + refute Enum.any?(cached_collection, &(&1.name == "alice" and &1.id == 1)) + + # Step 6: Update the user via update/2 with :evict (default) + evicted_user = %{updated_user | name: "alice_evicted"} + + {:ok, ^evicted_user} = + SchemaCache.update(fn -> {:ok, evicted_user} end) + + # Both caches should be evicted + assert {:ok, nil} = adapter.get(find_key) + assert {:ok, nil} = adapter.get(all_key) + + # Step 7: Re-cache the singular entry for delete test + {:ok, ^evicted_user} = + SchemaCache.read("find_user", %{id: 1}, nil, fn -> {:ok, evicted_user} end) + + assert {:ok, ^evicted_user} = adapter.get(find_key) + + # Step 8: Delete the user - verify remaining caches are evicted + {:ok, ^evicted_user} = SchemaCache.delete(fn -> {:ok, evicted_user} end) + + assert {:ok, nil} = adapter.get(find_key) + end + + defp insert_user!(attrs) do + defaults = %{ + name: "user_#{System.unique_integer([:positive])}", + email: "user_#{System.unique_integer([:positive])}@test.com" + } + + %User{} + |> User.changeset(Map.merge(defaults, attrs)) + |> Repo.insert!() + end end diff --git a/test/support/data_case.ex b/test/support/data_case.ex new file mode 100644 index 0000000..bfab0a7 --- /dev/null +++ b/test/support/data_case.ex @@ -0,0 +1,38 @@ +defmodule SchemaCache.Test.DataCase do + @moduledoc false + + use ExUnit.CaseTemplate + + alias Ecto.Adapters.SQL.Sandbox + alias SchemaCache.Test.Repo + + @ets_tables [ + :schema_cache_ets, + :schema_cache_ets_sets, + :schema_cache_key_to_id, + :schema_cache_id_to_key + ] + + using do + quote do + alias SchemaCache.Test.Repo + alias SchemaCache.Test.User + end + end + + setup tags do + :ok = Sandbox.checkout(Repo) + + unless tags[:async] do + Sandbox.mode(Repo, {:shared, self()}) + end + + for table <- @ets_tables do + if :ets.whereis(table) != :undefined do + :ets.delete_all_objects(table) + end + end + + :ok + end +end diff --git a/test/support/fail_mget_adapter.ex b/test/support/fail_mget_adapter.ex new file mode 100644 index 0000000..4177d0b --- /dev/null +++ b/test/support/fail_mget_adapter.ex @@ -0,0 +1,31 @@ +defmodule SchemaCache.Test.FailMgetAdapter do + @moduledoc false + + @behaviour SchemaCache.Adapter + + @doc """ + An adapter that delegates all operations to the ETS adapter except `mget/1`, + which always returns `{:error, :boom}`. Used to test mget error handling paths. + """ + + @impl true + defdelegate get(key), to: SchemaCache.Adapters.ETS + + @impl true + defdelegate put(key, value, opts), to: SchemaCache.Adapters.ETS + + @impl true + defdelegate delete(key), to: SchemaCache.Adapters.ETS + + @impl true + defdelegate sadd(key, member), to: SchemaCache.Adapters.ETS + + @impl true + defdelegate srem(key, member), to: SchemaCache.Adapters.ETS + + @impl true + defdelegate smembers(key), to: SchemaCache.Adapters.ETS + + @impl true + def mget(_keys), do: {:error, :boom} +end diff --git a/test/support/fake_composite_schema.ex b/test/support/fake_composite_schema.ex new file mode 100644 index 0000000..57d7cf1 --- /dev/null +++ b/test/support/fake_composite_schema.ex @@ -0,0 +1,12 @@ +defmodule SchemaCache.Test.FakeCompositeSchema do + @moduledoc false + + use Ecto.Schema + + @primary_key false + schema "fake_composite_schemas" do + field(:tenant_id, :integer, primary_key: true) + field(:resource_id, :integer, primary_key: true) + field(:label, :string) + end +end diff --git a/test/support/fake_schema.ex b/test/support/fake_schema.ex new file mode 100644 index 0000000..9de618b --- /dev/null +++ b/test/support/fake_schema.ex @@ -0,0 +1,11 @@ +defmodule SchemaCache.Test.FakeSchema do + @moduledoc false + + use Ecto.Schema + + @primary_key {:id, :integer, autogenerate: false} + schema "fake_schemas" do + field(:name, :string) + field(:email, :string) + end +end diff --git a/test/support/redis_adapter.ex b/test/support/redis_adapter.ex new file mode 100644 index 0000000..c5c4869 --- /dev/null +++ b/test/support/redis_adapter.ex @@ -0,0 +1,91 @@ +defmodule SchemaCache.Test.RedisAdapter do + @moduledoc false + + @behaviour SchemaCache.Adapter + + # Uses a Redix connection stored in the process dictionary. + # The connection is started in RedisCase setup, per-test. + + @impl true + def get(key) do + case Redix.command(redis_conn(), ["GET", key]) do + {:ok, nil} -> {:ok, nil} + {:ok, value} -> {:ok, :erlang.binary_to_term(value)} + {:error, reason} -> {:error, reason} + end + end + + @impl true + def put(key, value, opts) do + encoded = :erlang.term_to_binary(value) + + command = + if Keyword.get(opts, :ttl) do + ["SET", key, encoded, "PX", to_string(Keyword.get(opts, :ttl))] + else + ["SET", key, encoded] + end + + case Redix.command(redis_conn(), command) do + {:ok, "OK"} -> :ok + {:error, reason} -> {:error, reason} + end + end + + @impl true + def delete(key) do + case Redix.command(redis_conn(), ["DEL", key]) do + {:ok, _} -> :ok + {:error, reason} -> {:error, reason} + end + end + + # Native set operations using Redis SADD/SREM/SMEMBERS. + # Members are integer IDs from KeyRegistry, stored as strings in Redis. + + @impl true + def sadd(key, member) do + case Redix.command(redis_conn(), ["SADD", key, to_string(member)]) do + {:ok, _} -> :ok + {:error, reason} -> {:error, reason} + end + end + + @impl true + def srem(key, member) do + case Redix.command(redis_conn(), ["SREM", key, to_string(member)]) do + {:ok, _} -> :ok + {:error, reason} -> {:error, reason} + end + end + + @impl true + def smembers(key) do + case Redix.command(redis_conn(), ["SMEMBERS", key]) do + {:ok, []} -> {:ok, nil} + {:ok, members} -> {:ok, Enum.map(members, &String.to_integer/1)} + {:error, reason} -> {:error, reason} + end + end + + @impl true + def mget(keys) do + case Redix.command(redis_conn(), ["MGET" | keys]) do + {:ok, values} -> + values + |> Enum.map(fn + nil -> nil + binary -> :erlang.binary_to_term(binary) + end) + |> then(&{:ok, &1}) + + {:error, reason} -> + {:error, reason} + end + end + + defp redis_conn do + Process.get(:schema_cache_redis_conn) || + raise "Redis connection not set. Call Process.put(:schema_cache_redis_conn, conn) in test setup." + end +end diff --git a/test/support/redis_case.ex b/test/support/redis_case.ex new file mode 100644 index 0000000..0d4c7d4 --- /dev/null +++ b/test/support/redis_case.ex @@ -0,0 +1,35 @@ +defmodule SchemaCache.Test.RedisCase do + @moduledoc false + + use ExUnit.CaseTemplate + + using do + quote do + alias SchemaCache.Test.User + end + end + + setup do + redis_url = Application.get_env(:schema_cache, :redis_url, "redis://localhost:6379") + {:ok, conn} = Redix.start_link(redis_url) + + # Flush Redis before each test + {:ok, "OK"} = Redix.command(conn, ["FLUSHDB"]) + + # Make connection available to the adapter + Process.put(:schema_cache_redis_conn, conn) + + # Set the adapter to Redis for this test + Application.put_env(:schema_cache, :adapter, SchemaCache.Test.RedisAdapter) + + on_exit(fn -> + # Reset adapter back to ETS + Application.put_env(:schema_cache, :adapter, SchemaCache.Adapters.ETS) + + # Stop the connection + if Process.alive?(conn), do: GenServer.stop(conn) + end) + + %{redis_conn: conn} + end +end diff --git a/test/support/repo.ex b/test/support/repo.ex new file mode 100644 index 0000000..262b487 --- /dev/null +++ b/test/support/repo.ex @@ -0,0 +1,7 @@ +defmodule SchemaCache.Test.Repo do + @moduledoc false + + use Ecto.Repo, + otp_app: :schema_cache, + adapter: Ecto.Adapters.Postgres +end diff --git a/test/support/user.ex b/test/support/user.ex new file mode 100644 index 0000000..a1dfc10 --- /dev/null +++ b/test/support/user.ex @@ -0,0 +1,26 @@ +defmodule SchemaCache.Test.User do + @moduledoc false + + use Ecto.Schema + import Ecto.Changeset + + @type t :: %__MODULE__{} + + @required [:name, :email] + @allowed [:name, :email] + + schema "users" do + field(:name, :string) + field(:email, :string) + + timestamps() + end + + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() + def changeset(user, attrs) do + user + |> cast(attrs, @allowed) + |> validate_required(@required) + |> unique_constraint(:email) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..cc5a61e 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,15 @@ ExUnit.start() + +{:ok, _} = SchemaCache.Supervisor.start_link(adapter: SchemaCache.Adapters.ETS) + +# Initialize ETS tables from the test runner process so they persist +# for the entire test suite (table ownership is tied to the creating process). +SchemaCache.Adapters.ETS.get("__init__") +SchemaCache.Adapters.ETS.sadd("__init__", "__init__") + +{:ok, _} = SchemaCache.Test.Repo.start_link() +Ecto.Adapters.SQL.Sandbox.mode(SchemaCache.Test.Repo, :manual) + +# Redis connections are started per-test in RedisCase, not globally. +# This provides better isolation — each test gets its own connection +# and a fresh FLUSHDB before running.