From 737cf12ae475936c5141363e5011c86a44ec6af5 Mon Sep 17 00:00:00 2001 From: Riccardo Binetti Date: Mon, 22 Dec 2025 15:59:23 +0100 Subject: [PATCH] Add query_cache option to selectively bypass query cache Highly dynamic queries with many different combinations of joins can generate unique cache keys on every execution, causing cache bloat while providing little performance benefit. This option allows selectively disabling Ecto's internal query cache for such cases, preventing cache pollution while still maintaining normal caching behavior for typical queries. When set to false, the query bypasses cache lookup, cache insertion, and cache update callbacks, going directly to query normalization and adapter preparation on each execution. --- lib/ecto/adapter/queryable.ex | 9 ++++--- lib/ecto/query/planner.ex | 3 ++- lib/ecto/repo.ex | 5 ++++ lib/ecto/repo/queryable.ex | 8 +++++-- test/ecto/query/planner_test.exs | 25 ++++++++++++++++++++ test/support/test_repo.exs | 40 ++++++++++++++++++++++++++++++++ 6 files changed, 84 insertions(+), 6 deletions(-) diff --git a/lib/ecto/adapter/queryable.ex b/lib/ecto/adapter/queryable.ex index 9d0a62d5c8..8350801232 100644 --- a/lib/ecto/adapter/queryable.ex +++ b/lib/ecto/adapter/queryable.ex @@ -98,16 +98,19 @@ defmodule Ecto.Adapter.Queryable do @doc """ Plans and prepares a query for the given repo, leveraging its query cache. - This operation uses the query cache if one is available. + This operation uses the query cache if one is available, unless + `query_cache: false` is passed as option, which bypasses the query cache. """ - def prepare_query(operation, repo_name_or_pid, queryable) do + def prepare_query(operation, repo_name_or_pid, queryable, opts \\ []) do %{adapter: adapter, cache: cache} = Ecto.Repo.Registry.lookup(repo_name_or_pid) + query_cache? = Keyword.get(opts, :query_cache, true) + {_meta, prepared, _cast_params, dump_params} = queryable |> Ecto.Queryable.to_query() |> Ecto.Query.Planner.ensure_select(operation == :all) - |> Ecto.Query.Planner.query(operation, cache, adapter, 0) + |> Ecto.Query.Planner.query(operation, cache, adapter, 0, query_cache?) {prepared, dump_params} end diff --git a/lib/ecto/query/planner.ex b/lib/ecto/query/planner.ex index 424e24f3c7..fa1b8be024 100644 --- a/lib/ecto/query/planner.ex +++ b/lib/ecto/query/planner.ex @@ -133,9 +133,10 @@ defmodule Ecto.Query.Planner do The cache value is the compiled query by the adapter along-side the select expression. """ - def query(query, operation, cache, adapter, counter) do + def query(query, operation, cache, adapter, counter, query_cache?) do {query, params, key} = plan(query, operation, adapter) {cast_params, dump_params} = Enum.unzip(params) + key = if query_cache?, do: key, else: :nocache query_with_cache(key, query, operation, cache, adapter, counter, cast_params, dump_params) end diff --git a/lib/ecto/repo.ex b/lib/ecto/repo.ex index e97545463f..91b6398b45 100644 --- a/lib/ecto/repo.ex +++ b/lib/ecto/repo.ex @@ -136,6 +136,11 @@ defmodule Ecto.Repo do See the next section for more information * `:telemetry_options` - Extra options to attach to telemetry event name. See the next section for more information + * `:query_cache` - When set to `false`, bypasses the Ecto query cache for the current + operation. This means the query will not be looked up in the cache, it will not be stored + in the cache and no cache update function will not be passed to the adapter. Note that + this doesn't necessarily disable the database cache, it only affects Ecto's internal + cache of normalized queries and adapter prepared statements. Defaults to `true`. ## Adapter-Specific Errors diff --git a/lib/ecto/repo/queryable.ex b/lib/ecto/repo/queryable.ex index b6b9614f4a..a9fd650c1c 100644 --- a/lib/ecto/repo/queryable.ex +++ b/lib/ecto/repo/queryable.ex @@ -39,8 +39,10 @@ defmodule Ecto.Repo.Queryable do {query, opts} = repo.prepare_query(:stream, query, opts) query = attach_prefix(query, opts) + query_cache? = Keyword.get(opts, :query_cache, true) + {query_meta, prepared, cast_params, dump_params} = - Planner.query(query, :all, cache, adapter, 0) + Planner.query(query, :all, cache, adapter, 0, query_cache?) opts = [cast_params: cast_params] ++ opts @@ -223,8 +225,10 @@ defmodule Ecto.Repo.Queryable do {query, opts} = repo.prepare_query(operation, query, opts) query = attach_prefix(query, opts) + query_cache? = Keyword.get(opts, :query_cache, true) + {query_meta, prepared, cast_params, dump_params} = - Planner.query(query, operation, cache, adapter, 0) + Planner.query(query, operation, cache, adapter, 0, query_cache?) opts = [cast_params: cast_params] ++ opts diff --git a/test/ecto/query/planner_test.exs b/test/ecto/query/planner_test.exs index 26e4a191a7..1d02db869c 100644 --- a/test/ecto/query/planner_test.exs +++ b/test/ecto/query/planner_test.exs @@ -2829,4 +2829,29 @@ defmodule Ecto.Query.PlannerTest do end end end + + describe "query: query_cache option" do + setup do + cache = Planner.new_query_cache(__MODULE__) + {:ok, cache: cache} + end + + test "uses cache if true", %{cache: cache} do + query = from(p in Post, where: p.title == ^"hello") + + {_meta, {:cache, _update, _prepared}, _cast, _dump} = + Planner.query(query, :all, cache, Ecto.CachingTestAdapter, 0, true) + + assert :ets.info(cache, :size) == 1 + end + + test "bypasses cache if false", %{cache: cache} do + query = from(p in Post, where: p.title == ^"hello") + + {_meta1, {:nocache, _prepared}, _cast1, _dump1} = + Planner.query(query, :all, cache, Ecto.CachingTestAdapter, 0, false) + + assert :ets.info(cache, :size) == 0 + end + end end diff --git a/test/support/test_repo.exs b/test/support/test_repo.exs index 4df2752802..7d7b8a322f 100644 --- a/test/support/test_repo.exs +++ b/test/support/test_repo.exs @@ -161,6 +161,46 @@ defmodule Ecto.TestAdapter do end end +defmodule Ecto.CachingTestAdapter do + @moduledoc """ + Test adapter that supports query caching, used for testing the query_cache option. + """ + @behaviour Ecto.Adapter + @behaviour Ecto.Adapter.Queryable + + defmacro __before_compile__(_opts), do: :ok + + def ensure_all_started(_, _), do: {:ok, []} + + def init(_opts) do + {:ok, Supervisor.child_spec({Task, fn -> :timer.sleep(:infinity) end}, []), %{}} + end + + def checkout(_mod, _opts, fun), do: fun.() + def checked_out?(_mod), do: false + + def loaders(_primitive, type), do: [type] + def dumpers(_primitive, type), do: [type] + def autogenerate(:id), do: nil + def autogenerate(:embed_id), do: Ecto.UUID.autogenerate() + def autogenerate(:binary_id), do: Ecto.UUID.bingenerate() + + # Return :cache to trigger default caching in the planner + def prepare(operation, query), do: {:cache, {operation, query}} + + def execute(_adapter_meta, _query_meta, {_cache_status, {:all, _query}}, _dump_params, _opts) do + [] + end + + def execute(_adapter_meta, _query_meta, {_cache_status, {_operation, _query}}, _dump_params, _opts) do + {1, nil} + end + + def stream(_adapter_meta, _query_meta, _prepared, _dump_params, _opts) do + [] + end +end + Application.put_env(:ecto, Ecto.TestRepo, user: "invalid") defmodule Ecto.TestRepo do