From faf6ea2675f8793412f78ef9ac3c35d8b6b3d2b3 Mon Sep 17 00:00:00 2001 From: Sebastian Pfitzner Date: Fri, 3 Jul 2026 12:48:02 +0000 Subject: [PATCH] fix: evict memoized derived values when files are removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing a file only deleted its inputs; every derived value keyed by the URI (parse trees, diagnostics, testitems, ...) stayed in the Salsa storage for the lifetime of the workspace — measured at ~1.5 MB retained per removed file. Use the new Salsa.evict_derived! to drop all entries keyed by removed URIs in remove_file!, remove_all_children! (batched per call), and clear_indirect_file!. The promotion path in add_file! (indirect file becoming a regular file) deliberately does not evict: the URI stays live and its derived values revalidate through normal Salsa invalidation. Co-Authored-By: Claude Fable 5 --- src/public.jl | 16 +++++++++++++ test/test_eviction.jl | 55 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 test/test_eviction.jl diff --git a/src/public.jl b/src/public.jl index 13f09634..2fa39089 100644 --- a/src/public.jl +++ b/src/public.jl @@ -263,9 +263,20 @@ function remove_file!(jw::JuliaWorkspace, uri::URI) process_from_dynamic(jw) _remove_file!(jw, uri) + _evict_derived_for_uris!(jw, (uri,)) _reconcile!(jw) end +# Evict all memoized derived values keyed by any of the given (removed) URIs. +# Without this they would stay in the Salsa storage for the lifetime of the +# workspace, since nothing queries a removed URI again. +function _evict_derived_for_uris!(jw::JuliaWorkspace, uris) + uri_set = Set{URI}(uris) + Salsa.evict_derived!(jw.runtime) do key + any(arg -> arg isa URI && arg in uri_set, key.args) + end +end + # Input-only removal. Does not drain results or reconcile. function _remove_file!(jw::JuliaWorkspace, uri::URI) files = input_files(jw.runtime) @@ -293,14 +304,18 @@ function remove_all_children!(jw::JuliaWorkspace, uri::URI) uri_as_string = string(uri) + removed = URI[] for file in files file_as_string = string(file) if startswith(file_as_string, uri_as_string) _remove_file!(jw, file) + push!(removed, file) end end + isempty(removed) || _evict_derived_for_uris!(jw, removed) + _reconcile!(jw) end @@ -346,6 +361,7 @@ function clear_indirect_file!(jw::JuliaWorkspace, uri::URI) process_from_dynamic(jw) _clear_indirect_tracking!(jw, uri) + _evict_derived_for_uris!(jw, (uri,)) _reconcile!(jw) end diff --git a/test/test_eviction.jl b/test/test_eviction.jl new file mode 100644 index 00000000..796133d8 --- /dev/null +++ b/test/test_eviction.jl @@ -0,0 +1,55 @@ +@testitem "remove_file! evicts derived values for the removed URI" begin + using JuliaWorkspaces.URIs2: URI + using JuliaWorkspaces: Salsa + + n_entries_for(jw, uri) = + Salsa.evict_derived!(key -> any(arg -> isequal(arg, uri), key.args), jw.runtime) + + uri1 = URI("file:///evicttest/src/a.jl") + uri2 = URI("file:///evicttest/src/b.jl") + + jw = JuliaWorkspace() + add_file!(jw, TextFile(uri1, SourceText("f(x) = x + 1", "julia"))) + add_file!(jw, TextFile(uri2, SourceText("g(x) = x - 1", "julia"))) + + # Populate the derived caches. + get_diagnostic(jw, uri1) + get_diagnostic(jw, uri2) + + remove_file!(jw, uri1) + + # No derived entry may still be keyed by the removed URI. (The probe uses + # evict_derived! itself: it returns the number of matching entries.) + @test n_entries_for(jw, uri1) == 0 + + # The surviving file is untouched and still queryable. + @test get_diagnostic(jw, uri2) isa Vector + @test has_file(jw, uri2) +end + +@testitem "remove_all_children! evicts derived values for all removed URIs" begin + using JuliaWorkspaces.URIs2: URI + using JuliaWorkspaces: Salsa + + n_entries_for(jw, uri) = + Salsa.evict_derived!(key -> any(arg -> isequal(arg, uri), key.args), jw.runtime) + + uris = [URI("file:///evictfolder/src/f$i.jl") for i in 1:5] + outside = URI("file:///other/src/g.jl") + + jw = JuliaWorkspace() + for uri in uris + add_file!(jw, TextFile(uri, SourceText("h(x) = x", "julia"))) + end + add_file!(jw, TextFile(outside, SourceText("h(x) = x", "julia"))) + + get_diagnostics(jw) + + remove_all_children!(jw, URI("file:///evictfolder")) + + for uri in uris + @test n_entries_for(jw, uri) == 0 + end + @test has_file(jw, outside) + @test get_diagnostic(jw, outside) isa Vector +end