Skip to content
Merged
30 changes: 30 additions & 0 deletions .github/workflows/Documentation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ on:
permissions:
contents: write
statuses: write
pull-requests: write

jobs:
Documentation:
Expand All @@ -26,3 +27,32 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # For authentication with GitHub Actions token
DOCUMENTER_KEY: ${{ secrets.DOCUMENTER_KEY }} # For authentication with SSH deploy key
run: julia --project=docs/ docs/make.jl
- name: Comment with documentation preview
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
uses: actions/github-script@v9
with:
script: |
const marker = '<!-- cached-docs-preview -->';
const issue_number = context.issue.number;
const url = `https://lkdvos.github.io/Cached.jl/previews/PR${issue_number}/`;
const body = `${marker}\n[Preview the documentation](${url})`;
const comments = await github.paginate(github.rest.issues.listComments, {
...context.repo,
issue_number,
});
const comment = comments.find(c =>
c.user.login === 'github-actions[bot]' && c.body?.includes(marker)
);
if (comment) {
await github.rest.issues.updateComment({
...context.repo,
comment_id: comment.id,
body,
});
} else {
await github.rest.issues.createComment({
...context.repo,
issue_number,
body,
});
}
84 changes: 31 additions & 53 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,70 +1,48 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
# Repository guidance

## Layout

This checkout is the `main` worktree of a bare repository: `../Cached.jl` is the bare git dir and
sibling directories of `main/` are other worktrees. Create new worktrees next to `main/`
(`git worktree add ../<name> -b <branch>`), never inside it.
This repository uses sibling worktrees with the bare Git repository at `../Cached.jl`.
Create additional worktrees beside the current checkout, never inside it.

## Commands
- `src/`: memoization macro, strategy dispatch, cache containers, registry, and configuration.
- `ext/`: optional SQLite persistence, Tachikoma dashboard, and TimerOutputs integration.
- `test/`: tests discovered by ParallelTestRunner.
- `docs/src/`: user guides and API reference.
- `benchmark/`: cache container and disk benchmarks.

No `Manifest.toml` is checked in (it is gitignored), so instantiate first:
See `docs/src/index.md` for behavior and `docs/src/interface.md` for the custom cache contract.

```bash
julia --project -e 'using Pkg; Pkg.instantiate()'
```
## Development

Manifests are ignored; instantiate the environment before running tests or building docs.

```bash
# Run all tests (ParallelTestRunner picks up every test/*.jl except runtests.jl)
julia --project -e 'using Pkg; Pkg.test()'
# Install dependencies and run tests
julia --project -e 'using Pkg; Pkg.instantiate(); Pkg.test()'

# Run a single test file
julia --project=test test/test_aqua.jl
julia --project=test test/test_macro.jl

# Build docs
# Prepare and build documentation
julia --project=docs -e 'using Pkg; Pkg.develop(path=pwd()); Pkg.instantiate()'
julia --project=docs docs/make.jl

# Format (Runic). Not a project dependency; CI checks it through a reusable workflow.
julia --project=/tmp/runic -e 'using Pkg; Pkg.add("Runic"); using Runic; Runic.main(["--inplace", "src", "ext", "test", "docs"])'
# Benchmark containers
julia --project=benchmark -t 8 benchmark/containers.jl
```

## Architecture

`research/design.md` is the spec; the other notes in `research/` describe the archived prototype
(branch `archive/prototype`) and are background only.
Format Julia files with Runic; CI checks formatting.
Keep Markdown prose to one sentence per line.

- `src/macro.jl`: `@cached` moves the body to a method of `Cached.implementation(::typeof(f), ...)`
and leaves `f` with its original signature, calling `Cached.call`. ExprTools does the parsing.
- `src/call.jl`: `call` picks the value type `V` (annotation, or `return_type`) and dispatches on
the `CacheStyle` (`NoCache`, `GlobalCache{C}`, `TaskLocalCache{C}`). The lookup and the
implementation call are wrapped in the internal `instrument(f, Val(phase), thunk, owner)` hook,
with `owner` the package owning `f` (or its module, outside packages); its default `thunk()` compiles away.
- `src/registry.jl`: global caches, one untyped `C{Any,Any}` per function (its budget), looked up
lock-free in an atomically published `IdDict` snapshot.
- `src/containers/`: `interface.jl` is the public `AbstractCache` interface (`docs/src/interface.md`);
`slots.jl` implements it once for the internal `SlotCache` (storage in field `slots`), of which
`LRU` and `ClockCache` are eviction policies (hooks `admit!`, `touch!`, `victim`, `forget!`).
Keys are stored as `Key{Any}` and probed with a concretely typed `Key{K}`, so hits never box.
- `src/preferences.jl`: default settings from Preferences.jl, resolved once per function
(runtime > function > package > `[Cached]` > built-in), and `set_cache_preferences!`.
- `src/api.jl`: `cache_info`, `empty_caches!`, `set_cache_size!`, and the `cache_dashboard` and
`enable_cache_timers!`/`disable_cache_timers!` stubs with their error hints (registered in `__init__`).
- `src/disk.jl`: `DiskCacheStyle` (default `NoCache()`) and `DiskCache{S}` (serializer type `S`),
`diskversion`, `disk_artifact`, the process-wide switch, and `_diskcall`, the miss function of
`get!` under a `DiskCache`; the lookup and management functions are stubs for the SQLite extension.
- `ext/CachedSQLiteExt.jl`: the disk caches. Per function type an optional read-only artifact and
one SQLite database per node (WAL, `keyhash`+`key` → `value`), opened on first use; disk errors
warn once and the call continues. Lookup order: RAM, artifact, node database, compute.
- `ext/CachedTachikomaExt.jl`: the dashboard. It renders from a copy of the statistics, refreshed
every `interval`; tests render it headlessly with Tachikoma's `TestBackend`. Two tabs (`Tab`
structs, Tachikoma's `TabBar`): RAM, and Disk, whose rows come from `Cached.disk_cache_stats()`
(in-memory counters of the SQLite extension) and whose entries and sizes (`disk_cache_info`,
a `count(*)`) are read for all rows on a background task while it is shown, at most every 10 s.
- `ext/CachedTimerOutputsExt.jl`: `enable_cache_timers!(M)` evals a method of `instrument` for
the owner of `M` into the extension, `disable_cache_timers!(M)` deletes it (deleting an
overwritten method would revive the old one, so enabling deletes before defining).
## Implementation constraints

Hot-path invariants, checked by the tests: a cache hit is fully inferred and allocates nothing.
Benchmark with `julia --project=benchmark -t 8 benchmark/containers.jl`.
- `@cached` preserves the method signature and moves the body to `Cached.implementation`.
- The default global cache shares one size limit across a function's cached methods, with a separate cache per container type.
- Preserve inferred return types and allocation-free RAM hits for concrete keys with the built-in containers.
- Built-in RAM containers distinguish key types and use `hash` and `isequal`.
- `Cached.cachekey` may merge equivalent inputs; merged calls must accept the same result and return type.
- Compute misses outside cache locks so recursion works; concurrent misses may compute twice.
- Keep the call path safe for precompiled packages, without runtime method definitions.
- Disk lookup order is RAM, read-only artifact, local database, then computation.
- Dashboard rendering uses copied statistics; database counts and sizes are read in the background.
83 changes: 43 additions & 40 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,67 +6,70 @@
[![Code Style: Runic](https://img.shields.io/badge/code_style-%F0%9F%AA%A8_Runic-9558B2)](https://github.com/fredrikekre/Runic.jl)
[![Aqua](https://raw.githubusercontent.com/JuliaTesting/Aqua.jl/master/badge.svg)](https://github.com/JuliaTesting/Aqua.jl)

Transparent, strategy-aware memoization for Julia functions.
Memoization for Julia, with bounded memory, persistent results, and a live view of your caches.

> [!WARNING]
> This package is under active development and its API may still change.
> The design is described in [`research/design.md`](research/design.md).
- **Disk-backed caching:** reuse expensive results across runs with SQLite.jl, or ship precomputed results as artifacts.
- **Live dashboard:** watch hit rates and sizes, browse RAM and disk caches, and clear or resize RAM caches from your terminal.
- **Fast RAM hits:** preserve return-type inference and avoid allocations with the built-in caches and concrete keys.
- **Flexible strategies:** share results across tasks, keep them local to a task, or skip caching for selected argument types.
- **Custom keys:** reuse results for equivalent inputs with `Cached.cachekey`, or customize hashing and equality with `Hashed`.
- **Bounded storage:** Clock and LRU eviction, with limits in entries or bytes across a function's methods.
- **Package controls:** set defaults per package or function, inspect caches programmatically, and profile lookups and computations with TimerOutputs.jl.

```julia
using Cached

@cached function combine(a, b; normalize = true)
# expensive computation
@cached function fib(n::Int)::BigInt
n < 2 ? BigInt(n) : fib(n - 1) + fib(n - 2)
end

combine(1, 2) # computed
combine(1, 2) # looked up in combine's cache; the result is still inferred
uncached(combine, 1, 2) # bypasses the cache

# choose the strategy per function and argument type
Cached.CacheStyle(::typeof(combine), a::Int, b::Int) = TaskLocalCache{LRU}()

cache_info(combine) # hit/miss statistics
cache_info(MyPackage) # ... of all cached functions of a module, empty_caches! likewise
set_cache_size!(combine, 1_000)
fib(100) # compute and cache
fib(100) # reuse the result
cache_info(fib) # size and hit/miss statistics
set_cache_size!(fib, 1000) # bound the cache
```

Default sizes can be configured per package and per function through `LocalPreferences.toml`; see the [configuration docs](https://lkdvos.github.io/Cached.jl/dev/configuration/).
## Keep results across runs

Specialize `Cached.cachekey` to share results between equivalent inputs while the function
body receives the original arguments:
Load SQLite.jl and opt a function into disk caching:

```julia
Cached.cachekey(::typeof(combine), a, b; normalize = true) =
(Hashed(a, customhash, customequal), b, normalize)
using SQLite
Cached.DiskCacheStyle(::typeof(fib), ::Int) = DiskCache()
fib(200) # new results are kept in RAM and on disk
```

The hook may also return a canonical key, allowing calls to different methods or argument
types to share an entry. Merged calls must accept the same cached result and return type.
`Hashed` customizes RAM equality; disk entries are matched by serialized key bytes.
See the [custom cache keys guide](https://lkdvos.github.io/Cached.jl/dev/keys/) for examples.
Calls look in RAM, then on disk, before computing.
Processes on the same machine share the database.
You control result versions and disk cleanup; see [disk caching](https://lkdvos.github.io/Cached.jl/dev/disk/).

With [SQLite.jl](https://github.com/JuliaDatabases/SQLite.jl) loaded, results can also be kept on disk, below the RAM cache, in one database per function and node:
## See what your caches are doing

Load Tachikoma.jl to browse caches while your program runs:

```julia
using SQLite
Cached.DiskCacheStyle(::typeof(combine), a, b) = DiskCache()
disk_cache_info(combine) # its database on this node: path, entries, size
using Tachikoma
cache_dashboard()
```

See the [disk caching docs](https://lkdvos.github.io/Cached.jl/dev/disk/).

With [TimerOutputs.jl](https://github.com/KristofferC/TimerOutputs.jl) loaded, `enable_cache_timers!(MyPackage, to)` times the lookups and computations of the cached functions owned by `MyPackage` and its submodules, and `disable_cache_timers!(MyPackage)` brings back the zero-cost default. See the [timing docs](https://lkdvos.github.io/Cached.jl/dev/timing/).

With [Tachikoma.jl](https://github.com/kahliburke/Tachikoma.jl) loaded, `cache_dashboard()` opens a live terminal dashboard to browse, empty and resize the caches, with a second tab for the disk caches; see the [dashboard docs](https://lkdvos.github.io/Cached.jl/dev/dashboard/).
The dashboard shows recent hit rates, storage use, and activity, with separate RAM and Disk tabs:

```text
[RAM] │ Disk
4 caches · 325 entries · 175KiB in byte-measured caches · recent hit rate 70.3%
Name ▲ Hit rate Size Kind Activity
▌Main.fib █████60%░░░░░ ▏░░░░░░░3/10k░░░░░░░░░ LRU+disk ███████
Main.label ████100%█████ ▏░░░░░░░6/10k░░░░░░░░░ Clock ▇█ ▁▃▄▅▇
Name Hit rate Size Kind Activity
Main.fib █████60%░░░░░ ▏░░░░░░░3/10k░░░░░░░░░ Clock+disk ███████
Main.matrix ████100%█████ ███▊175KiB/1.0MiB░░░░░ Clock ▆▇█▁▂▃▄▄
Main.weights █████67%▊░░░░ ▋░░░░░░276/10k░░░░░░░░ Clock ▄█▆▃▇▅▂▆
⇥ tab ↑↓ select ⏎ resize e empty s sort r reverse / filter g refresh q quit
⇥ tab ↑↓ select ⏎ resize e empty s sort / filter q quit
```

See the [dashboard guide](https://lkdvos.github.io/Cached.jl/dev/dashboard/) for controls and statistics.

## Why Cached?

Choose Cached when memoization needs ongoing management: memory limits, reuse across runs, or visibility into a running workload.
It brings these tools together with strategies chosen by function and argument type.
For a single in-memory cache, Memoize.jl or Memoization.jl with an LRU container may already cover your needs.
See [the comparison and tradeoffs](https://lkdvos.github.io/Cached.jl/dev/#why-cached).

Requires Julia 1.10 or later.
Start with [usage](https://lkdvos.github.io/Cached.jl/dev/#usage), [configuration](https://lkdvos.github.io/Cached.jl/dev/configuration/), or [timing](https://lkdvos.github.io/Cached.jl/dev/timing/) in the [documentation](https://lkdvos.github.io/Cached.jl/dev/).
34 changes: 34 additions & 0 deletions docs/examples/package.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# A private package and preferences environment for executable documentation examples.
# Loading without precompilation lets it reuse the documentation process's dependencies.
let env = mktempdir(), uid = Base.UUID(rand(UInt128))
src = joinpath(env, "MyPackage", "src")
mkpath(src)
write(
joinpath(dirname(src), "Project.toml"),
"name = \"MyPackage\"\nuuid = \"$uid\"\n[deps]\nCached = \"$(Base.PkgId(Cached).uuid)\"\n",
)
write(
joinpath(src, "MyPackage.jl"),
"__precompile__(false)\nmodule MyPackage\nusing Cached\n@cached expensive(x) = x^2\nend\n",
)
pushfirst!(LOAD_PATH, env)
package = try
Base.require(Base.PkgId(uid, "MyPackage"))
finally
popfirst!(LOAD_PATH)
end
write(
joinpath(env, "Project.toml"),
"[deps]\nCached = \"$(Base.PkgId(Cached).uuid)\"\nMyPackage = \"$uid\"\n",
)
with_preferences = function (f)
original = Base.ACTIVE_PROJECT[]
Base.ACTIVE_PROJECT[] = joinpath(env, "Project.toml")
try
return Base.invokelatest(cd, f, env)
finally
Base.ACTIVE_PROJECT[] = original
end
end
(; package, with_preferences)
end
16 changes: 14 additions & 2 deletions docs/make.jl
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,20 @@ makedocs(;
assets = String[],
),
pages = [
"Home" => "index.md", "Custom cache keys" => "keys.md", "Configuration" => "configuration.md", "Dashboard" => "dashboard.md",
"Timing" => "timing.md", "Disk caching" => "disk.md", "Implementing a cache" => "interface.md",
"Home" => "index.md",
"Caches" => [
"Configuration" => "configuration.md",
"Eviction policies" => "eviction.md",
"Disk caching" => "disk.md",
],
"Monitoring" => [
"Dashboard" => "dashboard.md",
"Timing" => "timing.md",
],
"Customization" => [
"Custom cache keys" => "keys.md",
"Implementing a cache" => "interface.md",
],
"Reference" => "reference.md",
],
)
Expand Down
Loading
Loading