diff --git a/docs/src/configuration.md b/docs/src/configuration.md index b3f36dc..e827ac3 100644 --- a/docs/src/configuration.md +++ b/docs/src/configuration.md @@ -23,8 +23,9 @@ Settings are looked up per function, in this order (first match wins): 1. runtime calls: [`set_cache_size!`](@ref); 2. the function's own section, `[.MemoizationKit.]`; 3. the section of the package that owns the function, `[.MemoizationKit]`; -4. MemoizationKit's own section, `[MemoizationKit]`; -5. the built-in defaults above. +4. defaults provided by the package's code, [`MemoizationKit.default_cache_settings`](@ref); +5. MemoizationKit's own section, `[MemoizationKit]`; +6. the built-in defaults above. The package that owns a function is the package of the module that defines it, `parentmodule(typeof(f))`. For a function extended by several packages, that is the package that defines the function, not the ones that add cached methods to it. @@ -45,6 +46,19 @@ maxsize = 2_000_000_000 Unknown keys and invalid values are ignored with a warning. +## Package defaults + +A package can ship defaults for its own functions by overloading [`MemoizationKit.default_cache_settings`](@ref), instead of writing preferences on load or calling [`set_cache_size!`](@ref) from `__init__`: + +```julia +MemoizationKit.default_cache_settings(::typeof(device_kernel)) = (; measure = "bytes", maxsize = 2^28) +``` + +These defaults take precedence over the global `[MemoizationKit]` section, which is not specific to the package, so a generic limit does not override a smaller one the package chose, e.g. for values holding device memory. +The package's own sections, `[.MemoizationKit]` and `[.MemoizationKit.]`, still override them. +For several functions at once, define the method for a union, e.g. `::Union{typeof(f), typeof(g)}`. +Like preferences, the defaults are read when a function's first cache is created, so define the method in the same package or extension as the cached methods. + ## When changes take effect - `disk` and `disk_path` are read when a function first uses its disk cache in the session. diff --git a/docs/src/reference.md b/docs/src/reference.md index 1884dfb..f4e1076 100644 --- a/docs/src/reference.md +++ b/docs/src/reference.md @@ -49,6 +49,7 @@ empty_caches! set_cache_size! set_cache_preferences! MemoizationKit.cachesize +MemoizationKit.default_cache_settings cache_dashboard ``` diff --git a/ext/MemoizationKitSQLiteExt.jl b/ext/MemoizationKitSQLiteExt.jl index a7fa476..ca928c4 100644 --- a/ext/MemoizationKitSQLiteExt.jl +++ b/ext/MemoizationKitSQLiteExt.jl @@ -6,7 +6,7 @@ module MemoizationKitSQLiteExt # give across nodes. Disk errors never fail a call: they warn once and the call goes on. using MemoizationKit: MemoizationKit, DiskCache, _compute, _fname, _owner, _ispackage, _within, instrument, - _resolve_settings, _cached_section, _package_section, get_scratch!, sha256 + _settings, get_scratch!, sha256 using SQLite: SQLite, DBInterface using Serialization: serialize, deserialize, writeheader @@ -104,7 +104,7 @@ function stores(f::F) where {F} end function open_stores(f) - settings = _resolve_settings(_fname(f), _cached_section(), _package_section(f)) + settings = _settings(f) settings.disk || return Stores(f, nothing, nothing) dir = MemoizationKit.disk_artifact(f) artifact = dir === nothing ? nothing : attempt(() -> Store(joinpath(dir, storename(f) * ".sqlite"); immutable = true), "open the disk artifact of `$(_fname(f))` in $dir") diff --git a/src/MemoizationKit.jl b/src/MemoizationKit.jl index b353060..57ed5c2 100644 --- a/src/MemoizationKit.jl +++ b/src/MemoizationKit.jl @@ -11,7 +11,7 @@ export disable_disk_caches!, enable_disk_caches! # documented API that is used qualified, e.g. by overloading `MemoizationKit.cachesize` @static if VERSION >= v"1.11.0-DEV.469" - eval(Meta.parse("public AbstractCache, cache_stats, disk_cache_stats, cachesize, implementation, instrument_label, diskversion, disk_artifact, cachekey")) + eval(Meta.parse("public AbstractCache, cache_stats, disk_cache_stats, cachesize, default_cache_settings, implementation, instrument_label, diskversion, disk_artifact, cachekey")) end using Base: @lock diff --git a/src/preferences.jl b/src/preferences.jl index 9ddf62a..ce25965 100644 --- a/src/preferences.jl +++ b/src/preferences.jl @@ -2,7 +2,7 @@ # # Settings are resolved once per function, when its first cache is created: # runtime calls (`set_cache_size!`, ...) > [.MemoizationKit.] > [.MemoizationKit] -# > [MemoizationKit] > built-in defaults +# > default_cache_settings(f) > [MemoizationKit] > built-in defaults # where is the package that owns the function (`parentmodule(typeof(f))`). # The default container is a compile-time preference of MemoizationKit only, since it selects the # `CacheStyle` and so must be a constant. @@ -24,6 +24,25 @@ cachesize(x) = Base.summarysize(x) const MEASURES = Dict{String, Any}("count" => nothing, "bytes" => cachesize) +""" + MemoizationKit.default_cache_settings(f) -> NamedTuple + +Default cache settings of `f` provided by the package that defines its cached methods, e.g. a +smaller limit for values that hold scarce memory. They take precedence over the built-in defaults +and the `[MemoizationKit]` preferences, but the preferences of the package that owns `f` and of +`f` itself override them. Defaults to `(;)`, no settings. Settings are `maxsize`, `measure` +(`"count"` or `"bytes"`), `disk` and `disk_path`, as for [`set_cache_preferences!`](@ref). + +```julia +MemoizationKit.default_cache_settings(::typeof(device_kernel)) = (; measure = "bytes", maxsize = 2^28) +``` + +Settings are read when the first cache of `f` is created, so define this method in the same +package (or extension) as the cached methods of `f`. Unknown or invalid settings throw an +`ArgumentError` from that call. +""" +default_cache_settings(f) = (;) + const DEFAULT_CONTAINER = let name = @load_preference("container", "ClockCache") containers = Dict("ClockCache" => ClockCache, "LRU" => LRU) haskey(containers, name) || @@ -31,11 +50,23 @@ const DEFAULT_CONTAINER = let name = @load_preference("container", "ClockCache") containers[name] end -# Resolve settings from the preference sections, most general first. `package` is the -# `[.MemoizationKit]` table (or `nothing`), whose sub-tables are per-function sections. -function _resolve_settings(fname::AbstractString, cached::AbstractDict, package) +# Resolve settings from the preference sections and the package defaults, most general first. +# `defaults` is `default_cache_settings(f)`, and `package` the `[.MemoizationKit]` table +# (or `nothing`), whose sub-tables are per-function sections. Invalid preferences are ignored with +# a warning, while invalid defaults are a bug of the package and throw. +function _resolve_settings(fname::AbstractString, cached::AbstractDict, defaults, package) settings = Dict{String, Any}(string(k) => v for (k, v) in pairs(BUILTIN_SETTINGS)) _merge_settings!(settings, cached, "[MemoizationKit]") + for (k, v) in pairs(defaults) + k = string(k) + k in SETTING_KEYS && _isvalid(k, v) || throw( + ArgumentError( + "$(k in SETTING_KEYS ? "invalid" : "unknown") cache setting `$k = $(repr(v))` " * + "in `MemoizationKit.default_cache_settings(::typeof($fname))`" + ) + ) + settings[k] = v + end if package isa AbstractDict _merge_settings!(settings, package, "[.MemoizationKit]") section = get(package, fname, nothing) @@ -53,9 +84,9 @@ function _merge_settings!(settings, section::AbstractDict, where) for (k, v) in section v isa AbstractDict && continue # per-function section if !(k in SETTING_KEYS) - @warn "MemoizationKit: ignoring unknown preference `$k` in $where" + @warn "MemoizationKit: ignoring unknown setting `$k` in $where" elseif !_isvalid(k, v) - @warn "MemoizationKit: ignoring invalid preference `$k = $(repr(v))` in $where" + @warn "MemoizationKit: ignoring invalid setting `$k = $(repr(v))` in $where" else settings[k] = v end @@ -82,8 +113,10 @@ end _fname(f) = string(f isa Function ? nameof(f) : nameof(typeof(f))) +_settings(f) = _resolve_settings(_fname(f), _cached_section(), default_cache_settings(f), _package_section(f)) + function FunctionCaches(f) - s = _resolve_settings(_fname(f), _cached_section(), _package_section(f)) + s = _settings(f) return FunctionCaches(s.maxsize, s.by, AbstractCache[]) end diff --git a/test/test_disk.jl b/test/test_disk.jl index caf4792..53c7fc3 100644 --- a/test/test_disk.jl +++ b/test/test_disk.jl @@ -188,8 +188,8 @@ MemoizationKit.DiskCacheStyle(::Union{typeof(prefoff), typeof(prefpath), typeof( @testset "preferences, and disk errors" begin package = Dict{String, Any}("disk_path" => "/x", "f" => Dict{String, Any}("disk" => false)) - @test MemoizationKit._resolve_settings("f", Dict{String, Any}("disk" => true), package)[(:disk, :disk_path)] == (; disk = false, disk_path = "/x") - @test (@test_logs (:warn, r"invalid preference `disk = 1`") MemoizationKit._resolve_settings("f", Dict{String, Any}("disk" => 1), nothing)).disk + @test MemoizationKit._resolve_settings("f", Dict{String, Any}("disk" => true), (;), package)[(:disk, :disk_path)] == (; disk = false, disk_path = "/x") + @test (@test_logs (:warn, r"invalid setting `disk = 1`") MemoizationKit._resolve_settings("f", Dict{String, Any}("disk" => 1), (;), nothing)).disk @test_throws ArgumentError set_cache_preferences!(; disk = 1) # in a private project, as in `test_preferences.jl`; it comes before `DIR` on the load path diff --git a/test/test_preferences.jl b/test/test_preferences.jl index 3d40b6a..23b7e78 100644 --- a/test/test_preferences.jl +++ b/test/test_preferences.jl @@ -7,27 +7,38 @@ resolve(args...) = MemoizationKit._resolve_settings(args...)[(:maxsize, :by)] # caches(f) = last.(cache_info(f)) @testset "resolution order" begin - @test resolve("f", Dict(), nothing) == (; maxsize = 10_000, by = nothing) + @test resolve("f", Dict(), (;), nothing) == (; maxsize = 10_000, by = nothing) cached = Dict{String, Any}("maxsize" => 500) - @test resolve("f", cached, nothing) == (; maxsize = 500, by = nothing) - package = Dict{String, Any}("maxsize" => 50, "measure" => "bytes", "g" => Dict{String, Any}("maxsize" => 5)) - @test resolve("f", cached, package) == (; maxsize = 50, by = MemoizationKit.cachesize) - @test resolve("g", cached, package) == (; maxsize = 5, by = MemoizationKit.cachesize) + @test resolve("f", cached, (;), nothing) == (; maxsize = 500, by = nothing) + # package defaults override MemoizationKit's section, but not the package's sections + defaults = (; maxsize = 100, measure = "bytes") + @test resolve("f", cached, defaults, nothing) == (; maxsize = 100, by = MemoizationKit.cachesize) + package = Dict{String, Any}("maxsize" => 50, "g" => Dict{String, Any}("maxsize" => 5, "measure" => "count")) + @test resolve("f", cached, defaults, package) == (; maxsize = 50, by = MemoizationKit.cachesize) + @test resolve("g", cached, defaults, package) == (; maxsize = 5, by = nothing) end -@testset "invalid preferences are ignored with a warning" begin +@testset "invalid settings are ignored with a warning" begin for (section, msg) in ( - Dict{String, Any}("maxsise" => 1) => r"unknown preference `maxsise`", - Dict{String, Any}("maxsize" => -1) => r"invalid preference `maxsize = -1`", - Dict{String, Any}("maxsize" => true) => r"invalid preference `maxsize = true`", - Dict{String, Any}("maxsubcaches" => 2) => r"unknown preference `maxsubcaches`", # removed setting - Dict{String, Any}("measure" => "kilos") => r"invalid preference `measure = \"kilos\"`", + Dict{String, Any}("maxsise" => 1) => r"unknown setting `maxsise`", + Dict{String, Any}("maxsize" => -1) => r"invalid setting `maxsize = -1`", + Dict{String, Any}("maxsize" => true) => r"invalid setting `maxsize = true`", + Dict{String, Any}("maxsubcaches" => 2) => r"unknown setting `maxsubcaches`", # removed setting + Dict{String, Any}("measure" => "kilos") => r"invalid setting `measure = \"kilos\"`", ) - s = @test_logs (:warn, msg) resolve("f", section, nothing) - @test s == resolve("f", Dict(), nothing) + s = @test_logs (:warn, msg) resolve("f", section, (;), nothing) + @test s == resolve("f", Dict(), (;), nothing) end end +@testset "invalid package defaults throw" begin + @test_throws r"invalid cache setting `measure = :bytes`.*default_cache_settings\(::typeof\(f\)\)" resolve( + "f", Dict(), (; measure = :bytes), nothing + ) + @test_throws r"unknown cache setting `maxsise = 1`" resolve("f", Dict(), (; maxsise = 1), nothing) + @test_throws ArgumentError resolve("f", Dict(), (; container = "LRU"), nothing) # global only +end + struct Key x::Int end @@ -36,6 +47,10 @@ end @cached fromcached(x) = x @cached Aqua.test_all(k::Key) = k.x @cached Aqua.test_ambiguities(k::Key) = k.x +@cached Aqua.test_unbound_args(k::Key) = k.x +MemoizationKit.default_cache_settings(::typeof(Aqua.test_unbound_args)) = (; maxsize = 6) +@cached Aqua.test_undefined_exports(k::Key) = k.x +MemoizationKit.default_cache_settings(::typeof(Aqua.test_undefined_exports)) = (; maxsize = -1) # Preferences are written next to the active project. Point it at a private temporary project # while writing them, so that test files running in parallel never see them. @@ -79,6 +94,8 @@ with_private_preferences() do Aqua.test_ambiguities(Key(1)) @test MemoizationKit.cache_stats(only(cache_info(Aqua.test_all)).second).maxsize == 3 @test MemoizationKit.cache_stats(only(cache_info(Aqua.test_ambiguities)).second).maxsize == 2 + # package defaults override MemoizationKit's section, the package's section overrides them + @test MemoizationKit._settings(Aqua.test_unbound_args).maxsize == 3 # runtime settings still override preferences set_cache_size!(Aqua.test_all, 11) @test MemoizationKit.cache_stats(only(cache_info(Aqua.test_all)).second).maxsize == 11 @@ -86,6 +103,19 @@ with_private_preferences() do delete_preferences!(Aqua, "MemoizationKit"; force = true) end + # package defaults apply without preferences, and take precedence over MemoizationKit's section + set_preferences!(MemoizationKit, "maxsize" => 7; force = true) + try + Aqua.test_unbound_args(Key(1)) + @test MemoizationKit.cache_stats(only(cache_info(Aqua.test_unbound_args)).second).maxsize == 6 + finally + delete_preferences!(MemoizationKit, "maxsize"; force = true) + end + + # invalid package defaults fail the call, every time + @test_throws ArgumentError Aqua.test_undefined_exports(Key(1)) + @test_throws ArgumentError Aqua.test_undefined_exports(Key(1)) + # functions outside packages only see MemoizationKit's section @test MemoizationKit._package_section(fromcached) === nothing end @@ -109,7 +139,7 @@ with_private_preferences() do "measure" => "bytes", "test_all" => Dict("maxsize" => 4), "test_ambiguities" => Dict("measure" => "count") ) - @test resolve("test_all", Dict(), section) == + @test resolve("test_all", Dict(), (;), section) == (; maxsize = 4, by = MemoizationKit.cachesize) set_cache_preferences!(Aqua.test_all; maxsize = nothing) # empty sections are removed @test !haskey(load_preference(Aqua, "MemoizationKit"), "test_all")