From 678b1f9214b527b1c5b404a301a0d7908ed47f91 Mon Sep 17 00:00:00 2001 From: jeroen11dijk Date: Mon, 8 Jun 2026 13:54:59 +0200 Subject: [PATCH 1/2] Memoize Reach.Project.Query.function_index/1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `function_index/1` is called from hot paths — notably `find_function_at_location/3`, which `Reach.Check.Changed.changed_functions/3` invokes once per changed line. With no caching, every call rebuilt the entire function index (four maps + a node-to-function index over `project.nodes`), producing O(diff_lines × project_nodes) work on `mix reach.check --changed`. A 4,700-line diff against a 146kLoC / 1,099-module project failed to complete in 20+ minutes pre-patch and completes in normal time after. The previous `reset_cache/0` stub already pointed at the intent. This wires it up: - Add `cache_key: reference() | nil` to `Reach.Project`, set via `make_ref/0` at both terminal constructors (`source_project/2`, `merge_project/2`). - `function_index/1` memoizes the built index in the process dictionary, keyed on `project.cache_key`. A `nil` cache_key (e.g. a struct built by external code) bypasses the cache and rebuilds on every call, so callers can't accidentally serve stale data. - `reset_cache/0` deletes the entry. The existing isolation test ("function indexes do not leak between projects in the same process") still passes because distinct projects carry distinct refs. Adds positive memoization, reset, and bypass tests. Co-Authored-By: Claude Opus 4.7 --- lib/reach/project.ex | 19 +++++++++++--- lib/reach/project/query.ex | 21 ++++++++++++++-- test/reach/project/query_cache_test.exs | 33 +++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/lib/reach/project.ex b/lib/reach/project.ex index e733ee5..07ef902 100644 --- a/lib/reach/project.ex +++ b/lib/reach/project.ex @@ -31,11 +31,20 @@ defmodule Reach.Project do nodes: %{IR.Node.id() => IR.Node.t()}, call_graph: Graph.t(), summaries: %{{module(), atom(), non_neg_integer()} => map()}, - plugins: [module()] + plugins: [module()], + cache_key: reference() | nil } @enforce_keys [:modules, :graph, :nodes, :call_graph] - defstruct [:modules, :graph, :nodes, :call_graph, summaries: %{}, plugins: []] + defstruct [ + :modules, + :graph, + :nodes, + :call_graph, + summaries: %{}, + plugins: [], + cache_key: nil + ] @doc """ Builds a project graph from source file paths. @@ -312,7 +321,8 @@ defmodule Reach.Project do graph: Graph.new(type: :directed), nodes: nodes, call_graph: Graph.new(type: :directed), - plugins: plugins + plugins: plugins, + cache_key: make_ref() } end @@ -403,7 +413,8 @@ defmodule Reach.Project do nodes: merged_nodes, call_graph: merged_call_graph, summaries: summaries, - plugins: plugins + plugins: plugins, + cache_key: make_ref() } end diff --git a/lib/reach/project/query.ex b/lib/reach/project/query.ex index d97884e..09a3259 100644 --- a/lib/reach/project/query.ex +++ b/lib/reach/project/query.ex @@ -3,9 +3,26 @@ defmodule Reach.Project.Query do alias Reach.IR.Helpers, as: IRHelpers - def function_index(project), do: build_function_index(project) + @function_index_cache_key {__MODULE__, :function_index} - def reset_cache, do: :ok + def function_index(%{cache_key: nil} = project), do: build_function_index(project) + + def function_index(%{cache_key: key} = project) do + case Process.get(@function_index_cache_key) do + {^key, index} -> + index + + _miss -> + index = build_function_index(project) + Process.put(@function_index_cache_key, {key, index}) + index + end + end + + def reset_cache do + Process.delete(@function_index_cache_key) + :ok + end def find_function(project, target) do index = function_index(project) diff --git a/test/reach/project/query_cache_test.exs b/test/reach/project/query_cache_test.exs index c87f77e..2aeb184 100644 --- a/test/reach/project/query_cache_test.exs +++ b/test/reach/project/query_cache_test.exs @@ -4,6 +4,11 @@ defmodule Reach.Project.QueryCacheTest do alias Reach.Project alias Reach.Project.Query + setup do + Query.reset_cache() + :ok + end + test "function indexes do not leak between projects in the same process" do first = project_with("FirstOnly", "run") second = project_with("SecondOnly", "execute") @@ -15,6 +20,34 @@ defmodule Reach.Project.QueryCacheTest do refute Query.find_function(second, {FirstOnly, :run, 0}) end + test "function_index is memoized within a process for the same project" do + project = project_with("Memoized", "go") + + first = Query.function_index(project) + second = Query.function_index(project) + + assert :erts_debug.same(first, second) + end + + test "reset_cache invalidates the cached function index" do + project = project_with("Resettable", "do_thing") + + first = Query.function_index(project) + Query.reset_cache() + second = Query.function_index(project) + + refute :erts_debug.same(first, second) + end + + test "projects without a cache_key skip the cache and rebuild every call" do + project = project_with("Uncached", "act") |> Map.put(:cache_key, nil) + + first = Query.function_index(project) + second = Query.function_index(project) + + refute :erts_debug.same(first, second) + end + defp project_with(module, function) do source = """ defmodule #{module} do From ecf7bd9e5623c27c51ea879cb577a9151d13b638 Mon Sep 17 00:00:00 2001 From: jeroen11dijk Date: Mon, 8 Jun 2026 14:00:20 +0200 Subject: [PATCH 2/2] Drop defensive nil-cache_key clause and redundant test setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExUnit runs each test in its own process, so the per-test setup that reset the process-dict cache was a no-op. The nil cache_key clause was a safety net for projects constructed outside Reach.Project's own constructors — both constructors now always set make_ref/0, so the safety net just adds API surface and test scaffolding without protecting against a realistic case. Co-Authored-By: Claude Opus 4.7 --- lib/reach/project/query.ex | 2 -- test/reach/project/query_cache_test.exs | 14 -------------- 2 files changed, 16 deletions(-) diff --git a/lib/reach/project/query.ex b/lib/reach/project/query.ex index 09a3259..2d0385e 100644 --- a/lib/reach/project/query.ex +++ b/lib/reach/project/query.ex @@ -5,8 +5,6 @@ defmodule Reach.Project.Query do @function_index_cache_key {__MODULE__, :function_index} - def function_index(%{cache_key: nil} = project), do: build_function_index(project) - def function_index(%{cache_key: key} = project) do case Process.get(@function_index_cache_key) do {^key, index} -> diff --git a/test/reach/project/query_cache_test.exs b/test/reach/project/query_cache_test.exs index 2aeb184..0e348f3 100644 --- a/test/reach/project/query_cache_test.exs +++ b/test/reach/project/query_cache_test.exs @@ -4,11 +4,6 @@ defmodule Reach.Project.QueryCacheTest do alias Reach.Project alias Reach.Project.Query - setup do - Query.reset_cache() - :ok - end - test "function indexes do not leak between projects in the same process" do first = project_with("FirstOnly", "run") second = project_with("SecondOnly", "execute") @@ -39,15 +34,6 @@ defmodule Reach.Project.QueryCacheTest do refute :erts_debug.same(first, second) end - test "projects without a cache_key skip the cache and rebuild every call" do - project = project_with("Uncached", "act") |> Map.put(:cache_key, nil) - - first = Query.function_index(project) - second = Query.function_index(project) - - refute :erts_debug.same(first, second) - end - defp project_with(module, function) do source = """ defmodule #{module} do