diff --git a/.agents/skills/pr-cycle/SKILL.md b/.agents/skills/pr-cycle/SKILL.md index a47ed70d..59b63901 100644 --- a/.agents/skills/pr-cycle/SKILL.md +++ b/.agents/skills/pr-cycle/SKILL.md @@ -64,8 +64,8 @@ judgment core and push everything else to cheaper models or to no model at all. | Tier | What | Steps | Model | |------|------|-------|-------| | 0 — mechanical | All GitHub API ops, `make ci`, README regen, push preamble | 1, 5, 6, 8(preamble), 9, 10 | script (`pr.py`) | -| 1 — cheap delegation | Local review via `pr-review` (read-only sub-agents); mechanical/repetitive fix application | 2, 4b, 11 | Sonnet (pinned in agent def; review sub-agents overridable per-run, e.g. to opus — see [Input](#input)) | -| 2 — judgment core | Classify findings; write explicit fix specs + test assertions; sync audit | 3, 4a, 7 | Opus (session model) | +| 1 — cheap delegation | Local review via `pr-review` (read-only sub-agents); fix application, fanned out across disjoint sub-agents | 2, 4b, 11 | Sonnet by default; per-group `model` override to Opus for harder groups (see 4b); review sub-agents overridable per-run (see [Input](#input)) | +| 2 — judgment core | Classify findings; write explicit fix specs + test assertions; partition the fan-out; sync audit | 3, 4a, 7 | Opus (session model) | A Sonnet session can drive the whole cycle; only Tier-2 actually needs strong reasoning, so consider switching the session to a cheaper model once the judgment @@ -107,8 +107,9 @@ review-agent model override from the Input if one was given. `pr-review` will: - acquire the diff (`.agents/skills/pr-cycle/pr.py PR_NUMBER diff`, equivalent to `git diff origin/master`), -- spawn `pr-code-reviewer` and `pr-consumer-reviewer` in parallel (read-only, each - carrying its own rubric; Sonnet by default, or the overridden model on **both** +- shard the changed material into appropriately sized, randomized chunks and spawn one + `pr-code-reviewer` and one `pr-consumer-reviewer` per shard in parallel (read-only, + each carrying its own rubric; Sonnet by default, or the overridden model on **all** spawns), and - return a consolidated findings report with severity and a per-finding verdict. @@ -157,19 +158,46 @@ Common fix types: - Trybuild golden file regeneration: `TRYBUILD=overwrite cargo test --no-default-features --features "proc_macro,time_stores" compile_fail_macro_arg_validation` - Macro code changes: `cached_proc_macro/src/` -#### 4b. Apply fixes (route per fix) - -For each spec, choose the routing: - -- **Delegate to `pr-fix-implementer` (Sonnet)** when the fix is **mechanical or - repetitive across multiple files** (e.g. the same change in all six sharded stores, - a doc-string pattern replicated across store modules). State "delegating because: - ". Spawn the `pr-fix-implementer` agent with the fix spec as the prompt. -- **Apply inline** when the fix is **a one-off, subtle, or logic/macro change**. - For small one-off edits the spec-writing + verification round-trip costs more than - editing directly. State "applying inline because: ". - -After all fixes are applied, verify with: +#### 4b. Partition the fixes and fan out across disjoint sub-agents + +Once every valid finding has a spec, apply them by **fanning out across as many +parallel sub-agents as the specs allow**, rather than applying them serially in the +orchestrator. Two rules govern the fan-out: + +**Disjoint partitioning (correctness).** Parallel agents share one working tree, so two +agents must never write the same file — concurrent edits to one file race and corrupt +each other. Partition the specs into groups whose **written-file sets do not overlap**: + +- For each spec, compute the full set of files it writes — the Target file(s) *and* the + test file its Test clause adds to (often `tests/cached.rs`). +- Any two specs that share a written file MUST land in the same group. A common sink + like `tests/cached.rs` therefore pulls every test-adding spec into one group — that is + expected; keep that group together rather than risking a race. +- Otherwise split into as many groups as possible — ideally one spec per group — to + maximize parallelism. More disjoint groups means more concurrency. + +**Appropriate model per group (cost).** Each group is handled by a `pr-fix-implementer` +agent spawned with the Agent tool's `model` parameter set to the tier the group's +*hardest* fix needs: + +- `model: sonnet` (the agent's default) — mechanical or repetitive groups: doc/comment + updates, a pattern replicated across the sharded stores, golden-file regen, simple + test additions. +- `model: opus` — groups containing a subtle logic change, a macro change in + `cached_proc_macro/src/`, or any fix whose application still needs real reasoning. The + spec from 4a is already precise enough to hand off (it must be, to be valid); raising + the implementer's model buys more careful application, not more decision latitude. + +Spawn all groups **in a single message** (multiple Agent calls) so they run concurrently. +Each agent's prompt is the verbatim fix spec(s) for its group. Before spawning, state the +partition: list each group, the files it owns, its model, and why that model. + +**Inline fallback.** Skip the fan-out and edit directly only in the degenerate case where +it cannot pay off: a single spec, or a few tiny one-off edits that all touch one +overlapping region (so they cannot be partitioned anyway). State "applying inline because: +". + +After all agents report back, verify with: ```bash .agents/skills/pr-cycle/pr.py PR_NUMBER ci diff --git a/.agents/skills/pr-review/SKILL.md b/.agents/skills/pr-review/SKILL.md index 8e7ce59b..0dd8d041 100644 --- a/.agents/skills/pr-review/SKILL.md +++ b/.agents/skills/pr-review/SKILL.md @@ -1,6 +1,6 @@ --- name: pr-review -description: Targeted, read-only review of a PR or checked-out branch. Acquires the diff (a PR number, or the current branch vs origin/master), spawns an independent code-review sub-agent and a library-consumer sub-agent in parallel, then aggregates their findings into a single report with severity and a valid / already-fixed / invalid verdict for each. Read-only — it does not edit files, commit, push, or touch the GitHub PR conversation. The review sub-agents default to Sonnet but can be overridden per run (e.g. to opus). Use when asked to "review this PR", "review the branch", "what's wrong with this diff", "do a code review", or "review with opus". For the full review → fix → push → resolve loop, use `pr-cycle` (which delegates its review step here). +description: Targeted, read-only review of a PR or checked-out branch. Acquires the diff (a PR number, or the current branch vs origin/master), shards the changed material into appropriately sized, randomized chunks, and spawns multiple read-only code-review and library-consumer sub-agents in parallel (one per shard), then aggregates and de-duplicates their findings into a single report with severity and a valid / already-fixed / invalid verdict for each. Read-only — it does not edit files, commit, push, or touch the GitHub PR conversation. The review sub-agents default to Sonnet but can be overridden per run (e.g. to opus). Use when asked to "review this PR", "review the branch", "what's wrong with this diff", "do a code review", or "review with opus". For the full review → fix → push → resolve loop, use `pr-cycle` (which delegates its review step here). allowed-tools: Bash, Read, Agent --- @@ -13,8 +13,9 @@ findings, then goes on to address, push, and resolve them. ## Scope — what this does and does not do -**Does:** acquire the diff, spawn the two read-only review sub-agents, evaluate -their findings, and report them with severity and a verdict. +**Does:** acquire the diff, shard it into appropriately sized chunks, spawn the +read-only review sub-agents (one per shard, multiple of each type), evaluate and +de-duplicate their findings, and report them with severity and a verdict. **Does NOT:** edit files, run `make ci`, regenerate the README, commit, or push; and it does **not** interact with the GitHub PR conversation — it does not read existing @@ -29,8 +30,8 @@ This skill is purely advisory: its output is a findings report for a human (or f | Tier | What | Step | Model | |------|------|------|-------| -| 1 — cheap delegation | Read-only review sub-agents | 2 | Sonnet (pinned in agent def; overridable per-run, e.g. to opus — see [Input](#input)) | -| 2 — judgment core | Classify findings into valid / already-fixed / invalid | 3, 4 | session model (use Opus for the verdict pass) | +| 1 — cheap delegation | Read-only review sub-agents, one per shard | 3 | Sonnet (pinned in agent def; overridable per-run, e.g. to opus — see [Input](#input)) | +| 2 — judgment core | Shard the material; de-duplicate and classify findings into valid / already-fixed / invalid | 2, 4, 5 | session model (use Opus) | ## Input @@ -41,63 +42,116 @@ A target and an optional review-agent model override, in any order. `gh pr view --json number` (run with the sandbox disabled — see below), but a PR is **not required**: a plain checked-out branch is reviewed by diffing against `origin/master`. -- **Review-agent model**: the model used by the two sub-agents (`pr-code-reviewer`, +- **Review-agent model**: the model used by the two reviewer types (`pr-code-reviewer`, `pr-consumer-reviewer`) **defaults to `sonnet`**, but can be overridden. If the input names a model (e.g. "review with opus", "opus reviewers", "model=opus"), pass that - model to the Agent tool's `model` parameter when spawning **both** sub-agents in - step 2. With no override, omit `model` so each agent uses its pinned Sonnet default. + model to the Agent tool's `model` parameter when spawning **all** shard sub-agents in + step 3. With no override, omit `model` so each agent uses its pinned Sonnet default. +- **Shard sizing (optional)**: by default the orchestrator sizes shards automatically + from the review-agent model — smaller shards for cheaper models, larger for stronger + ones (see step 2). Override with an explicit target in the input if you want finer or + coarser splitting, e.g. "shards of ~4 files", "one file per shard", or "single shard" + (the latter restores the old whole-diff-per-reviewer behavior). Announce the resolved target and review-agent model at the start — e.g. "Reviewing the current branch with **opus** reviewers" or "Reviewing PR #264 with Sonnet -reviewers" — before spawning anything. +reviewers" — before spawning anything. After sharding (step 2), announce the shard +counts (e.g. "3 code shards, 2 consumer shards") before spawning the reviewers. ## Steps -### 1. Acquire the diff +### 1. Acquire the diff and build the review inventory The diff is `git diff origin/master`, which works for any checked-out branch whether or not it has a PR: ```bash git diff origin/master +git diff origin/master --stat ``` If you are targeting a specific PR, the `pr-cycle` helper prints the identical diff -and is equivalent: +and is equivalent (`.agents/skills/pr-cycle/pr.py PR_NUMBER diff`). -```bash -.agents/skills/pr-cycle/pr.py PR_NUMBER diff -``` +From the changed-file list, build an inventory of **review units**. A unit is normally +one changed file, with one exception: keep **atomic couplings** together as a single +unit — a trybuild `tests/ui/.rs` and its matching `.stderr` (and any paired +source) must travel together, since reviewing one without the other is meaningless. + +Tag each unit with the reviewer type(s) it needs: +- **Code-review set** — all code: `cached_proc_macro/src/`, `src/`, `tests/`, examples. + Essentially every changed `.rs` file and golden file. +- **Consumer-review set** — public-facing surface only: `src/lib.rs`, the public APIs in + `src/stores/`, `cached_proc_macro/src/lib.rs` (the macro attribute surface), + `README.md`, `CHANGELOG.md`, `docs/migrations/`, and `examples/`. Internal macro + plumbing and internal test helpers are not consumer-relevant. + +A unit may belong to both sets (e.g. `src/lib.rs`). + +### 2. Shard each set into appropriately sized, randomized chunks -Capture the full diff text — it is fed verbatim to both sub-agents. +The code set and the consumer set are sharded **independently**. Sharding has two jobs: +keep each shard small enough that the review model attends to every line, and vary the +grouping between rounds so repeated reviews surface different findings. -### 2. Spawn two independent sub-agents in parallel +**a. Pick the target shard size from the review-agent model.** Cheaper models get +smaller shards; stronger models absorb more per shard without losing attention: -**Agent A — code reviewer**: Spawn with the `pr-code-reviewer` agent type. Prompt must -include: -- The PR number (or branch name, if there is no PR) -- The full diff (from step 1) +| Review model | Target per shard | +|--------------|------------------| +| sonnet (default) | ~600-900 changed diff lines, or ~4-6 units | +| opus | ~1500-2500 changed diff lines, or ~10-15 units | -**Agent B — library consumer**: Spawn with the `pr-consumer-reviewer` agent type. Prompt -must include: -- The PR number (or branch name) -- The full diff -- The current `src/lib.rs` doc comments and `README.md` (or relevant excerpts covering - the changed APIs) +An explicit shard-size override from the Input wins over this table. Use the +`--stat` line counts from step 1 for packing. + +**b. Randomize the grouping, then pack.** Produce a fresh random ordering of the units +each run — `shuf` reseeds from the OS on every invocation, so each round yields a +different permutation: + +```bash +git diff origin/master --name-only | shuf +``` -Both agents are read-only (no Edit/Write tools) and carry their full rubrics in their +Pack the shuffled unit list greedily: add units to the current shard until adding the +next would exceed the target size, then start a new shard. Because the order is +reshuffled every round, a given file lands with different neighbors each time — reviewers +see different cross-file context and surface different cross-cutting findings. Do **not** +re-sort the shuffled list into a tidy order; the randomness is the point. (Atomic +couplings from step 1 stay intact as one unit through the shuffle.) + +This yields some number of code shards and consumer shards (each typically a handful). +Announce the counts before spawning. + +### 3. Spawn one sub-agent per shard, in parallel + +For each **code shard**, spawn a `pr-code-reviewer`. For each **consumer shard**, spawn a +`pr-consumer-reviewer`. Every agent's prompt must include: +- The target (PR number, or branch name if there is no PR) +- The explicit list of files in its shard +- An instruction to **scope its review to those files**: acquire its slice with + `git diff origin/master -- ` and Read those files in full for context, but + report findings only on the assigned files. +- (consumer shards only) a pointer to the current `src/lib.rs` doc comments and + `README.md` for the APIs its files touch. + +Both agent types are read-only (no Edit/Write) and carry their full rubrics in their agent definitions — do not re-specify the rubric in the prompt. **Model override:** if the input requested a review-agent model (see [Input](#input)), -pass it to the Agent tool's `model` parameter on **both** spawns (e.g. `model: "opus"`). +pass it to the Agent tool's `model` parameter on **every** spawn (e.g. `model: "opus"`). With no override, omit `model` so each agent uses its pinned Sonnet default. -Launch both agents in parallel. Wait for both to complete before proceeding. +Spawn **all** shard agents in a single message so they run concurrently, and wait for all +to complete before proceeding. (Harness concurrency is capped; excess agents queue and +still complete.) -### 3. Evaluate all findings +### 4. Evaluate all findings (de-duplicate across shards) -Collect both sub-agent reports. For each finding, assign a verdict and explain your -reasoning: +Collect every shard's report. Shards are disjoint, so most findings are unique, but a +cross-cutting issue can be reported by more than one shard (or by both a code and a +consumer reviewer) — **merge duplicates into one finding** before judging. For each +finding, assign a verdict and explain your reasoning: - **Valid** — the concern is real and the code should change. - **Already fixed** — the concern was valid in principle but the current code already @@ -110,13 +164,15 @@ This verdict pass is the judgment core; run it on the session model (use Opus). soften or pad — an invalid finding called valid sends `pr-cycle` (or a human) chasing a non-issue. -### 4. Report +### 5. Report Present a single consolidated report: - The target reviewed (PR number or branch name) and the review-agent model used. -- **Code-reviewer findings**: total count, broken down by severity (high / medium / low), - and by verdict (valid / already-fixed / invalid). +- **Sharding**: how many code shards and consumer shards ran, and the target shard size + used. +- **Code-reviewer findings**: total count (after de-dup), broken down by severity + (high / medium / low), and by verdict (valid / already-fixed / invalid). - **Consumer-reviewer findings**: the same breakdown. - For each **valid** finding: a one-line summary, the `file:line` (or area), and why it matters — enough that `pr-cycle` or a human can act on it without re-reading the agent diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 41e9a569..db72c93f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@1.96.0 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a9dcb120..2337d418 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,12 +10,24 @@ jobs: environment: release # Optional: for enhanced security permissions: id-token: write # Required for OIDC token exchange + contents: write # Required for pushing tags and creating GitHub releases steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 # Full history needed so --generate-notes can compute the diff range from the previous tag - uses: rust-lang/crates-io-auth-action@v1 id: auth - name: Publish to crates.io + id: publish run: bash bin/publish.sh env: CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }} + - name: Tag and create GitHub releases + # Tags every publishable workspace crate that lacks a tag or release yet, + # including backfilling crates published in earlier runs; idempotent + # (skips tags and releases that already exist on the remote). + if: steps.publish.outcome == 'success' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bash bin/tag-release.sh diff --git a/.gitignore b/.gitignore index 54e2f394..25658cbd 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ _tmp_readme.md local/ !local/.gitkeep .antigravitycli/ +.claude/worktrees/ diff --git a/AGENTS.md b/AGENTS.md index 2e3eaef9..3e2873bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,10 +54,10 @@ Write any scratch files, research dumps, or intermediate agent outputs to `local | `TtlSortedCache` | `cached::stores` | TTL-ordered, optional size limit; requires `time_stores` | | `ExpiringLruCache` | `cached::stores` | LRU, size-bounded, per-value expiry via `Expires` trait | | `ExpiringCache` | `cached::stores` | Unbounded HashMap-backed, per-value expiry via `Expires` trait; default store for `#[cached(expires = true)]` | -| `ShardedCache` | `cached::stores` | Fully concurrent, sharded `Arc`-backed unbounded cache; default for `#[concurrent_cached]` (no extra attrs) | +| `ShardedUnboundCache` | `cached::stores` | Fully concurrent, sharded `Arc`-backed unbounded cache; default for `#[concurrent_cached]` (no extra attrs) | | `ShardedLruCache` | `cached::stores` | Fully concurrent, sharded LRU; default for `#[concurrent_cached(max_size = N)]` | -| `ShardedTtlCache` | `cached::stores` | Fully concurrent, sharded TTL cache; default for `#[concurrent_cached(ttl = T)]`; requires `time_stores` | -| `ShardedLruTtlCache` | `cached::stores` | Fully concurrent, sharded LRU + TTL; default for `#[concurrent_cached(max_size = N, ttl = T)]`; requires `time_stores` | +| `ShardedTtlCache` | `cached::stores` | Fully concurrent, sharded TTL cache; default for `#[concurrent_cached(ttl_secs = N)]` (also selected by `ttl_millis` and `ttl = ""`); requires `time_stores` | +| `ShardedLruTtlCache` | `cached::stores` | Fully concurrent, sharded LRU + TTL; default for `#[concurrent_cached(max_size = N, ttl_secs = N)]` (also selected by `ttl_millis` and `ttl = ""`); requires `time_stores` | | `ShardedExpiringCache` | `cached::stores` | Fully concurrent, sharded per-value expiry (unbounded); default for `#[concurrent_cached(expires = true)]` | | `ShardedExpiringLruCache` | `cached::stores` | Fully concurrent, sharded LRU + per-value expiry; default for `#[concurrent_cached(expires = true, max_size = N)]` | @@ -77,7 +77,7 @@ Write any scratch files, research dumps, or intermediate agent outputs to `local **Builder APIs**: All stores are constructed exclusively through a `::builder()` constructor (e.g., `LruCache::builder()`, `TtlCache::builder()`). `build()` returns `Result` (fallible) — the direct constructors (`new`, `with_*`) and the `try_build()` alias were removed in 2.0. The size-bound setter is `.max_size(n)` (renamed from `.size(n)` in 2.0). Builders support an `on_evict(|k, v| { ... })` callback fired on every evicted entry. -**`TimedEntry`**: Exposed from `TtlCache::store()` and `LruTtlCache::store()` for direct introspection; fields `instant: Instant` and `value: V`. +**`TimedEntry`**: Now `pub(crate)` -- no longer exposed in the public API. The `store()` accessors on `TtlCache` and `LruTtlCache` were removed in a prior batch; `TimedEntry` followed as `pub(crate)` once there was no public surface that returned it. Use the `Cached` trait API for inspection. --- @@ -93,11 +93,13 @@ Write any scratch files, research dumps, or intermediate agent outputs to `local | `CloneCached` | `cache_get_with_expiry_status` for timed caches returning owned values | | `CacheEvict` | `evict() -> usize` to sweep expired entries; fires `on_evict` | | `Expires` | Implemented by values in `ExpiringLruCache`; provides `is_expired()` | -| `ConcurrentCached` | Self-synchronizing cache with a shared `&self` API (Redis, Disk) | -| `ConcurrentCachedAsync` | Async self-synchronizing cache | -| `CacheTtl` | `ttl()` / `set_ttl()` / `unset_ttl()` on timed stores | +| `ConcurrentCacheBase` | Shared base of both concurrent traits: owns `type Error` + `cache_size`/`len`/`is_empty` | +| `ConcurrentCached` | Self-synchronizing cache with a shared `&self` API (Redis, Disk); supertrait `ConcurrentCacheBase` | +| `ConcurrentCachedAsync` | Async self-synchronizing cache; supertrait `ConcurrentCacheBase` | +| `ConcurrentCacheTtl` | `&self` `ttl()`/`set_ttl()`/`unset_ttl()`/`try_set_ttl()`/`refresh_on_hit()` on concurrent TTL stores | +| `CacheTtl` | `ttl()` / `set_ttl()` / `unset_ttl()` on single-owner timed stores | -**`CacheMetrics`**: Snapshot struct returned by `cache.metrics()` on any `Cached` store. Fields: `hits`, `misses`, `evictions` (all `Option`), `size: usize`, `capacity: Option`. Has a `hit_ratio() -> Option` method. +**`CacheMetrics`**: Snapshot struct returned by `cache.metrics()` on any `Cached` store. Fields: `hits`, `misses`, `evictions` (all `Option`), `entry_count: usize`, `capacity: Option`. Has a `hit_ratio() -> Option` method. --- @@ -112,18 +114,22 @@ use cached::macros::concurrent_cached; **Renamed from pre-1.0**: was `cached::proc_macro`. The Cargo feature flag is still named `proc_macro`. -The macro attributes use `ttl =` (not `time =`) and `refresh =` (not `time_refresh =`). Note: `#[once]` supports `ttl =` but has never had a `refresh =` attribute (single-value cache, refresh-on-hit is not applicable). +The macro attributes use `ttl_secs =` (whole seconds), `ttl_millis =` (milliseconds), or `ttl = ""` (not `time =`); and `refresh =` (not `time_refresh =`). Note: `#[once]` supports `ttl_secs`/`ttl_millis`/`ttl` but has never had a `refresh =` attribute (single-value cache, refresh-on-hit is not applicable). -**2.0 attribute changes**: `result` and `option` were **removed** — `Result`/`Option` returns now skip caching `Err`/`None` by default; opt back in with `cache_err = true` / `cache_none = true`. The `size = N` attribute is a **deprecated alias** for the preferred `max_size = N` (emits a deprecation warning). +**2.0 attribute changes**: `result` and `option` were **removed** — `Result`/`Option` returns now skip caching `Err`/`None` by default; opt back in with `cache_err = true` / `cache_none = true`. The `size = N` attribute is a **hard rename error** — the macro rejects it with "`size` was renamed to `max_size`; use `max_size = ...`" and does not compile. Use `max_size = N`. -**Additional `#[cached]` / `#[once]` attributes** (beyond `name`, `max_size`, `ttl`, `refresh`, `ty`, `create`, `key`, `convert`, `cache_err`, `cache_none`, `with_cached_flag`): -- `sync_writes`: `false` (default), `true` / `"default"` (whole-cache lock), or `"by_key"` (per-key bucketed locks; `#[cached]` only) +**Additional `#[cached]` / `#[concurrent_cached]` attributes** (beyond `name`, `max_size`, `ttl_secs`, `ttl_millis`, `ttl`, `refresh`, `ty`, `create`, `key`, `convert`, `cache_err`, `cache_none`, `with_cached_flag`), and **`#[once]`** (beyond `name`, `ttl_secs`, `ttl_millis`, `ttl`, `cache_err`, `cache_none`, `with_cached_flag`): +- `sync_writes`: `"by_key"` (default for `#[cached]`; per-key bucketed locks), `false` (no synchronization; old default), `true` / `"default"` (whole-cache lock). `#[once]` defaults to `false`; `#[concurrent_cached]` does not support `sync_writes`. `result_fallback` with no explicit `sync_writes` implicitly uses `Disabled`. - `sync_writes_buckets`: `usize` — number of per-key lock buckets for `sync_writes = "by_key"`; defaults to 64 - `sync_lock`: `"rwlock"` (default) or `"mutex"` — the lock type wrapping the generated cache static - `unsync_reads`: `bool` — use a shared read lock for cache hits; only works for stores implementing `CachedRead` (e.g. `UnboundCache`, `TtlSortedCache`, `HashMap`) - `result_fallback`: `bool` — on `Err`, return the last cached `Ok` value instead; requires a `Result` return type +- `force_refresh`: unquoted block `{ }` or quoted string `"{ }"` over the function args -- when true, bypasses the cache and recomputes unconditionally. `force_refresh = true` (bare bool) is also accepted. +- `in_impl`: `bool` — generates a `_no_cache` sibling and a function-local cache static; suppresses the `_prime_cache` companion (the cache static is function-local and cannot be shared with a sibling) +- `companions_vis`: string — visibility of the generated `{fn}_no_cache` and `{fn}_prime_cache` companions (e.g. `"pub(crate)"`, `""` for private); defaults to the cached function's own visibility +- `map_error` (disk/redis `#[concurrent_cached]` only): unquoted closure `|e| MyErr(e)` or quoted string `"|e| MyErr(e)"` — converts the store error into the function's error type. Optional: when omitted, `.map_err(Into::into)?` is generated, requiring `E: From`. -**`_prime_cache` helpers**: Every macro-generated function `foo(…)` also emits `foo_prime_cache(…)` for manually refreshing cached entries (bypasses the cache and forces re-execution). `#[once]` functions emit `foo_prime_cache()` with no arguments. +**`_prime_cache` helpers**: Every macro-generated function `foo(…)` also emits `foo_prime_cache(…)` for manually refreshing cached entries (bypasses the cache and forces re-execution), except `in_impl` methods, for which the `_prime_cache` companion is not generated (the cache static is function-local). `#[once]` functions emit `foo_prime_cache()` with no arguments. **Generics**: generic functions with `where` clauses are supported. The macros clone the original `syn::Signature` (preserving the `where` clause, lifetimes, const generics) for the generated origin/`inner` helper — quoting `#generics` alone would drop the `where` clause. Because `#[cached]`/`#[concurrent_cached]` store the cache in a `static`, a generic parameter that would land in the derived key/value type must be pinned via `key` + `convert` (and `ty`); `#[once]`'s static only holds the concrete value type, so it is unconstrained. @@ -186,10 +192,11 @@ This runs: `make check` (fmt + clippy + readme) -> `make tests` -> `make example --- ## README Sync -`README.md` is auto-generated from `src/lib.rs` doc comments — **never edit README.md directly**. +`README.md` is generated from `src/lib.rs` doc comments by `cargo-readme` — **never edit `README.md` directly**. Any change to the README (wording, tables, examples) must be made in the `src/lib.rs` doc comments and then regenerated; a hand-edit to `README.md` is overwritten on the next regeneration and will fail `make check/readme`. ```bash -make docs # regenerate -make check/readme # verify in sync +cargo install cargo-readme # one-time, if not already installed +make docs # regenerate README.md from src/lib.rs via cargo-readme (cargo readme) +make check/readme # verify README.md matches the generated output ``` --- @@ -237,15 +244,17 @@ Invoke these via `/skill-name` in Claude Code or by name in agent prompts: | `ahash` (default) | ahash hasher for internal hash maps | | `time_stores` (default) | `TtlCache`, `LruTtlCache`, `TtlSortedCache` | | `async_core` | Async support marker (no runtime); use with custom async runtimes | -| `async` | Async support via Tokio (enables `async_core` + `tokio`) | -| `async_tokio_rt_multi_thread` | Tokio multi-thread runtime (required for `#[tokio::test]`) | +| `async` | Async support (runtime-agnostic; pulls `async-lock` and `blocking`, no tokio) | | `redis_store` | Synchronous Redis backend | -| `redis_tokio` | Async Redis backend (Tokio) | -| `redis_smol` | Async Redis backend (smol); implies `redis_store` + `async` | -| `redis_connection_manager` | Redis connection-manager support | -| `redis_async_cache` | Redis client-side caching over RESP3 for async caches | -| `disk_store` | Disk-backed cache via `redb` | -| `wasm` | WASM compatibility | +| `redis_tokio` | Async Redis backend (Tokio, no TLS); implies `redis_store` + `async` | +| `redis_tokio_native_tls` | `redis_tokio` + TLS via `native-tls` | +| `redis_tokio_rustls` | `redis_tokio` + TLS via `rustls` | +| `redis_smol` | Async Redis backend (smol, no TLS); implies `redis_store` + `async` | +| `redis_smol_native_tls` | `redis_smol` + TLS via `native-tls` | +| `redis_smol_rustls` | `redis_smol` + TLS via `rustls` | +| `redis_connection_manager` | Redis connection-manager support (no TLS; add `redis_tokio_native_tls` or `redis_tokio_rustls` for TLS) | +| `redis_async_cache` | Redis client-side caching over RESP3 for async caches (TLS-agnostic; add `redis_tokio_native_tls` or `redis_tokio_rustls` for TLS) | +| `redb_store` | Disk-backed cache via `redb` | --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 569c6004..d5a5a20e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ ### Breaking Changes +#### Redis TLS features split ([#231](https://github.com/jaemk/cached/issues/231)) +- `redis_tokio` no longer implies native-tls. It now enables the TLS-agnostic `redis/tokio-comp` + connection path. Add `redis_tokio_native_tls` (system TLS) or `redis_tokio_rustls` (pure-Rust + TLS) alongside to restore TLS. +- `redis_smol` no longer implies native-tls. Add `redis_smol_native_tls` or `redis_smol_rustls` + alongside if TLS is required. +- `redis_async_cache` is now also TLS-agnostic: it pulls `redis_tokio` + `redis/cache-aio`. + Add `redis_tokio_native_tls` or `redis_tokio_rustls` alongside if TLS is required. +- **Migration:** if you were relying on `redis_tokio`, `redis_smol`, or `redis_async_cache` + for TLS connectivity, add the appropriate TLS backend feature (`redis_tokio_native_tls` / + `redis_tokio_rustls` for Tokio; `redis_smol_native_tls` / `redis_smol_rustls` for smol) + to your `Cargo.toml` features list. + #### Minimum supported Rust version - MSRV raised from 1.85 to 1.89 (required by `redb` 4.x). @@ -12,31 +25,162 @@ - Renamed `DiskCache` → `RedbCache` (names the backend explicitly, like `RedisCache`); `DiskCache`, `DiskCacheBuilder`, `DiskCacheError`, and `DiskCacheBuildError` remain as type aliases, so existing code keeps compiling. - `DiskCache` is now backed by [`redb`](https://crates.io/crates/redb) 4.x instead of the unmaintained `sled`, dropping the RustSec-flagged `fxhash` transitive dependency. Still pure-Rust (no C toolchain). - On-disk format changed: existing caches are not read (entries are recomputed); `DISK_FILE_VERSION` was bumped. -- `RedbCacheError::Storage` and `RedbCacheBuildError::Connection` now wrap `redb::Error` instead of `sled::Error`; `RedbCacheBuildError` gained an `Io` variant and dropped the never-constructed `MissingDiskPath` variant. +- `RedbCacheError::Storage` and `RedbCacheBuildError::Storage` now wrap `redb::Error` instead of `sled::Error`; `RedbCacheBuildError` gained an `Io` variant and dropped the never-constructed `MissingDiskPath` variant. - Removed `DiskCache::connection()` / `connection_mut()`, `DiskCacheBuilder::connection_config`, and the `connection_config` macro attribute. The backend handle is no longer exposed. - `durable` maps to redb durability and defaults to `true` (durable, fsync per write), so a disk cache persists by default. Set `false` to trade durability for write throughput: writes then use `Durability::None`, which is not persisted until a later durable commit, so they can be lost on process exit or crash; call `RedbCache::flush()` / `async_flush()` to force one. +#### `new()` constructor consistency for stores +- In-memory stores (`UnboundCache`, `LruCache`, `TtlCache`, `LruTtlCache`, `TtlSortedCache`, `ExpiringCache`, `ExpiringLruCache`, and all six sharded variants) gained `Type::new()` / `Type::new(required_field)` constructors that return a ready-to-use cache. `builder()` is still available for non-default configuration. +- `RedbCache::new` (and its `DiskCache` alias), `RedisCache::new`, and `AsyncRedisCache::new` are removed. These previously returned a *builder*, conflicting with the convention that `new()` returns a ready store. Replace `::new(` with `::builder(` on these three types; the rest of the builder chain is unchanged. + +#### Macro `ttl` attribute replaced by `ttl_secs`, `ttl_millis`, and Duration expression +- `ttl = ` (bare whole-second integer) is removed from `#[cached]` / `#[once]` / `#[concurrent_cached]`. The macro now accepts three mutually exclusive forms: `ttl_secs = N` (whole seconds, replaces the old integer form), `ttl_millis = N` (milliseconds, new in this release), or `ttl = ""` (a string-literal Duration expression, e.g. `ttl = "Duration::from_secs(60)"`). Using the old bare-integer form produces an error directing you to `ttl_secs`. +- Builders gained `.ttl_secs(n)` and `.ttl_millis(n)` convenience methods alongside the existing `.ttl(Duration)`. All three target the same underlying field; the last call wins. Builder-level calls do not enforce mutual exclusion. + +#### Short method aliases moved to `CachedExt` / `ConcurrentCachedExt` +- The short method aliases (`get`, `set`, `remove`, `remove_entry`, `clear`, `len`, `is_empty`, `delete`, `try_set`, `contains`, `hits`, `misses`, `metrics`, and the short `get_or_set_with` family) moved off `Cached` / `ConcurrentCached` onto blanket extension traits `CachedExt` / `ConcurrentCachedExt`, implemented for every `Cached` / `ConcurrentCached` type. The core traits keep only the `cache_`-prefixed methods, so a custom store implements a smaller surface. Both extension traits are re-exported from the crate root and the prelude. **Migration:** callers using `cached::prelude::*` need no change; others add `use cached::CachedExt;` / `use cached::ConcurrentCachedExt;` where they call a short alias, or use the `cache_`-prefixed form. Custom `impl Cached` / `impl ConcurrentCached` blocks must drop any short-alias methods (now provided by the blanket impl). + #### Macro attribute changes - Removed the deprecated `size` attribute from `#[cached]` / `#[concurrent_cached]` (deprecated since 2.0). Use `max_size = N`; the macros still detect `size` and emit a compile error directing you to `max_size`. #### Trait API changes -- `ConcurrentCachedAsync` cache operations are renamed with an `async_` prefix (`async_cache_get`, `async_cache_set`, `async_cache_remove`, `async_cache_remove_entry`, `async_cache_delete`), removing the `E0034` "multiple applicable items" error when both concurrent traits are imported. The sync config methods (`ttl`/`set_ttl`/`unset_ttl`/`set_refresh_on_hit`) are unchanged. -- `CacheTtl` and `CacheEvict` are now single-owner (`&mut self`) traits only, since `&mut self` was unusable on stores held through `Arc`/`static`. `CacheTtl` was removed from `DiskCache`, `RedisCache`, `AsyncRedisCache`, `ShardedTtlCache`, and `ShardedLruTtlCache`; `CacheEvict` from the four TTL/expiring sharded stores. Set TTL on concurrent stores via `ConcurrentCached::set_ttl` / `ConcurrentCachedAsync::set_ttl` (`&self`), and evict via the new `ConcurrentCacheEvict` trait (`fn evict(&self) -> usize`, implemented by `ShardedTtlCache`, `ShardedLruTtlCache`, `ShardedExpiringCache`, `ShardedExpiringLruCache`). Single-owner in-memory stores are unchanged. +- `ConcurrentCachedAsync` cache operations are renamed with an `async_` prefix (`async_cache_get`, `async_cache_set`, `async_cache_remove`, `async_cache_remove_entry`, `async_cache_delete`), removing the `E0034` "multiple applicable items" error when both concurrent traits are imported. +- Split the concurrent cache trait surface to eliminate the remaining `E0034` "multiple applicable items in scope" error. `ConcurrentCached` and `ConcurrentCachedAsync` previously each declared identical synchronous helpers (`cache_size`, `len`, `is_empty`, `ttl`, `set_ttl`, `unset_ttl`, `refresh_on_hit`, `set_refresh_on_hit`); on a store implementing both traits (`RedbCache`, every `Sharded*` store) calling one of those with both traits in scope failed to compile. Those helpers now live on two new shared traits: introspection (`type Error`, `cache_size`, `len`, `is_empty`) on `ConcurrentCacheBase` (the supertrait of both concurrent traits, mirroring the single-owner `Cached` core), and the global-TTL controls (`ttl`, `set_ttl`, `unset_ttl`, `refresh_on_hit`, `set_refresh_on_hit`, plus a new validated `try_set_ttl` that rejects a zero `Duration` with `SetTtlError::ZeroTtl`) on `ConcurrentCacheTtl`. Only the TTL-capable concurrent stores (`ShardedTtlCache`, `ShardedLruTtlCache`, `RedisCache`, `AsyncRedisCache`, `RedbCache`) implement `ConcurrentCacheTtl`; the non-TTL sharded stores no longer expose `set_ttl`/`ttl`/etc. Both new traits are re-exported from the crate root and the prelude. **Migration:** custom `impl ConcurrentCached`/`ConcurrentCachedAsync` blocks must move their `type Error` (and any `cache_size`/`len`/`is_empty` override) into a single `impl ConcurrentCacheBase for X` block, and any TTL behavior into `impl ConcurrentCacheTtl for X`. Callers using `cached::prelude::*` need no change (both traits are imported); callers importing the concurrent traits individually should add `ConcurrentCacheBase` / `ConcurrentCacheTtl` where they call those helpers. +- `CacheTtl` and `CacheEvict` are now single-owner (`&mut self`) traits only, since `&mut self` was unusable on stores held through `Arc`/`static`. `CacheTtl` was removed from `DiskCache`, `RedisCache`, `AsyncRedisCache`, `ShardedTtlCache`, and `ShardedLruTtlCache`; `CacheEvict` from the four TTL/expiring sharded stores. Set TTL on concurrent stores via `ConcurrentCacheTtl::set_ttl` (`&self`), and evict via the new `ConcurrentCacheEvict` trait (`fn evict(&self) -> usize`, implemented by `ShardedTtlCache`, `ShardedLruTtlCache`, `ShardedExpiringCache`, `ShardedExpiringLruCache`). Single-owner in-memory stores are unchanged. +- `Cached::cache_get_or_set_with` / `cache_try_get_or_set_with` (and their `get_or_set_with` / `try_get_or_set_with` aliases) and `CachedAsync::async_get_or_set_with` / `async_try_get_or_set_with` now return `&V` / `Result<&V, E>` instead of `&mut V` / `Result<&mut V, E>` ([#179](https://github.com/jaemk/cached/issues/179)). New `*_mut` variants (`cache_get_or_set_with_mut`, `cache_try_get_or_set_with_mut`, `get_or_set_with_mut`, `try_get_or_set_with_mut`, `async_get_or_set_with_mut`, `async_try_get_or_set_with_mut`) preserve the mutable-reference behavior. External `impl`s of these traits must update their method signatures and implement the new `*_mut` required methods. +- `refresh_on_hit` and `set_refresh_on_hit` are now **required** methods on `CacheTtl` and `ConcurrentCacheTtl` (the trait-default bodies that returned `false` were removed). This fixes a latent bug: the concurrent stores (`ShardedTtlCache`, `ShardedLruTtlCache`, `RedisCache`, `AsyncRedisCache`, `RedbCache`) overrode only `set_refresh_on_hit`, so `ConcurrentCacheTtl::refresh_on_hit` always reported `false` through trait dispatch even after `set_refresh_on_hit(true)`; it now correctly reflects the configured flag. **Migration:** custom `impl CacheTtl`/`impl ConcurrentCacheTtl` blocks must now provide both methods explicitly (a non-refreshing store can return `false` and treat the setter as a no-op). #### Other breaking changes -- Error enum variants dropped their redundant `Error` suffix: `RedbCacheError::{StorageError, CacheDeserializationError, CacheSerializationError}` became `{Storage, CacheDeserialization, CacheSerialization}`; `RedbCacheBuildError::ConnectionError` became `Connection`; `RedisCacheError::{RedisCacheError, PoolError, CacheDeserializationError, CacheSerializationError}` became `{Redis, Pool, CacheDeserialization, CacheSerialization}`. +- Error enum variants dropped their redundant `Error` suffix: `RedbCacheError::{StorageError, CacheDeserializationError, CacheSerializationError}` became `{Storage, CacheDeserialization, CacheSerialization}`; `RedbCacheBuildError::ConnectionError` became `Storage` (names the backend, matching `RedbCacheError::Storage`); `RedisCacheError::{RedisCacheError, PoolError, CacheDeserializationError, CacheSerializationError}` became `{Redis, Pool, CacheDeserialization, CacheSerialization}`. - The public store error enums (`RedbCacheError`, `RedbCacheBuildError`, `RedisCacheError`, `RedisCacheBuildError`, `BuildError`, and the `TtlSortedCache` error) are now `#[non_exhaustive]`, so external matches must include a wildcard arm. - `RedbCache::remove_expired_entries` now returns `Result` (the number of entries removed) instead of `Result<(), RedbCacheError>`, matching `CacheEvict::evict` / `ConcurrentCacheEvict::evict`. - `CacheMetrics.size` renamed to `entry_count` (the only field not matching its `cache_*` accessor). - Builder refresh naming unified on `refresh_on_hit`: the `refresh()` alias was removed from the in-memory TTL builders, and `DiskCacheBuilder` / `RedisCacheBuilder` / `AsyncRedisCacheBuilder` `refresh` was renamed to `refresh_on_hit`. The `#[cached(refresh = true)]` attribute is unchanged. +- `cache_reset` (and `ConcurrentCached::cache_reset` / `ConcurrentCachedAsync::async_cache_reset`) no longer preserves the preallocated backing capacity. It now calls `clear()` + `shrink_to(initial_capacity)`, which the allocator may satisfy with a smaller allocation, so subsequent inserts up to the initial capacity may reallocate. To retain the allocation, recreate the cache instead of resetting it. + +#### Redis and disk store changes +- Redis values are now serialized with MessagePack (`rmp-serde`) instead of JSON; the `redis_store` feature pulls `rmp-serde` instead of `serde_json`. Old (2.x) JSON-format entries are read transparently: the store tries MessagePack first, then falls back to `serde_json` for entries that carry a `version` key in the JSON object, and serves the value without recompute. New writes use MessagePack; old entries are rewritten as MessagePack on their next write. `RedisCacheError`'s serialize/deserialize variants carry `rmp_serde::encode::Error` / `rmp_serde::decode::Error` instead of `serde_json::Error`. +- Redis TTL now uses the millisecond commands `PSETEX` / `PEXPIRE`; sub-second TTLs are honored to the millisecond instead of rounded up to the next whole second. Whole-second TTLs are unchanged. Requires Redis 2.6+. +- `RedisCache::connection_string()` / `AsyncRedisCache::connection_string()` now return a `ConnectionString` newtype whose `Display` and `Debug` both redact credentials. Call `.reveal()` on the returned value to get the raw URL string. +- `RedbCacheError` and `RedbCacheBuildError` are now struct variants (named fields) matching the redis enums; `RedbCacheBuildError::Connection` is renamed `Storage`. The serialize/deserialize variants on both backends carry MessagePack error types, and `CacheDeserialization` gains a `cached_value: Vec` field holding the bytes that failed to decode. **Migration:** tuple patterns like `CacheSerialization(e)` become `CacheSerialization { source }`. + +#### Sharded store and error-type renames +- `ShardedCache` renamed to `ShardedUnboundCache` (along with `ShardedCacheBase` -> `ShardedUnboundCacheBase` and `ShardedCacheBuilder` -> `ShardedUnboundCacheBuilder`). The old name read as the umbrella for the whole sharded family while it only named the unbounded variant; the new name is parallel with `ShardedLruCache`, `ShardedTtlCache`, and the rest. No deprecated alias - rename at the call site. +- `ttl_sorted`'s dedicated error type is removed; `TtlSortedCache` now uses the shared `CacheSetError` (variant `TimeBounds`), the same type as `TtlCache` / `LruTtlCache`, so all three TTL stores report one error type. The previous `TtlSortedCacheError` name (and the `ttl_sorted::Error` it aliased) no longer exists; rename it to `CacheSetError`. The unused `From for std::io::Error` impl is removed; the store never surfaced errors through `io::Error`. + +#### Required trait methods (custom `ConcurrentCached` / `CloneCached` impls) +- `cache_clear` and `cache_reset` are now required on `ConcurrentCached` (and `async_cache_clear` / `async_cache_reset` on `ConcurrentCachedAsync`). Their previous no-op `Ok(())` defaults silently did nothing; every built-in store already overrides both. A custom impl must now provide them. `cache_reset_metrics` keeps its no-op default. +- `cache_peek_with_expiry_status` is now required on `CloneCached` and `ConcurrentCloneCached`. The old provided defaults returned a wrong result (`(None, false)` / a side-effecting delegate) that silently broke `force_refresh` + `result_fallback` for custom stores. Every built-in store already overrides it; a custom expiry-capable store must provide a genuinely side-effect-free read. + +#### Macro attribute and store-method removals +- The `unbound` attribute is removed from `#[cached]`. The default store (no `max_size`, `ttl`, or `expires`) is already an `UnboundCache`, so `#[cached(unbound)]` built an identical store to a bare `#[cached]`. The attribute is intercepted with a migration error; drop it. +- `#[concurrent_cached]`'s `refresh` attribute is now a plain `bool` (was `Option`), matching `#[cached]`. `refresh = false` is the default and no longer conflicts with `expires` or a `create` block - only `refresh = true` does. No change needed unless you relied on `refresh = false` erroring next to `expires`/`create`. +- The inherent `refresh_on_hit(&self) -> bool` and `set_refresh_on_hit(&mut self, bool)` methods on `TtlCache` and `LruTtlCache` are removed; they shadowed the `CacheTtl` trait methods, and the inherent setter returned `()` instead of the previous value. Bring `CacheTtl` into scope to call them (the trait setter returns the previous `bool`). The builder `refresh_on_hit(self, bool) -> Self` is unchanged. + +#### Feature and toolchain +- The `wasm` cargo feature is removed. It gated nothing - `web-time` provides wasm-compatible time types transparently with no opt-in feature. Drop it from your feature list; wasm targets need nothing extra. +- The `disk_store` cargo feature is renamed to `redb_store`, naming the backend (`redb`) explicitly, parallel to the `redis_*` features. No backwards-compatible alias; rename it in your `Cargo.toml`. +- The `redis_ahash` cargo feature is removed. It enabled the `redis` crate's optional `ahash` feature and gated no `cached` code; enable `ahash` on your own `redis` dependency if needed. +- `cached_proc_macro_types` moved to edition 2024 and raised its `rust-version` to 1.89, matching the workspace (its semver version is unchanged at `1.0`). `cached_proc_macro`'s `rust-version` is likewise raised to 1.89. + +#### API audit follow-ups +- `Cached::cache_try_set` (and its `try_set` alias) now return `Result, CacheSetError>` instead of `Result, Box>`. `CacheSetError` is a new `#[non_exhaustive]` enum (variant `TimeBounds`) re-exported from the crate root, so callers can match on the failure instead of handling an opaque boxed error. Custom `Cached` impls that override `cache_try_set` must update the return type. +- The `DiskCache` / `DiskCacheBuilder` / `DiskCacheError` / `DiskCacheBuildError` aliases for the `Redb*` types are removed (the rename to `RedbCache` happened earlier in this release; the aliases are not carried forward). Rename `DiskCache*` to `RedbCache*`. +- The `store()` accessors on `UnboundCache`, `TtlCache`, `LruTtlCache`, and `ExpiringLruCache` are removed. They exposed the internal backing map (and leaked the internal `TimedEntry` wrapper) and existed on only some stores. Use the public `Cached` API (`cache_get`, `cache_size`, iteration helpers) instead. +- `ShardHasher` now requires `Clone` as a supertrait (the `deep_clone` / `copy_from` methods already required it de facto). Custom `ShardHasher` impls must be `Clone`; `DefaultShardHasher` already is. +- `#[must_use]` was added to the pure-query trait methods (`cache_size`/`len`/`is_empty`/`metrics`/`hits`/`misses`/`ttl`/`refresh_on_hit`/...) and to `cache_remove`/`cache_remove_entry`. Code that discards these results under `-D warnings` will need `let _ = ...`. The short `remove`/`remove_entry` aliases are intentionally left un-annotated for for-effect removal. +- The `Expires` trait gained a default method `expires_at(&self) -> Option` returning the value's expiry instant when tracked (`None` by default / when unknown). It is advisory/observability only; `is_expired()` remains the authoritative liveness check. Existing `impl Expires` blocks (which provide only `is_expired`) get the default for free. + +#### Store builder API uniformity (C1) +- `RedbCache::builder(name)`, `RedisCache::builder(prefix, ttl)`, and `AsyncRedisCache::builder(prefix, ttl)` now take no arguments: `::builder()`. Required fields (`name`, `prefix`, `ttl`) are set via dedicated setters and validated in `build()`, which returns `BuildError::MissingRequired(field_name)` if a required field is absent. All store builders now share a uniform no-arg `::builder()` entry point. + +#### `CachedAsync` method renames (I1) +- The four `async_get_or_set_with*` methods on the `CachedAsync` trait are renamed with the `cache_` namespace infix, matching the `Cached` trait convention: `async_get_or_set_with` → `async_cache_get_or_set_with`, `async_get_or_set_with_mut` → `async_cache_get_or_set_with_mut`, `async_try_get_or_set_with` → `async_cache_try_get_or_set_with`, `async_try_get_or_set_with_mut` → `async_cache_try_get_or_set_with_mut`. The four shorthand methods are likewise renamed: `get_async` → `async_cache_get`, `set_async` → `async_cache_set`, `remove_async` → `async_cache_remove`, `clear_async` → `async_cache_clear`. Every `CachedAsync` method now uses the `async_cache_*` namespace. The `ConcurrentCachedAsync` trait is unchanged. + +#### Error vocabulary for TTL validation (I4+I5) +- `BuildError::InvalidTtl { ttl }` is removed. A zero TTL at build time now yields `BuildError::InvalidValue { field: "ttl", reason: "must be greater than zero" }`, which is more general (the variant can represent other field-validation failures) and more descriptive. +- `RedisCacheBuildError::InvalidTtl` and `RedbCacheBuildError::InvalidTtl` are renamed to `RedisCacheBuildError::Build(BuildError)` and `RedbCacheBuildError::Build(BuildError)` respectively, wrapping the inner `BuildError` instead of duplicating its content. Exhaustive matches on these enums must be updated. + +#### `set_ttl(Duration::ZERO)` now disables expiry for future inserts only (I2) +- A zero `Duration` passed to any `set_ttl` surface now means "expiry disabled" -- exactly equivalent to `unset_ttl()`, with future-inserted entries never expiring. It no longer panics (sharded stores) and no longer means "expire immediately". This is uniform across the sharded `ShardedTtlCache` / `ShardedLruTtlCache`, the single-owner `TtlCache` / `LruTtlCache`, and `RedisCache` / `AsyncRedisCache`. For the Redis stores a disabled TTL writes keys WITHOUT any expiry (a plain `SET` instead of `SETEX`), and the refresh-on-hit path issues no `EXPIRE`. `build()` still rejects a zero TTL, and `CacheTtl::try_set_ttl(0)` still returns `SetTtlError::ZeroTtl` -- those are the strict "give me a real ttl" paths; to disable expiry, call `set_ttl(0)` or `unset_ttl()`. (`TtlSortedCache` now matches the other TTL stores: a zero TTL disables expiry for future inserts, where it previously meant immediate expiry. Its per-entry expiry is now `Option` (`None` = never expires), ordered so never-expiring entries are evicted last under a size cap.) Because TTL stores now track per-entry expiry, `set_ttl` affects future inserts only; existing entries keep their computed expiry. + +#### `#[cached(refresh = true)]` without a TTL is now a compile error (I7) +- Using `refresh = true` on `#[cached]` without also specifying a TTL (`ttl_secs`, `ttl_millis`, or `ttl`) is now a compile error. Previously the attribute was silently ignored in this configuration. This matches the existing behavior of `#[concurrent_cached]`, which has always required a TTL alongside `refresh = true`. + +#### `sync_writes` default on `#[cached]` changed to `"by_key"` +- A bare `#[cached]` now uses `sync_writes = "by_key"`: concurrent first calls for the same key are deduplicated through bucketed per-key locks. Previously the default was no synchronization, mirroring Python's `functools.lru_cache`. Opt out with `sync_writes = false`. `result_fallback` with no explicit `sync_writes` implicitly uses `Disabled` (not `"by_key"`). `#[once]` and `#[concurrent_cached]` defaults are unchanged. + +#### `Cached` trait: `type Error` associated type; `cache_try_set` / `try_set` return `Result, Self::Error>` +- `Cached` gained `type Error`. Built-in infallible stores (`UnboundCache`, `LruCache`, sharded stores, `ExpiringCache`, `ExpiringLruCache`) set `type Error = std::convert::Infallible`. `TtlCache` / `LruTtlCache` / `TtlSortedCache` set `type Error = CacheSetError`. Custom `impl Cached` blocks must add the associated type; call sites that bound the error as `CacheSetError` for an infallible store must update to `Infallible` or drop the annotation. + +#### Sharded stores: inherent `get`/`set`/`remove`/`remove_entry`/`delete`/`reset` return unwrapped values +- The six concrete sharded types now expose inherent methods returning `Option`, `()`, and `bool` directly, so `store.get(&k)` is `Option` rather than `Result, Infallible>`. To use the `Result`-returning trait methods, call through `ConcurrentCached` or use the `cache_`-prefixed names. + +#### `TimedEntry` is no longer public +- `cached::TimedEntry` is now `pub(crate)`. Any `use cached::TimedEntry;` import fails. The type was only reachable after the `store()` accessors were removed. + +#### Runtime decoupling: `async_tokio_rt_multi_thread` removed; `async` no longer pulls tokio; `async_sync` re-exports changed +- `async_tokio_rt_multi_thread` cargo feature removed. Users who need `tokio/rt-multi-thread` (e.g. for `#[tokio::test]`) must add `tokio` with `rt-multi-thread` directly to their own dev-dependencies. +- The `async` feature no longer implies `tokio`. It now pulls only `async-lock` and `blocking` (runtime-agnostic). smol/async-std async users no longer compile tokio. +- `cached::async_sync::{Mutex, RwLock, OnceCell}` now re-export from `async-lock` instead of `tokio::sync`. `OnceCell` from `async-lock` has no `const_new()`; replace with `OnceCell::new()`. +- Async `RedbCache` runs blocking redb work on the `blocking` crate's thread pool instead of `tokio::spawn_blocking`, making it runtime-agnostic. `RedbCacheError::BackgroundTaskFailed` variant removed. + +#### Macro attributes `convert`, `create`, `force_refresh`, `map_error`, `cache_prefix_block` accept unquoted Rust +- These attributes now accept unquoted Rust (e.g. `convert = { format!("{a}") }`, `map_error = |e| MyErr(e)`, `force_refresh = { id == 0 }`). The quoted-string form still works. `force_refresh = true` (a bare bool) is now also valid. `ty` and `key` remain quoted strings. + +#### `map_error` optional on disk/Redis `#[concurrent_cached]` +- When omitted, the generated code uses `.map_err(Into::into)?`. The function's error type must implement `From` (disk) or `From` (Redis). Supplying `map_error` still works and requires no change. + +#### `companions_vis` macro attribute +- `#[cached]`, `#[once]`, and `#[concurrent_cached]` accept `companions_vis = ""` to set the visibility of the generated `{fn}_no_cache` and `{fn}_prime_cache` companions independently of the cached function's own visibility. Defaults to the cached function's visibility (no change for existing code). ### Additive / non-breaking - `cached::prelude` re-exports the common traits for a single glob import. -- `ConcurrentCached` gained `cache_clear` / `cache_reset` / `cache_reset_metrics` (`&self`, default no-op). The sharded stores override all three; `DiskCache` overrides `cache_clear` / `cache_reset` but keeps the no-op `cache_reset_metrics` (it tracks no in-memory metrics). `ConcurrentCachedAsync` gained the async counterparts. +- Custom hasher on the non-sharded in-memory stores: `UnboundCache`, `LruCache`, `TtlCache`, `LruTtlCache`, `TtlSortedCache`, `ExpiringCache`, and `ExpiringLruCache` gained a hasher type parameter defaulted to `DefaultHashBuilder` (e.g. `UnboundCache`) and a `.hasher(s)` builder method, mirroring the sharded stores. `DefaultHashBuilder` (ahash under the `ahash` feature, else std `RandomState`) is re-exported from the crate root. Naming a store as `UnboundCache` is unchanged. +- Concurrent metrics through a trait: `ConcurrentCacheBase` gained `cache_hits` / `cache_misses` / `cache_capacity` / `cache_evictions` and a default `metrics() -> CacheMetrics`, so a `ConcurrentCached` / `ConcurrentCachedAsync` bound can read a sharded store's metrics generically (the inherent `metrics()` is retained), mirroring the accessors on `Cached`. +- `#[cached]` / `#[once]` now reject the concurrent-store-only attributes `disk`, `redis`, and `map_error` with a clear error pointing to `#[concurrent_cached]`, instead of a generic unknown-field message. +- The `len` / `cache_size` / `iter` / `evict` contract on lazy-eviction stores is documented in one place: `len` / `cache_size` returns the stored count without an expiry scan (may include expired entries), `iter` omits expired entries from the view without removing them, and `evict()` reclaims expired entries and yields an accurate live count. Behavior unchanged. +- `ConcurrentCached` / `ConcurrentCachedAsync` gained a no-op-default `cache_reset_metrics` / `async_cache_reset_metrics` (`&self`). The sharded stores override it to zero their per-shard counters; `RedbCache` and `RedisCache` / `AsyncRedisCache` keep the no-op default (they track no in-memory metrics). `cache_clear` / `cache_reset` (and their async counterparts) are required methods, not defaults - see the breaking-changes entry above. +- `CacheTtl::try_set_ttl` - the strict "give me a real ttl" variant of `set_ttl` that returns the new `SetTtlError` (variant `ZeroTtl`) when passed a zero TTL instead of interpreting it as "disable expiry". Use it when a zero TTL is a caller error rather than a request to disable expiry (which `set_ttl(0)` / `unset_ttl()` do). Provided default, so existing `CacheTtl` impls get it for free. +- `ConcurrentCached` / `ConcurrentCachedAsync` gained ergonomic `len` / `is_empty` aliases over `cache_size`, mirroring the sync `Cached` trait. +- `Debug` is implemented for `RedisCache`, `AsyncRedisCache`, and `RedbCache` (redacted: prints only namespace/prefix/path/ttl/refresh, never connection strings or credentials). +- `PartialEq` / `Eq` are implemented for `ExpiringCache` and `ExpiringLruCache` (equal when their stored entries are equal). +- `#[must_use]` parity across the sharded builders, and the `with_hasher` doc alias is spread to every sharded builder's `hasher` method for discoverability. +- Malformed `key` / `convert` macro attributes now produce a contextual error explaining what the attribute expects, with an example, instead of a bare `syn` "unexpected token". +- `redis_connection_manager` now builds on the `redis_tokio` feature instead of re-listing redis sub-features (resolved feature set unchanged). +- `ConcurrentCached` / `ConcurrentCachedAsync` gained a defaulted `cache_get_or_set_with` / `async_cache_get_or_set_with` (with a `get_or_set_with` alias), mirroring the single-owner traits. The default is a get-then-set (non-atomic; a concurrent miss may run the factory more than once). +- `ConcurrentCached` / `ConcurrentCachedAsync` gained a defaulted `refresh_on_hit()` getter, and `set_refresh_on_hit` is now defaulted (`{ false }`) so custom impls no longer need to write it. +- `RedisCache` and `AsyncRedisCache` now implement `Clone` (Arc-backed pool / cloneable connection). `RedbCache` stays non-`Clone`. +- The `name` macro attribute is validated as a Rust identifier: an invalid `name` now produces a spanned "`name` must be a valid Rust identifier" error instead of a macro panic. +- `#[once]` and `#[concurrent_cached]` now reject the `#[cached]`-only sync attributes (`sync_lock`, `unsync_reads`, and `sync_writes_buckets` on `#[concurrent_cached]`) with a clear "not supported on this macro" message instead of a generic unknown-field error. +- `RedisCacheBuildError::MissingConnectionString` and the redis (de)serialization errors now expose their wrapped cause via `Error::source()` and render it through `Display` (cleaner than the previous debug formatting). +- `ConcurrentCacheEvict::evict` is now `#[must_use]`. - `RedbCache::flush` and `RedbCache::async_flush` force a durable (fsync) commit, so you can run with `durable(false)` for cheap writes and flush at chosen points (periodically or before shutdown) to persist them. - `RedbCache::disk_path()` returns the path of the on-disk redb database file backing the cache. -- Doc fixes: corrected the "sharded stores expose inherent helpers" note, added a `Cached::get` mutability note, and documented the sharded-LRU minimum-per-shard capacity. +- New `SerializeCached` / `SerializeCachedAsync` traits with `cache_set_ref(&self, &K, &V)` / `async_cache_set_ref`, implemented by `RedisCache` / `AsyncRedisCache` / `RedbCache`, let serialize-backed stores set an entry without taking ownership of the key/value. The `#[concurrent_cached]` macro now calls the borrowed setter for any store implementing these traits (the built-in `redis`/`disk` stores and custom `ty`/`create` stores alike), avoiding an extra value clone on the set ([#196](https://github.com/jaemk/cached/issues/196), [#195](https://github.com/jaemk/cached/issues/195)). +- `RedisCache` / `AsyncRedisCache` now implement `cache_clear` / `async_cache_clear` via a namespace-scoped `SCAN` + batched `DEL` (O(n), scoped to the cache's prefix, not a server flush), and `cache_reset` / `async_cache_reset` delegate to them (redis tracks no in-memory metrics, matching `RedbCache`). Glob metacharacters (`*`, `?`, `[`, `]`, `\`) in the namespace/prefix are escaped in the `SCAN` pattern so they match literally ([#200](https://github.com/jaemk/cached/issues/200)). `RedisCacheBuilder` / `AsyncRedisCacheBuilder` `build()` now returns `RedisCacheBuildError::EmptyScope` when both the namespace (after trimming trailing `:`) and the prefix are empty, since that would make `cache_clear` run `SCAN MATCH *` and delete every key in the database. (This is technically a breaking behavior change for any caller that explicitly set the namespace to empty and left the prefix empty; the default namespace `"cached-redis-store:"` is non-empty so normal usage is unaffected. See the [migration guide](docs/migrations/2.0-to-unreleased.md#9-rediscachebuilderbuild--asyncrediscachebuilderbuild-return-emptyscope-when-namespace-and-prefix-are-both-empty) for details.) +- `LruCache::set_max_size` / `try_set_max_size` resize a live cache, eagerly evicting LRU entries when shrinking (paralleling `TtlSortedCache`'s existing `set_max_size` / `try_set_max_size`, which set the new bound but evict lazily on the next insert rather than eagerly); `LruTtlCache` and `ExpiringLruCache` gained the same two methods (delegating to their inner LRU) for parity ([#180](https://github.com/jaemk/cached/issues/180)). All four `try_set_max_size` methods now return a single dedicated `SetMaxSizeError` (variant `ZeroSize`) instead of the builder `BuildError` (LRU family) or a `std::io::Error` (`TtlSortedCache`), so the runtime-resize error is self-describing and consistent across stores. +- `RedbCacheBuilder::build()` now validates `cache_name` (used as a filename component) and returns `RedbCacheBuildError::InvalidCacheName` if it is empty, contains a path separator (`/` or `\`), or is a path-traversal component (`.` or `..`), which would otherwise silently create subdirectories, escape the cache directory, or produce a meaningless filename. +- `#[cached]` / `#[concurrent_cached]` / `#[once]` gained a `ttl_millis = N` attribute for sub-second TTLs (milliseconds); mutually exclusive with `ttl`, `ttl_secs`, and `expires`, with a compile error if any are combined ([#149](https://github.com/jaemk/cached/issues/149)). +- `#[cached]` / `#[concurrent_cached]` / `#[once]` gained a `force_refresh = "{ }"` attribute (a curly-brace expression block over the function's arguments, like `convert`) that bypasses the cached value and recomputes when the expression is true. On `#[once]` it overwrites the single shared value (there is no per-call key, so unlike `#[cached]` there is no "exclude the flag from the key" caveat). When combined with `result_fallback = true`, a force-refreshed call that returns `Err` still serves the previously cached `Ok` value (the fallback wins over the bypass), and capturing that fallback value leaves no read side effects on the bypassed entry (no TTL renewal, recency update, or hit-counter change) on both `#[cached]` and `#[concurrent_cached]` ([#146](https://github.com/jaemk/cached/issues/146)). +- `#[cached]` / `#[concurrent_cached]` / `#[once]` gained an `in_impl = true` attribute, allowing them on methods inside `impl` blocks (the generated cache static lives in the function body); `self`-receiver methods are accepted only under `in_impl` (a `convert` block alone cannot rescue them, since the cache static cannot live at `impl` scope) ([#16](https://github.com/jaemk/cached/issues/16), [#140](https://github.com/jaemk/cached/issues/140)). +- `#[cached]` / `#[concurrent_cached]` accept reference arguments (`&T`, `Option<&T>`) on the default-key path, deriving an owned key (`::Owned`) without requiring a `convert` ([#202](https://github.com/jaemk/cached/issues/202), [#203](https://github.com/jaemk/cached/issues/203)). +- The macros resolve the crate root via `proc-macro-crate`, so a renamed or re-exported `cached` dependency works ([#157](https://github.com/jaemk/cached/issues/157)). +- Macro-introduced bindings are now hygienically named (`__cached_*`), so function arguments named `key`, `cache`, or `result` no longer collide with generated code ([#230](https://github.com/jaemk/cached/issues/230), [#114](https://github.com/jaemk/cached/issues/114)). +- Applying `#[cached]` / `#[concurrent_cached]` to a generic function without a `key` + `convert` now produces a clear compile error (each monomorphization would need its own static); generics are supported when `key` + `convert` pin a concrete key type ([#80](https://github.com/jaemk/cached/issues/80)). +- The release workflow now creates a git tag and GitHub release for each workspace crate that is newly published, via `bin/tag-release.sh`. The root crate keeps the bare `vX.Y.Z` tag; subcrates are tagged `-vX.Y.Z` ([#245](https://github.com/jaemk/cached/issues/245)). +- Doc fixes: corrected the "sharded stores expose inherent helpers" note, added a `Cached::get` mutability note, documented the sharded-LRU minimum-per-shard capacity, named floats as the canonical `convert` case ([#78](https://github.com/jaemk/cached/issues/78)), and added a cache-invalidation example ([#21](https://github.com/jaemk/cached/issues/21)) and a struct-method example ([#236](https://github.com/jaemk/cached/pull/236)). - `hashbrown` updated to 0.17 (internal). Dev-only: `criterion` 0.8, `googletest` 0.14. +- `#[once]` now rejects the `#[cached]`-only attributes (`result_fallback`, `refresh`, `max_size`, `ty`, `create`, `key`, `convert`) with clear "not supported on `#[once]`" messages instead of a generic unknown-field error (I6). +- `#[must_use]` added to `CacheEvict::evict` and the single-owner inherent `evict` methods (I3). +- A non-string `force_refresh` value (e.g. `force_refresh = true` instead of the required block form `force_refresh = "{ ... }"`) now produces a helpful error message explaining the expected syntax (8b). +- TTL stores (`TtlCache`, `LruTtlCache`, `ShardedTtlCache`, `ShardedLruTtlCache`) now store per-entry expiry timestamps; `set_ttl` applies to future inserts only; `refresh_on_hit` recomputes expiry from the current TTL at access time. +- Per-entry expiry on the sharded TTL stores removes the need to re-read the global TTL on every lookup and eliminates a class of time-skew bugs where entries inserted before a `set_ttl` change could expire at unexpected times. +- `async_sync::{Mutex, RwLock, OnceCell}` now re-export from `async-lock`; async `RedbCache` uses the `blocking` crate thread pool (runtime-agnostic). Async smol/async-std users no longer pull tokio. +- Inherent `get`/`set`/`remove`/`remove_entry`/`delete`/`reset` on the six sharded types return unwrapped values directly (no `Result` wrapper). +- Macro attributes `convert`, `create`, `force_refresh`, `map_error`, `cache_prefix_block` accept unquoted Rust in addition to the existing quoted-string form. `force_refresh = true` (bare bool) is now valid. +- `map_error` is optional on `#[concurrent_cached(disk = true)]` and Redis-backed `#[concurrent_cached]`; when omitted the generated code uses `.map_err(Into::into)?`. +- `companions_vis = ""` attribute on all three macros controls the visibility of generated `{fn}_no_cache` and `{fn}_prime_cache` companions. ## [2.0.2] - Docs/tests only (no API change): document the `Expires` trait / `expires = true` as the idiomatic way to set a dynamic, per-entry TTL (a lifetime computed at call time rather than the uniform `ttl = N`), with a runnable example reference, and add a regression test for the runtime-argument-driven TTL case ([#246](https://github.com/jaemk/cached/issues/246)). @@ -89,7 +233,7 @@ #### `size` → `max_size` naming (builder setter, macro attribute, runtime setters) - Builder setter `.size(n)` → `.max_size(n)` (LRU-family stores and `TtlSortedCache`). The sharded builders' per-shard cap setter is `per_shard_max_size`. - The `#[cached]` / `#[concurrent_cached]` **macro attribute `size = N` → `max_size = N`**. The old `size = N` spelling keeps working as a **deprecated alias** that emits a deprecation warning (anchored at the `size` token). Setting both on one annotation is a compile error. See "New macro attributes" under Added below. -- **`TtlSortedCache` runtime max-size setters**: `size_limit(n)` → `set_max_size(n)` and `try_size_limit(n)` → `try_set_max_size(n)` (matching the `set_ttl` runtime-mutator convention). +- **`TtlSortedCache` runtime max-size setters**: `size_limit(n)` → `set_max_size(n)` and `try_size_limit(n)` → `try_set_max_size(n)` (matching the `set_ttl` runtime-mutator convention). The error type also changed: `try_set_max_size` now returns `Result, cached::SetMaxSizeError>` instead of `std::io::Result>`; if you propagate the error with `?` into an `io::Error` context, update the enclosing function's error type or convert explicitly. ### Added @@ -97,7 +241,7 @@ - `max_size = N` attribute for `#[cached]` and `#[concurrent_cached]`: the preferred spelling of the LRU-bound attribute, mirroring the renamed `max_size` builder setter. The original `size = N` attribute continues to work as a **deprecated alias** — using it emits a deprecation warning (anchored at the `size` token) steering you to `max_size`. Specifying both `size` and `max_size` on the same annotation is a compile error. - `cache_err = true` attribute for `#[cached]`, `#[once]`, and `#[concurrent_cached]`: opt-in to also cache `Err` values from `Result` returns (requires a `Result` return type; mutually exclusive with `result_fallback`). - `cache_none = true` attribute for `#[cached]`, `#[once]`, and `#[concurrent_cached]`: opt-in to also cache `None` values from `Option` returns (requires an `Option` return type). -- `result_fallback = true` support for `#[concurrent_cached]`: on an `Err` return, the last cached `Ok` value for the same key is returned instead. The stale value is kept in the primary cache slot (via `ConcurrentCloneCached::cache_get_with_expiry_status`) and re-cached with a fresh TTL window on `Err`; no separate fallback store is created. Requires `ttl` (a compile error is emitted otherwise). Restricted to the default in-memory sharded path (not redis/disk). Mutually exclusive with `cache_err` and `with_cached_flag`. +- `result_fallback = true` support for `#[concurrent_cached]`: on an `Err` return, the last cached `Ok` value for the same key is returned instead. The stale value is kept in the primary cache slot (via `ConcurrentCloneCached::cache_get_with_expiry_status`) and re-cached with a fresh TTL window on `Err`; no separate fallback store is created. Requires a TTL (`ttl`/`ttl_secs`/`ttl_millis`) (a compile error is emitted otherwise). Restricted to the default in-memory sharded path (not redis/disk). Mutually exclusive with `cache_err` and `with_cached_flag`. #### New sharded in-memory cache stores - Add six fully-concurrent, sharded in-memory cache stores: `ShardedCache` (unbounded), `ShardedLruCache` (LRU), `ShardedTtlCache` (TTL, requires `time_stores`), `ShardedLruTtlCache` (LRU + TTL, requires `time_stores`), `ShardedExpiringCache` (per-value expiry, unbounded), and `ShardedExpiringLruCache` (per-value expiry, LRU-bounded). All six wrap an `Arc` (cheap clone, `Send + Sync`), use power-of-two per-shard `parking_lot::RwLock`s with cache-line-padded shard structs to eliminate false sharing, and support builder APIs with `on_evict` callbacks, `copy_from` for live resharding, and `metrics()` / `shard_sizes()` for observability. Shard routing uses the `ShardHasher` trait (default: `DefaultShardHasher` backed by ahash) as a zero-overhead type parameter, allowing custom partition logic without runtime overhead. @@ -111,7 +255,7 @@ - Add `cache_clear_with_on_evict()` to all seven non-sharded stores (`UnboundCache`, `LruCache`, `TtlCache`, `LruTtlCache`, `ExpiringCache`, `ExpiringLruCache`, `TtlSortedCache`): fires the `on_evict` callback for every removed entry and (where applicable) increments the evictions counter. The plain `cache_clear()` method remains fast and side-effect-free; `cache_clear_with_on_evict()` is the opt-in alternative. - Add `StripedCounter` — a 16-slot cache-line-padded atomic counter — for hit/miss metrics on `UnboundCache` and `TtlSortedCache` to reduce false sharing under concurrent `cache_get_read`. All other stores continue to use plain `AtomicU64`. - Add `ConcurrentCloneCached` trait: concurrent analogue of `CloneCached` for the four expiry-capable sharded stores (`ShardedTtlCache`, `ShardedLruTtlCache`, `ShardedExpiringCache`, `ShardedExpiringLruCache`). Provides `cache_get_with_expiry_status(&self, key: &K) -> (Option, bool)` — returns the value without removing expired entries, enabling `result_fallback` to fall back to stale values in-place. Takes `&self` (not `&mut self`) since sharded stores are internally synchronized. -- Add API consistency aliases: `Cached::{get,set,remove,remove_entry,delete}` and `ConcurrentCached::{get,set,remove,remove_entry,delete}` delegate to the existing `cache_*` methods (the sync `Cached` trait gains `remove_entry` / `delete` to match `ConcurrentCached`); both the sharded and non-sharded TTL builders expose `.refresh_on_hit(...)` as the primary setter with `.refresh(...)` retained as an alias; `DiskCache`, `RedisCache`, and `AsyncRedisCache` expose `::builder(...)` aliases (alongside their existing `::new(...)` builder entry points). Note: `DiskCache::new(...)` / `RedisCache::new(...)` / `AsyncRedisCache::new(...)` are **builder** entry points — they return a builder, not a ready-to-use store — and are intentionally retained; only the in-memory and sharded store constructors that returned stores directly were removed. +- Add API consistency aliases: `Cached::{get,set,remove,remove_entry,delete}` and `ConcurrentCached::{get,set,remove,remove_entry,delete}` delegate to the existing `cache_*` methods (the sync `Cached` trait gains `remove_entry` / `delete` to match `ConcurrentCached`); both the sharded and non-sharded TTL builders expose `.refresh_on_hit(...)` as the primary setter with `.refresh(...)` retained as an alias; `DiskCache`, `RedisCache`, and `AsyncRedisCache` expose `::builder(...)` aliases (alongside their existing `::new(...)` builder entry points). Note: `DiskCache::new(...)` / `RedisCache::new(...)` / `AsyncRedisCache::new(...)` are **builder** entry points -- they return a builder, not a ready-to-use store -- and are intentionally retained; only the in-memory and sharded store constructors that returned stores directly were removed. - Add an inherent `capacity()` getter to `LruCache`, `LruTtlCache`, and `ExpiringLruCache` — and to their sharded counterparts `ShardedLruCache`, `ShardedLruTtlCache`, and `ShardedExpiringLruCache` — that returns the configured max-entry bound (distinct from `cache_size()`, which returns the current live entry count). - Add `BuildError::InvalidTtl { ttl }` variant for a single consistently-worded zero-TTL rejection path across all builders. - Document on `ConcurrentCachedAsync` that `get`/`set`/`remove`/`delete` short aliases are intentionally absent to avoid worsening method-resolution ambiguity. @@ -228,6 +372,11 @@ `create` were silently ignored — a real footgun (the user thought their disk path / durability was applied when it was not). Move the dropped attrs into your `create` block, or remove them. +- **Breaking:** `#[cached]` likewise rejects its store-builder attributes + (`ttl`, `ttl_millis`, `max_size`, `unbound`, `refresh`) when a `create` block + is supplied, with the same unified message, mirroring `#[concurrent_cached]`. + Previously `refresh` paired with `create` was silently ignored. Move the + dropped attrs into your `create` block, or remove them. - `CacheEvict::evict` now returns the number of expired entries removed, matching the existing `TtlSortedCache` behavior. - Fix `DiskCache::cache_get` refreshes to return serialization errors instead of panicking when diff --git a/Cargo.toml b/Cargo.toml index 38a9e8a6..e1ad3e56 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,34 +11,40 @@ keywords = ["cache", "memoize", "lru", "redis", "disk"] license = "MIT" edition = "2024" rust-version = "1.89" +# Internal dev tooling and process docs that should not ship in the published crate. +exclude = [".agents/", ".claude/", ".github/", "bin/", "docs/dev/", "AGENTS.md", "CLAUDE.md"] [package.metadata.docs.rs] all-features = true rustdoc-args = ["--cfg", "docsrs"] +[lints] +workspace = true + [features] default = ["proc_macro", "ahash", "time_stores"] proc_macro = ["cached_proc_macro", "cached_proc_macro_types"] ahash = ["dep:ahash", "hashbrown/default"] async_core = [] -async = ["async_core", "tokio"] -async_tokio_rt_multi_thread = ["async", "tokio/rt-multi-thread"] -redis_store = ["redis", "r2d2", "serde", "serde_json"] -redis_connection_manager = ["async", "redis_store", "redis/connection-manager", "redis/tokio-comp"] +async = ["async_core", "dep:async-lock", "dep:blocking"] +redis_store = ["redis", "r2d2", "serde", "rmp-serde", "serde_json"] +redis_connection_manager = ["redis_tokio", "redis/connection-manager"] redis_smol = [ "redis_store", "async", - "redis/smol-native-tls-comp", + "redis/smol-comp", ] +redis_smol_native_tls = ["redis_smol", "redis/smol-native-tls-comp"] +redis_smol_rustls = ["redis_smol", "redis/smol-rustls-comp"] redis_tokio = [ "redis_store", "async", - "redis/tokio-native-tls-comp", + "redis/tokio-comp", ] -redis_async_cache = ["redis_tokio", "redis/cache-aio"] -redis_ahash = ["redis_store", "redis/ahash"] -disk_store = ["redb", "serde", "rmp-serde", "directories"] -wasm = [] +redis_tokio_native_tls = ["redis_tokio", "redis/tokio-native-tls-comp"] +redis_tokio_rustls = ["redis_tokio", "redis/tokio-rustls-comp"] +redis_async_cache = ["redis_store", "async", "redis_tokio", "redis/cache-aio"] +redb_store = ["redb", "serde", "rmp-serde", "directories"] time_stores = [] [dependencies.cached_proc_macro] @@ -95,12 +101,15 @@ features = ["derive"] optional = true [dependencies.serde_json] -version = "1.0" +version = "1" optional = true -[dependencies.tokio] +[dependencies.async-lock] +version = "3" +optional = true + +[dependencies.blocking] version = "1" -features = ["macros", "time", "sync", "parking_lot", "rt"] optional = true [dependencies.web-time] @@ -111,6 +120,11 @@ googletest = "0.14" tempfile = "3.10.1" trybuild = "1" criterion = "0.8" +futures = "0.3" + +[dev-dependencies.tokio] +version = "1" +features = ["macros", "time", "sync", "parking_lot", "rt", "rt-multi-thread"] [dev-dependencies.async-std] version = "1.6" @@ -125,21 +139,27 @@ version = "3" [workspace] members = ["cached_proc_macro", "examples/wasm"] +# Elevate all compiler warnings to errors across the workspace (equivalent to +# `-D warnings`). Applies to `cargo build`/`test`/`clippy`, not just the Makefile +# clippy target. Members opt in with `[lints] workspace = true`. +[workspace.lints.rust] +warnings = "deny" + [[example]] name = "redis" -required-features = ["redis_store", "async_tokio_rt_multi_thread", "proc_macro"] +required-features = ["redis_store", "proc_macro"] [[example]] name = "redis-async-tokio" -required-features = ["redis_tokio", "async_tokio_rt_multi_thread", "proc_macro"] +required-features = ["redis_tokio_native_tls", "proc_macro"] [[example]] name = "redis-async-async-std" -required-features = ["redis_smol", "proc_macro"] +required-features = ["redis_smol_native_tls", "proc_macro"] [[example]] name = "tokio" -required-features = ["async_tokio_rt_multi_thread", "proc_macro"] +required-features = ["async", "proc_macro"] [[example]] name = "async_std" @@ -151,7 +171,7 @@ required-features = ["proc_macro"] [[example]] name = "expiring_sized_cache" -required-features = ["async_tokio_rt_multi_thread", "time_stores"] +required-features = ["time_stores"] [[example]] name = "basic" @@ -163,15 +183,15 @@ required-features = ["time_stores", "proc_macro"] [[example]] name = "disk" -required-features = ["disk_store", "proc_macro"] +required-features = ["redb_store", "proc_macro"] [[example]] name = "disk_async" -required-features = ["disk_store", "async_tokio_rt_multi_thread", "proc_macro"] +required-features = ["redb_store", "async", "proc_macro"] [[example]] name = "redis-client-side-cache-tokio" -required-features = ["redis_async_cache", "async_tokio_rt_multi_thread", "proc_macro"] +required-features = ["redis_async_cache", "redis_tokio_native_tls", "proc_macro"] [[example]] name = "sharded" @@ -181,6 +201,10 @@ required-features = ["time_stores", "proc_macro"] name = "sharded_expiring" required-features = ["proc_macro"] +[[example]] +name = "struct_method" +required-features = ["proc_macro"] + [[bench]] name = "cache_benches" harness = false diff --git a/Makefile b/Makefile index ec5b477e..48e77a3d 100644 --- a/Makefile +++ b/Makefile @@ -54,7 +54,7 @@ TEST_TARGETS = tests \ tests/redis-connection-manager \ tests/redis-async-cache \ tests/redis-async-cache-tokio \ - tests/redis-async-cache-smol \ + tests/redis-async-cache-rustls \ tests/redis-store \ tests/redis-store-standalone \ tests/redis-tokio \ @@ -123,13 +123,13 @@ help: ## List all supported Make targets tests/time-stores) desc="Run tests with proc_macro and time_stores" ;; \ tests/async) desc="Run async tests with proc_macro and time_stores" ;; \ tests/ahash) desc="Run tests with proc_macro and ahash (no time_stores)" ;; \ - tests/disk-store) desc="Run disk_store tests with proc_macro and async runtime" ;; \ - tests/disk-store-sync) desc="Run disk_store tests with proc_macro (no async runtime)" ;; \ + tests/disk-store) desc="Run redb_store tests with proc_macro and async runtime" ;; \ + tests/disk-store-sync) desc="Run redb_store tests with proc_macro (no async runtime)" ;; \ tests/redis) desc="Run all Redis-backed test targets" ;; \ tests/redis-connection-manager) desc="Check standalone redis_connection_manager feature compilation" ;; \ tests/redis-async-cache) desc="Check standalone redis_async_cache feature compilation" ;; \ - tests/redis-async-cache-tokio) desc="Check redis_async_cache with redis_tokio" ;; \ - tests/redis-async-cache-smol) desc="Check redis_async_cache with redis_smol" ;; \ + tests/redis-async-cache-tokio) desc="Check redis_async_cache with redis_tokio_native_tls" ;; \ + tests/redis-async-cache-rustls) desc="Check redis_async_cache with redis_tokio_rustls" ;; \ tests/redis-store) desc="Run synchronous Redis store tests" ;; \ tests/redis-store-standalone) desc="Check redis_store feature compilation without proc_macro" ;; \ tests/redis-tokio) desc="Run async Redis Tokio tests" ;; \ @@ -184,7 +184,7 @@ examples/cargo/wasm: # mixing tokio and smol runtimes that --all-features would enable together. examples/redis/redis-async-async-std: docker/redis @echo [$@]: Running example redis-async-async-std... - $(CARGO_COMMAND) run --example redis-async-async-std --features "redis_smol,proc_macro" + $(CARGO_COMMAND) run --example redis-async-async-std --features "redis_smol_native_tls,proc_macro" examples/redis/%: docker/redis @echo [$@]: Running example $*... @@ -216,25 +216,25 @@ tests/time-stores: @echo "[$@]: Running tests (time_stores + proc_macro)..." $(CARGO_COMMAND) test --no-default-features --features "proc_macro,time_stores" --tests -- --nocapture -# async + proc_macro + time_stores (tokio rt-multi-thread required for #[tokio::test]) +# async + proc_macro + time_stores (tokio in dev-deps supplies the test runtime) tests/async: - @echo "[$@]: Running tests (async_tokio_rt_multi_thread + proc_macro + time_stores)..." - $(CARGO_COMMAND) test --no-default-features --features "proc_macro,time_stores,async_tokio_rt_multi_thread" --tests -- --nocapture + @echo "[$@]: Running tests (async + proc_macro + time_stores)..." + $(CARGO_COMMAND) test --no-default-features --features "proc_macro,time_stores,async" --tests -- --nocapture # proc_macro + ahash (no time_stores) tests/ahash: @echo "[$@]: Running tests (proc_macro + ahash, no time_stores)..." $(CARGO_COMMAND) test --no-default-features --features "proc_macro,ahash" --tests -- --nocapture -# disk_store + proc_macro (+ async rt for async disk tests) +# redb_store + proc_macro (+ async for async disk tests; tokio dev-dep supplies the test runtime) tests/disk-store: - @echo "[$@]: Running tests (disk_store + proc_macro + async_tokio_rt_multi_thread)..." - $(CARGO_COMMAND) test --no-default-features --features "proc_macro,disk_store,async_tokio_rt_multi_thread" --tests -- --nocapture + @echo "[$@]: Running tests (redb_store + proc_macro + async)..." + $(CARGO_COMMAND) test --no-default-features --features "proc_macro,redb_store,async" --tests -- --nocapture -# disk_store + proc_macro (no async runtime) +# redb_store + proc_macro (no async runtime) tests/disk-store-sync: - @echo "[$@]: Running tests (disk_store + proc_macro, no async)..." - $(CARGO_COMMAND) test --no-default-features --features "proc_macro,disk_store" --tests -- --nocapture + @echo "[$@]: Running tests (redb_store + proc_macro, no async)..." + $(CARGO_COMMAND) test --no-default-features --features "proc_macro,redb_store" --tests -- --nocapture # Redis targets. The runtime targets (redis-store, redis-tokio, all-features) # each take an order-only `| docker/redis` prerequisite so the container is @@ -242,7 +242,7 @@ tests/disk-store-sync: # ordering on the aggregate below is not honored under parallel make. The # standalone `*-async-cache*`/`connection-manager` targets are compile-only # `cargo check`s and need no container. -tests/redis: tests/redis-connection-manager tests/redis-async-cache tests/redis-async-cache-tokio tests/redis-async-cache-smol tests/redis-store-standalone tests/redis-store tests/redis-tokio tests/all-features +tests/redis: tests/redis-connection-manager tests/redis-async-cache tests/redis-async-cache-tokio tests/redis-async-cache-rustls tests/redis-store-standalone tests/redis-store tests/redis-tokio tests/all-features tests/redis-store-standalone: @echo "[$@]: Checking standalone redis_store feature compilation..." @@ -257,22 +257,22 @@ tests/redis-async-cache: $(CARGO_COMMAND) check --no-default-features --features redis_async_cache tests/redis-async-cache-tokio: - @echo "[$@]: Checking redis_async_cache with redis_tokio..." - $(CARGO_COMMAND) check --no-default-features --features "redis_tokio,redis_async_cache" + @echo "[$@]: Checking redis_async_cache with redis_tokio_native_tls..." + $(CARGO_COMMAND) check --no-default-features --features "redis_tokio_native_tls,redis_async_cache" -tests/redis-async-cache-smol: - @echo "[$@]: Checking redis_async_cache with redis_smol..." - $(CARGO_COMMAND) check --no-default-features --features "redis_smol,redis_async_cache" +tests/redis-async-cache-rustls: + @echo "[$@]: Checking redis_async_cache with redis_tokio_rustls..." + $(CARGO_COMMAND) check --no-default-features --features "redis_tokio_rustls,redis_async_cache" # Synchronous Redis store only tests/redis-store: | docker/redis @echo "[$@]: Running tests (redis_store + proc_macro)..." $(CARGO_COMMAND) test --no-default-features --features "proc_macro,redis_store" --tests -- --nocapture -# Async Redis via Tokio (implies redis_store + async; rt-multi-thread for #[tokio::test]) +# Async Redis via Tokio with native-tls (tokio dev-dep supplies the test runtime) tests/redis-tokio: | docker/redis - @echo "[$@]: Running tests (redis_tokio + proc_macro + time_stores + async_tokio_rt_multi_thread)..." - $(CARGO_COMMAND) test --no-default-features --features "proc_macro,time_stores,redis_tokio,async_tokio_rt_multi_thread" --tests -- --nocapture + @echo "[$@]: Running tests (redis_tokio_native_tls + proc_macro + time_stores)..." + $(CARGO_COMMAND) test --no-default-features --features "proc_macro,time_stores,redis_tokio_native_tls" --tests -- --nocapture # Full all-features run tests/all-features: | docker/redis diff --git a/README.md b/README.md index 6e2d050e..2ba76915 100644 --- a/README.md +++ b/README.md @@ -13,16 +13,22 @@ Memoized functions defined using `#[cached]`/`#[once]` macros are thread-safe wi function-cache wrapped in a mutex/rwlock. `#[concurrent_cached]` functions are thread-safe via the store's own internal synchronization: sharded stores use per-shard `parking_lot::RwLock`; Redis and disk stores rely on their respective server/file-system concurrency. -By default, the function-cache is **not** locked for the duration of the function's execution, so initial (on an empty cache) -concurrent calls of long-running functions with the same arguments will each execute fully and each overwrite -the memoized value as they complete. This mirrors the behavior of Python's `functools.lru_cache`. To synchronize the execution and caching -of un-cached arguments, specify `#[cached(sync_writes = true)]` / `#[once(sync_writes = true)]`; for -`#[cached]`, use `sync_writes = "by_key"` to synchronize duplicate keys through bucketed per-key locks -(not supported by `#[once]` or `#[concurrent_cached]`). +By default, `#[cached]` uses `sync_writes = "by_key"`: concurrent first calls for the same key are +deduplicated through bucketed per-key locks, so the function body runs at most once per key per miss +window. To allow concurrent misses to each compute independently (the pre-3.0 default, which mirrored +Python's `functools.lru_cache`), set `sync_writes = false`. To hold the whole-cache lock for the +duration of each miss, use `sync_writes = true` (or `"default"`). `#[once]` defaults to no +synchronization (add `sync_writes = true` to serialize concurrent first-calls); `#[concurrent_cached]` +does not support `sync_writes`. For `#[cached]`, the number of per-key lock buckets for `"by_key"` is +tunable with `sync_writes_buckets = N` (default 64). - See [`cached::stores` docs](https://docs.rs/cached/latest/cached/stores/index.html) cache stores available. - See [`macros` docs](https://docs.rs/cached/latest/cached/macros/index.html) for more macro examples. +> **Upgrading from 2.x?** See the +> [migration guide](https://github.com/jaemk/cached/blob/master/docs/migrations/2.0-to-unreleased.md) +> for all breaking changes and a step-by-step walkthrough. +> > **Upgrading from 1.x?** 2.0 contains breaking changes (new `cache_remove_entry` required method, > `Result`/`Option` caching behavior flipped to smart-by-default, `result`/`option` attributes > removed, and more). See the @@ -37,28 +43,55 @@ of un-cached arguments, specify `#[cached(sync_writes = true)]` / `#[once(sync_w > [agent-oriented guide](https://github.com/jaemk/cached/blob/master/docs/migrations/0.x-to-1.0.md) > for automated migration tooling. +**Method naming** + +Every synchronous cache operation has a short alias (`get`/`set`/`remove`/`clear`/`len`/...) and a +`cache_`-prefixed form (`cache_get`/`cache_set`/`cache_remove`/`cache_clear`/`cache_size`/...). +The short aliases are the preferred spelling. Use the `cache_`-prefixed names when a short alias +would collide with another in-scope trait's method of the same name (for example, your type also +implements a trait with its own `get`). + +The `get`/`set`/`remove` short aliases for `Cached` stores live on `CachedExt`; those for +`ConcurrentCached` stores live on `ConcurrentCachedExt`. Both extension traits have blanket +implementations, so the short names are always available when the extension trait is in scope. +The simplest way to get them is `use cached::prelude::*;`, which re-exports both extension traits. +Alternatively, import them directly: `use cached::{Cached, CachedExt};`. Custom store +implementations only need to implement the `cache_`-prefixed required methods on the core trait; +the short aliases come for free via the blanket extension trait impl. + +For `Cached` stores, `len`/`is_empty` are also on `CachedExt`. For `ConcurrentCached` stores, +`len`/`is_empty` are defined on `ConcurrentCacheBase` (the shared base trait), not on +`ConcurrentCachedExt` — bring `ConcurrentCacheBase` into scope to call them on a generic bound. + +Both async traits use the `async_cache_*` spelling. `ConcurrentCachedAsync` has +`async_cache_get`, `async_cache_set`, `async_cache_remove`, ...; `CachedAsync` has +`async_cache_get`, `async_cache_set`, `async_cache_remove`, `async_cache_clear`, plus the +`async_cache_get_or_set_with` family (`async_cache_get_or_set_with`, +`async_cache_try_get_or_set_with`, and their `_mut` variants). Neither trait has a short alias; +the `async_` prefix already prevents collisions with the sync methods. + **Features** - `default`: Include `proc_macro`, `ahash`, and `time_stores` features - `proc_macro`: Include proc macros - `ahash`: Enable the optional `ahash` hasher as default hashing algorithm. - `async_core`: Include runtime-agnostic async traits used by async cache stores -- `async`: Include support for async functions and async cache stores using Tokio synchronization -- `async_tokio_rt_multi_thread`: Enable `tokio`'s optional `rt-multi-thread` feature. +- `async`: Include support for async functions and async cache stores (runtime-agnostic; no tokio dependency; uses `async-lock` and `blocking`) - `redis_store`: Include Redis cache store -- `redis_smol`: Include async Redis support using `smol` and `smol` tls support, implies `redis_store` and `async` -- `redis_tokio`: Include async Redis support using `tokio` and `tokio` tls support, implies `redis_store` and `async` +- `redis_smol`: Include async Redis support using `smol` (no TLS); implies `redis_store` and `async` +- `redis_smol_native_tls`: `redis_smol` + TLS via `native-tls` (system TLS library) +- `redis_smol_rustls`: `redis_smol` + TLS via `rustls` (pure-Rust TLS) +- `redis_tokio`: Include async Redis support using `tokio` (no TLS); implies `redis_store` and `async` +- `redis_tokio_native_tls`: `redis_tokio` + TLS via `native-tls` (system TLS library) +- `redis_tokio_rustls`: `redis_tokio` + TLS via `rustls` (pure-Rust TLS) - `redis_connection_manager`: Enable the optional `connection-manager` feature of `redis`. Any async redis caches created will use a connection manager instead of a `MultiplexedConnection`. Implies `async` (Tokio runtime) and `redis_store`, - but does **not** enable TLS. Add `redis_tokio` alongside if TLS is required. + but does **not** enable TLS. Add `redis_tokio_native_tls` or `redis_tokio_rustls` alongside if TLS is required. - `redis_async_cache`: Enable Redis client-side caching over RESP3 for async Redis caches. - When enabled standalone, this feature defaults to the Tokio async Redis path. -- `redis_ahash`: Enable the optional `ahash` feature of `redis` -- `disk_store`: Include disk cache store -- `wasm`: Enable WASM support. Note that this feature is incompatible with `tokio`'s multi-thread - runtime (`async_tokio_rt_multi_thread`) and all Redis features (`redis_store`, `redis_smol`, `redis_tokio`, `redis_ahash`) + Implies `redis_tokio`, `async`, and `redis_store`, but does not enable TLS. Add `redis_tokio_native_tls` or `redis_tokio_rustls` alongside if TLS is required. +- `redb_store`: Include disk cache store - `time_stores`: Include time-based cache stores ([`TtlCache`](https://docs.rs/cached/latest/cached/struct.TtlCache.html), [`LruTtlCache`](https://docs.rs/cached/latest/cached/struct.LruTtlCache.html), [`TtlSortedCache`](https://docs.rs/cached/latest/cached/struct.TtlSortedCache.html), [`ShardedTtlCache`](https://docs.rs/cached/latest/cached/type.ShardedTtlCache.html), and [`ShardedLruTtlCache`](https://docs.rs/cached/latest/cached/type.ShardedLruTtlCache.html)). - Also required when using `#[concurrent_cached(ttl = …)]` on the default in-memory path. + Also required when using `#[cached(ttl_secs = ...)]`, `#[cached(ttl = ...)]`, `#[cached(ttl_millis = ...)]`, `#[concurrent_cached(ttl_secs = ...)]`, `#[concurrent_cached(ttl = ...)]`, `#[concurrent_cached(ttl_millis = ...)]`, `#[once(ttl_secs = ...)]`, `#[once(ttl = ...)]`, or `#[once(ttl_millis = ...)]` on the default in-memory path. Disable this feature when targeting environments without system time support (e.g. `wasm32-unknown-unknown` without WASI or JS). The procedural macros (`#[cached]`, `#[once]`, `#[concurrent_cached]`) offer a number of features, including async support. @@ -75,43 +108,59 @@ Any custom cache that implements `cached::ConcurrentCached`/`cached::ConcurrentC | Use case | Annotated signature | |---|---| | **`#[cached]`** | | -| Unbounded memoize (default) | `#[cached] fn fib(n: u64) -> u64` | +| Unbounded memoize (default; deduplicates concurrent misses per key) | `#[cached] fn fib(n: u64) -> u64` | +| Unbounded memoize, allow concurrent misses per key (old default) | `#[cached(sync_writes = false)] fn fib(n: u64) -> u64` | | LRU-bounded — evict past N entries | `#[cached(max_size = 1_000)] fn lookup(id: u32) -> Row` | -| TTL — expire results after N seconds | `#[cached(ttl = 60)] fn config() -> Config` | -| LRU + TTL | `#[cached(max_size = 500, ttl = 300)] fn search(q: String) -> Vec` | +| TTL — expire results after N whole seconds | `#[cached(ttl_secs = 60)] fn config() -> Config` | +| TTL as a Duration expression (inlined verbatim, so `Duration` must be in scope; see note below) | `#[cached(ttl = "Duration::from_secs(60)")] fn config() -> Config` | +| TTL in milliseconds (sub-second capable; Redis honors millisecond TTL via PSETEX/PEXPIRE) | `#[cached(ttl_millis = 500)] fn poll(id: u64) -> Status` | +| LRU + TTL | `#[cached(max_size = 500, ttl_secs = 300)] fn search(q: String) -> Vec` | | Don't cache `None` returns (implicit for `Option`) | `#[cached] fn find(id: u64) -> Option` | | Don't cache `Err` returns (implicit for `Result`) | `#[cached] fn load(id: u64) -> Result` | | Force-cache `None` returns | `#[cached(cache_none = true)] fn find(id: u64) -> Option` | | Force-cache `Err` returns | `#[cached(cache_err = true)] fn load(id: u64) -> Result` | -| Serve stale value when function returns `Err` | `#[cached(result_fallback = true, ttl = 60)] fn fetch(id: u64) -> Result` | +| Serve stale value when function returns `Err` | `#[cached(result_fallback = true, ttl_secs = 60)] fn fetch(id: u64) -> Result` | | Per-value / dynamic per-entry TTL (value carries its own expiry) | `#[cached(expires = true)] fn token(scope: String) -> Token` | -| Deduplicate concurrent first calls for same key | `#[cached(ttl = 30, sync_writes = "by_key")] fn expensive(id: u64) -> Payload` | +| Deduplicate concurrent first calls for same key (explicit; same as bare `#[cached]`) | `#[cached(ttl_secs = 30, sync_writes = "by_key")] fn expensive(id: u64) -> Payload` | +| Recompute when an expression over the args is true | `#[cached(force_refresh = { id == 0 })] fn fetch(id: u64) -> Data` | +| Force-refresh via a dedicated flag (exclude it from the key) | `#[cached(key = "u64", convert = { id }, force_refresh = { refresh })] fn fetch(id: u64, refresh: bool) -> Data { let _ = refresh; … }` — the generated guard reads `refresh` to decide whether to bypass the cache; the function body still receives `refresh` as a normal parameter, so if your body does not otherwise use it, add `let _ = refresh;` (or `#[allow(unused_variables)]`) to silence the unused-variable warning | +| Cache a method inside an `impl` block (one cache shared across all instances) | `#[cached(in_impl = true)] fn load(&self, id: u64) -> Data` | +| Control visibility of generated `_no_cache` / `_prime_cache` companions | `#[cached(companions_vis = "pub(crate)")] pub fn compute(x: u64) -> u64` | | Async | `#[cached(max_size = 100)] async fn remote(id: u64) -> Data` | | **`#[once]`** | | | Compute and cache a global value forever | `#[once] fn app_config() -> Config` | -| Refresh a global value periodically | `#[once(ttl = 300, sync_writes = true)] fn pubkey() -> Key` | +| Refresh a global value periodically | `#[once(ttl_secs = 300, sync_writes = true)] fn pubkey() -> Key` | +| TTL in milliseconds (sub-second capable) | `#[once(ttl_millis = 500)] fn pubkey() -> Key` | | Optional global — skip caching if `None` (implicit) | `#[once] fn feature_flag() -> Option` | +| Recompute when an expression is true | `#[once(force_refresh = { flag })] fn config(flag: bool) -> Config` | +| Cache a method inside an `impl` block (one value shared across all instances) | `#[once(in_impl = true)] fn config(&self) -> Config` | | **`#[concurrent_cached]`** | | | Thread-safe sharded memoize (no global lock per call) | `#[concurrent_cached] fn compute(x: u64) -> u64` | | Sharded with LRU | `#[concurrent_cached(max_size = 1_000)] fn lookup(id: u64) -> Row` | -| Sharded with TTL | `#[concurrent_cached(ttl = 60)] fn fetch(url: String) -> Body` | -| Sharded LRU + TTL with custom shard count | `#[concurrent_cached(max_size = 1_000, ttl = 60, shards = 32)] fn query(id: u64) -> Row` | +| Sharded with TTL | `#[concurrent_cached(ttl_secs = 60)] fn fetch(url: String) -> Body` | +| Sharded LRU + TTL with custom shard count | `#[concurrent_cached(max_size = 1_000, ttl_secs = 60, shards = 32)] fn query(id: u64) -> Row` | +| TTL in milliseconds (sub-second; Redis honors millisecond TTL via PSETEX/PEXPIRE) | `#[concurrent_cached(ttl_millis = 500)] fn poll(id: u64) -> Status` | | Per-value expiry, thread-safe | `#[concurrent_cached(expires = true)] fn session(id: u32) -> Token` | | Per-value expiry with LRU bound | `#[concurrent_cached(expires = true, max_size = 1_000)] fn session(id: u32) -> Token` | | Cache only successful results (implicit for `Result`) | `#[concurrent_cached] fn load(id: u64) -> Result` | | Don't cache `None` returns (implicit for `Option`) | `#[concurrent_cached] fn find(id: u64) -> Option` | -| Serve stale value when function returns `Err` | `#[concurrent_cached(result_fallback = true, ttl = 60)] fn fetch(id: u64) -> Result` | -| Persist results to disk | `#[concurrent_cached(disk = true, map_error = \|e\| MyErr(e))] fn crunch(n: u64) -> Result` | -| Redis-backed async cache | `#[concurrent_cached(ty = "AsyncRedisCache", create = r#"{ ... }"#, map_error = \|e\| MyErr(e))] async fn api(id: u64) -> Result` | +| Serve stale value when function returns `Err` | `#[concurrent_cached(result_fallback = true, ttl_secs = 60)] fn fetch(id: u64) -> Result` | +| Recompute when an expression over the args is true | `#[concurrent_cached(force_refresh = { id == 0 })] fn fetch(id: u64) -> Data` | +| Force-refresh via a dedicated flag (exclude it from the key) | `#[concurrent_cached(key = "u64", convert = { id }, force_refresh = { refresh })] fn fetch(id: u64, refresh: bool) -> Data { let _ = refresh; … }` — the generated guard reads `refresh` to decide whether to bypass the cache; the body still receives it as a normal parameter, so add `let _ = refresh;` (or `#[allow(unused_variables)]`) if your body does not otherwise use it | +| Cache a method inside an `impl` block (one cache shared across all instances) | `#[concurrent_cached(in_impl = true)] fn load(&self, id: u64) -> Data` | +| Persist results to disk (with `map_error`; or omit when `E: From`) | `#[concurrent_cached(disk = true, map_error = \|e\| MyErr(e))] fn crunch(n: u64) -> Result` | +| Redis-backed async cache (quoted or unquoted `create`/`map_error`) | `#[concurrent_cached(ty = "AsyncRedisCache", create = { ... }, map_error = \|e\| MyErr(e))] async fn api(id: u64) -> Result` | On `#[cached]` and `#[concurrent_cached]`, the LRU bound is set with `max_size = N` (mirroring the `max_size` builder/constructor methods on the stores). The `size = N` spelling — a deprecated alias in 2.x — has been removed; only `max_size = N` is accepted. +The `ttl` attribute accepts a Duration expression as a quoted string: `ttl = "Duration::from_secs(60)"`. The expression is inlined verbatim, so `Duration` must be in scope at the call site (e.g. `use cached::time::Duration;`); the `ttl_secs` / `ttl_millis` forms need no import. For whole seconds, the shorter `ttl_secs = N` form is preferred. `ttl_millis = N` sets a TTL in milliseconds. The three attributes `ttl`, `ttl_secs`, and `ttl_millis` are mutually exclusive; using more than one is a compile error. All three are mutually exclusive with `expires`. Sub-second precision for `ttl_millis` is honored by the in-memory, disk (redb), and Redis stores; Redis applies the TTL with millisecond precision via PSETEX/PEXPIRE. + For the default in-memory sharded stores, `#[concurrent_cached]` accepts any return type — plain values, `Option`, or `Result`. Plain values are always cached as-is. `Option` returns skip caching `None` by default; use `cache_none = true` to also cache `None` values. `Result` only caches `Ok` values; `Err` is returned without being stored. Use `cache_err = true` to also cache `Err` values. The macro detects `Result` by matching the exact identifier `Result` (including fully-qualified paths such as `std::result::Result`). Type aliases are not resolved at macro-expansion time, so any alias — even one whose name ends with `Result` (e.g. `type MyResult = Result`) — is treated as a plain value and its `Err` variant is cached. Use `Result` directly when you need Ok-only caching behavior. The same applies to `Option` detection: a type alias such as `type MaybeRow = Option` is treated as a plain value and its `None` variant is cached. Use `Option` directly when you need `None`-skipping behavior. -On the default in-memory path, do **not** specify `map_error` — the sharded stores are infallible and supplying it is a compile error. -For `disk` and `redis` stores, `Result` is required and `map_error` must convert the store's error into your `E`. +On the default in-memory path, do not specify `map_error` -- the sharded stores are infallible and supplying it is a compile error. +For `disk` and `redis` stores, `Result` is required. `map_error` is optional: when supplied it converts the store error into your `E`; when omitted the generated code uses `.map_err(Into::into)?`, so `E` must implement `From` (disk) or `From` (Redis). Both quoted-string and unquoted forms are accepted: `map_error = |e| MyErr(e)` and `map_error = "|e| MyErr(e)"` are equivalent. **Store comparison** @@ -124,7 +173,7 @@ For `disk` and `redis` stores, `Result` is required and `map_error` must c | [`TtlSortedCache`](https://docs.rs/cached/latest/cached/struct.TtlSortedCache.html) | TTL (expiry-ordered) | Optional | Global | No | Yes | No | Yes | | [`ExpiringLruCache`](https://docs.rs/cached/latest/cached/struct.ExpiringLruCache.html) | LRU + value-defined | Yes | Per-value | N/A | Yes | No | Yes | | [`ExpiringCache`](https://docs.rs/cached/latest/cached/struct.ExpiringCache.html) | Value-defined | No | Per-value | N/A | Yes | No | Yes | -| [`ShardedCache`](https://docs.rs/cached/latest/cached/type.ShardedCache.html) | None (unbounded) | No | No | N/A | On explicit remove | Yes (`Arc`) | Yes | +| [`ShardedUnboundCache`](https://docs.rs/cached/latest/cached/type.ShardedUnboundCache.html) | None (unbounded) | No | No | N/A | On explicit remove | Yes (`Arc`) | Yes | | [`ShardedLruCache`](https://docs.rs/cached/latest/cached/type.ShardedLruCache.html) | LRU | Yes | No | N/A | Yes | Yes (`Arc`) | Yes | | [`ShardedTtlCache`](https://docs.rs/cached/latest/cached/type.ShardedTtlCache.html) | TTL (insert time) | No | Global | Optional | Yes | Yes (`Arc`) | Yes | | [`ShardedLruTtlCache`](https://docs.rs/cached/latest/cached/type.ShardedLruTtlCache.html) | LRU + TTL | Yes | Global | Optional | Yes (†) | Yes (`Arc`) | Yes | @@ -136,11 +185,11 @@ For `disk` and `redis` stores, `Result` is required and `map_error` must c `TtlCache`/`LruTtlCache`/`TtlSortedCache`/`ShardedTtlCache`/`ShardedLruTtlCache` require the `time_stores` feature. -`ShardedCache` and its variants are partitioned across power-of-two shards (default: `available_parallelism() × 4`, clamped to 8–1024; the 8–1024 clamp applies only to this computed default — an explicit `shards = N` is rounded up to a power of two but never clamped) each protected by a `parking_lot::RwLock`. Shard structs are padded to 128-byte alignment (covering Intel adjacent-line prefetch and Apple Silicon 128-byte L1 lines) to eliminate false sharing; on a 64-shard deployment this amounts to ~8 KB of padding overhead per cache array. The outer type is an `Arc` — cloning is a reference share, not a deep copy (use `deep_clone()` for an independent copy; note that `deep_clone()` is an inherent method on each concrete sharded type, not part of any trait). They implement `ConcurrentCached`/`ConcurrentCachedAsync` and are the default store selected by `#[concurrent_cached]`. +`ShardedUnboundCache` and its variants are partitioned across power-of-two shards (default: `available_parallelism() × 4`, clamped to 8–1024; the 8–1024 clamp applies only to this computed default — an explicit `shards = N` is rounded up to a power of two but never clamped) each protected by a `parking_lot::RwLock`. Shard structs are padded to 128-byte alignment (covering Intel adjacent-line prefetch and Apple Silicon 128-byte L1 lines) to eliminate false sharing; on a 64-shard deployment this amounts to ~8 KB of padding overhead per cache array. The outer type is an `Arc` — cloning is a reference share, not a deep copy (use `deep_clone()` for an independent copy; note that `deep_clone()` is an inherent method on each concrete sharded type, not part of any trait). They implement `ConcurrentCached`/`ConcurrentCachedAsync` and are the default store selected by `#[concurrent_cached]`. For sharded LRU variants, eviction is enforced independently per shard. `max_size = N` is divided across shards with ceiling division. Use the builder's `per_shard_max_size` method for an exact per-shard cap (builder-only; `#[concurrent_cached]` does not expose a `per_shard_max_size` attribute — use `shards` to control parallelism and `max_size` for total capacity). **Capacity Fragmentation Warning**: To protect against premature evictions due to hash collisions in extremely small caches (where a shard capacity could drop to 1-2 entries), when sharding is active (`shards > 1`) we enforce a minimum capacity of `16` entries **per shard** (e.g., minimum total capacity of `128` on a single-core machine with 8 shards, or `256` on a 4-core machine with 16 shards). If you require smaller, strict limits under low capacities, configure `shards = 1` or specify `per_shard_max_size` directly (builder-only; not available via `#[concurrent_cached]`). -Because LRU caches require updating access recency, `ShardedLruCache`, `ShardedLruTtlCache`, and `ShardedExpiringLruCache` must acquire an exclusive **write lock** on accessed shards during read hits, which can lead to contention under highly concurrent read-heavy workloads. Unbounded `ShardedCache`, time-only `ShardedTtlCache` (when `refresh_on_hit` is disabled — enabling it promotes read hits to exclusive write locks), and expiring `ShardedExpiringCache` require only a **shared read lock** on read hits, avoiding this contention. To mitigate contention on LRU variants, consider increasing the number of `shards` to distribute writes. +Because LRU caches require updating access recency, `ShardedLruCache`, `ShardedLruTtlCache`, and `ShardedExpiringLruCache` must acquire an exclusive **write lock** on accessed shards during read hits, which can lead to contention under highly concurrent read-heavy workloads. Unbounded `ShardedUnboundCache`, time-only `ShardedTtlCache` (when `refresh_on_hit` is disabled -- enabling it promotes read hits to exclusive write locks), and expiring `ShardedExpiringCache` require only a **shared read lock** on read hits, avoiding this contention. To mitigate contention on LRU variants, consider increasing the number of `shards` to distribute writes. Note: this write-lock-on-read behavior is a known limitation of the strict-LRU sharded stores. A future read-optimized variant that relaxes strict recency ordering will ship as a separate store type; the existing stores will not change semantics. -> **`*Base` types:** Each sharded store has a corresponding `*Base` generic (`ShardedCacheBase`, `ShardedLruCacheBase`, etc.) parameterized on a custom [`ShardHasher`]. The named aliases (`ShardedCache`, `ShardedLruCache`, …) use the default hasher and are what most users should reach for. Use the `*Base` types only when implementing a custom `ShardHasher` for non-standard shard routing. +> **`*Base` types:** Each sharded store has a corresponding `*Base` generic (`ShardedUnboundCacheBase`, `ShardedLruCacheBase`, etc.) parameterized on a custom [`ShardHasher`]. The named aliases (`ShardedUnboundCache`, `ShardedLruCache`, …) use the default hasher and are what most users should reach for. Use the `*Base` types only when implementing a custom `ShardHasher` for non-standard shard routing. Construct a custom-hasher cache through the alias builder and its `hasher` method: `ShardedLruCache::builder().hasher(my_hasher)` switches the builder's hasher type and `build` yields a `*Base` over `my_hasher`. `new`/`builder` are defined only on the default-hasher alias, so a custom hasher is always introduced through `hasher`, never a `*Base::<_, _, H>` turbofish (which would otherwise silently drop the hasher). **Behavioral guarantees** @@ -149,17 +198,28 @@ Because LRU caches require updating access recency, `ShardedLruCache`, `ShardedL managing these stores directly must add their own synchronization when sharing across threads. `Sharded*` stores are internally synchronized (per-shard `parking_lot::RwLock`) and implement `ConcurrentCached`/`ConcurrentCachedAsync` — no external lock is needed. - The synchronous `cache_get` / `cache_set` / `cache_remove` operations come from the - `ConcurrentCached` trait (it must be in scope — `use cached::ConcurrentCached;` or - `use cached::prelude::*;`), not from inherent methods. The async trait operations are - `async_`-prefixed, so they never collide (e.g., `STORE.async_cache_get(&key).await.expect("ShardedCache is infallible")`). -- `Cached::get` (and its legacy alias `cache_get`) requires mutable access because some - stores update recency, expiration timestamps, or metrics during reads. + The synchronous `get` / `set` / `remove` short aliases come from the `ConcurrentCachedExt` + extension trait (bring it into scope with `use cached::prelude::*;` or + `use cached::{ConcurrentCached, ConcurrentCachedExt};`); the `cache_get` / `cache_set` / + `cache_remove` spellings come from `ConcurrentCached` directly. For sharded stores, inherent + methods with the same names take priority at the call site. The async trait operations are + `async_`-prefixed, so they never collide (e.g., `STORE.async_cache_get(&key).await.expect("ShardedUnboundCache is infallible")`). +- `CachedExt::get` (and the `Cached::cache_get` required method it wraps) requires mutable access + because some stores update recency, expiration timestamps, or metrics during reads. +- **`len` / `size` vs `iter` vs `evict` contract for timed and expiring stores:** + `len()` (and `cache_size()`, `is_empty()`) return the raw stored entry count without + scanning for expiry. On lazy-eviction stores (`TtlCache`, `LruTtlCache`, + `TtlSortedCache`, `ExpiringCache`, `ExpiringLruCache`, and their sharded equivalents) + this count may include entries that have expired but not yet been swept, so + `len()` can be greater than `iter().count()`. `iter()` (from [`CachedIter`]) omits + expired entries from the yielded view but does not remove them from the store - it + stays `&self`. Call `evict()` (via [`CacheEvict`] for single-owner stores or + [`ConcurrentCacheEvict`] for sharded stores) to physically remove expired entries, + reclaim memory, and obtain an accurate live count. - Expired values can remain allocated until a mutating operation, `evict`, or - store-specific cleanup removes them. Methods such as `len` may include expired values - unless a store documents otherwise. + store-specific cleanup removes them. - `cache_remove` fires the `on_evict` callback (if set) and counts as an eviction for - every successful removal, across all stores that track evictions. `ShardedCache` is the + every successful removal, across all stores that track evictions. `ShardedUnboundCache` is the exception: it has no evictions counter and always returns `None` from `metrics().evictions`, though its `on_evict` callback still fires. The `on_evict` column above marks the unbounded stores where explicit removal is the *only* eviction trigger. For stores with @@ -192,7 +252,9 @@ Because LRU caches require updating access recency, `ShardedLruCache`, `ShardedL `CachedIter` or uses `.iter()` / `cache_peek` must use non-sharded stores instead. The four expiry-capable sharded stores ([`ShardedTtlCache`], [`ShardedLruTtlCache`], [`ShardedExpiringCache`], [`ShardedExpiringLruCache`]) implement [`ConcurrentCloneCached`], - which provides `cache_get_with_expiry_status` for reading stale entries without evicting them. + which provides `cache_get_with_expiry_status` for reading stale entries without evicting them, and + `cache_peek_with_expiry_status` as a side-effect-free counterpart (the built-in sharded stores + override the default, which delegates to the renewing read). **Per-Value Expiry via the `Expires` Trait** @@ -204,7 +266,7 @@ It is also the idiomatic way to give entries a **dynamic, per-entry TTL** — a When using the `#[cached]` or `#[once]` proc macros, add `expires = true` to opt into per-value expiry automatically. For `#[cached]`, this selects `ExpiringCache` (unbounded) by default or `ExpiringLruCache` when `max_size` is also specified. For `#[once]`, this stores a single value whose expiry is polled on each call. -The macro form below derives each entry's TTL from a function argument — `key`/`convert` keep the TTL out of the cache key so it influences only the entry's lifetime, not which slot it occupies: +The macro form below derives each entry's TTL from a function argument — `key`/`convert` keep the TTL out of the cache key so it influences only the entry's lifetime, not which slot it occupies (`ignore`d as a doctest because it requires the default `proc_macro` feature; the same code runs in the [`expires_per_key`](https://github.com/jaemk/cached/blob/master/examples/expires_per_key.rs) example): ```rust use cached::macros::cached; @@ -242,7 +304,7 @@ For concurrent (multi-thread, no external lock) use, the sharded equivalents [`S > `max_size` bound. ```rust -use cached::{Cached, Expires, ExpiringCache, ExpiringLruCache}; +use cached::{CachedExt, Expires, ExpiringCache, ExpiringLruCache}; use cached::time::{Duration, Instant}; #[derive(Clone)] @@ -261,18 +323,18 @@ let now = Instant::now(); // ExpiringCache — unbounded, default for `#[cached(expires = true)]` let mut cache = ExpiringCache::builder().build().unwrap(); -cache.cache_set("key1", Response { +cache.set("key1", Response { payload: "a".to_string(), expires_at: now + Duration::from_secs(1), }); -cache.cache_set("key2", Response { +cache.set("key2", Response { payload: "b".to_string(), expires_at: now + Duration::from_secs(3600), }); // ExpiringLruCache — LRU-bounded, used with `#[cached(expires = true, max_size = N)]` let mut lru = ExpiringLruCache::builder().max_size(10).build().unwrap(); -lru.cache_set("key1", Response { +lru.set("key1", Response { payload: "a".to_string(), expires_at: now + Duration::from_secs(1), }); @@ -325,12 +387,12 @@ use cached::macros::once; /// Only cache the initial function call. /// Function will be re-executed after the cache -/// expires (according to `ttl` seconds). +/// expires (according to `ttl_secs`). /// When no (or expired) cache, concurrent calls /// will synchronize (`sync_writes`) so the function /// is only executed once. # #[cfg(feature = "time_stores")] -#[once(ttl =10, sync_writes = true)] +#[once(ttl_secs=10, sync_writes = true)] fn keyed(a: String) -> Option { if a == "a" { Some(a.len()) @@ -348,7 +410,7 @@ use cached::macros::cached; /// Cannot use sync_writes and result_fallback together #[cached( - ttl = 1, + ttl_secs = 1, sync_writes = "default", result_fallback = true )] @@ -358,6 +420,18 @@ fn doesnt_compile() -> Result { ``` ---- +`cache_get_or_set_with` returns a shared reference (`&V`); binding it as `&mut V` +no longer compiles. Use [`cache_get_or_set_with_mut`](crate::Cached::cache_get_or_set_with_mut) +when you need a mutable reference. + +```compile_fail +use cached::{Cached, UnboundCache}; + +let mut cache: UnboundCache = UnboundCache::builder().build().unwrap(); +let _: &mut u32 = cache.cache_get_or_set_with(1, || 2); +``` +---- + ```rust,no_run,ignore use cached::macros::concurrent_cached; use cached::AsyncRedisCache; @@ -370,15 +444,19 @@ enum ExampleError { RedisError(String), } -/// Cache the results of an async function in redis. Cache -/// keys will be prefixed with `cache_redis_prefix`. +/// Cache the results of an async function in redis. Redis keys are laid out as +/// `{namespace}:{prefix}:{key}`, where `namespace` defaults to `cached-redis-store:` +/// and `prefix` is required (here `cached_redis_prefix`). The prefix is what scopes +/// `cache_clear` to this logical cache, so give each cache a distinct prefix. /// Redis and disk stores require `Result`; supply a `map_error` closure /// to convert store errors into your error type. #[concurrent_cached( map_error = r##"|e| ExampleError::RedisError(format!("{:?}", e))"##, ty = "AsyncRedisCache", create = r##" { - AsyncRedisCache::builder("cached_redis_prefix", Duration::from_secs(1)) + AsyncRedisCache::builder() + .prefix("cached_redis_prefix") + .ttl(Duration::from_secs(1)) .refresh_on_hit(true) .build() .await @@ -431,7 +509,7 @@ use cached::macros::concurrent_cached; /// `#[concurrent_cached]` does **not** support `sync_writes`. /// For `Option` returns, `None` is skipped by default (use `cache_none = true` to cache it). /// For `Result` returns, only `Ok` values are cached by default (use `cache_err = true` -/// to also cache `Err`). `result_fallback = true` is supported (requires `ttl`): on an `Err` +/// to also cache `Err`). `result_fallback = true` is supported (requires `ttl_secs`, `ttl_millis`, or `ttl = ""`): on an `Err` /// return, the last cached `Ok` value for the same key is returned instead. The stale value /// is held in the primary cache slot and re-cached with a fresh TTL window on `Err`; no /// secondary store is created. @@ -485,12 +563,35 @@ Due to the requirements of storing arguments and return values in a global cache - For I/O-backed stores used by `#[concurrent_cached]` (Redis and disk), must either be owned and implement `Display + Clone`, or a `convert` expression must be used to produce a key of a `Display + Clone` type. `Clone` is needed so removal APIs can return the stored key. + - Floats (`f32` / `f64`), and any type containing them (e.g. a struct with float fields), do not + implement `Hash` / `Eq`, so they are the canonical case that requires a `convert` expression to + produce a hashable key. For example `key = "String", convert = r#"{ format!("{:.6}", x) }"#`, or + wrap the value with a crate such as `ordered-float`. - Arguments and return values will be `cloned` in the process of insertion and retrieval. For Redis and disk stores, keys are additionally formatted into `String`s and values are de/serialized. - Macro-defined functions should not be used to produce side-effectual results! -- Macro-defined functions cannot live directly under `impl` blocks since macros expand to a - static initialization and one or more function definitions. -- Macro-defined functions cannot accept `Self` types as a parameter. +- Macro-defined functions live at module scope by default (the macro expands to a static plus + one or more functions). To cache a method inside an `impl` block, set `in_impl = true`, which + emits the cache static inside the generated method body instead. A `{fn}_no_cache` sibling + method is generated at the same visibility, calling the original body directly and bypassing + the cache. The `_prime_cache` companion is not generated for `in_impl` methods (a + function-local static cannot be shared between two sibling methods, so priming would silently + do nothing; calling a non-existent prime function is a clear compile error instead). +- Macro-defined methods may take a `self` receiver only when `in_impl = true`; `self` is excluded + from the default cache key. Otherwise `self`-receiver methods are rejected with a compile error + (a `convert` block alone does not make them valid: off the `in_impl` path the cache static is + emitted at `impl` scope, where a `static` is not a legal item). + **Footgun:** because `self` is excluded, two instances with different internal state but identical + arguments share one cache entry, so `a.load(5)` and `b.load(5)` return the same cached value even + when `a` and `b` differ. The cache is process-global, not per-instance. If a method's result + depends on `self`'s fields, fold them into the key with a `convert` expression (e.g. + `convert = r#"{ format!("{}:{}", self.id, id) }"#`), or keep the logic in a free function keyed on + those fields. +- Macro-defined functions can be generic over type parameters only when a `key` + `convert` is + supplied to produce a concrete key type. On the default-key path (no `convert`), `#[cached]` / + `#[concurrent_cached]` reject generic functions, since each monomorphization would need its own + static cache: write a concrete monomorphic wrapper per type instead. (`#[once]` caches a single + concrete value and is unaffected.) diff --git a/benches/cache_benches.rs b/benches/cache_benches.rs index 16f76946..f9dbbda8 100644 --- a/benches/cache_benches.rs +++ b/benches/cache_benches.rs @@ -1,8 +1,8 @@ use cached::time::Duration; use cached::{ Cached, CachedRead, ConcurrentCached, Expires, ExpiringCache, ExpiringLruCache, LruCache, - LruTtlCache, ShardedCache, ShardedLruCache, ShardedLruTtlCache, TtlCache, TtlSortedCache, - UnboundCache, + LruTtlCache, ShardedLruCache, ShardedLruTtlCache, ShardedUnboundCache, TtlCache, + TtlSortedCache, UnboundCache, }; use criterion::{Criterion, Throughput, criterion_group, criterion_main}; use parking_lot::{Mutex, RwLock}; @@ -325,7 +325,7 @@ macro_rules! run_concurrent { // ---- Group 1: unbounded cache ------------------------------------------------- fn bench_sharded_unbound_concurrent(c: &mut Criterion) { - let mut group = c.benchmark_group("Concurrent Reads: ShardedCache vs single-lock"); + let mut group = c.benchmark_group("Concurrent Reads: ShardedUnboundCache vs single-lock"); group.throughput(Throughput::Elements(N_THREADS as u64)); // Baseline A: Mutex — every read takes an exclusive lock. @@ -355,7 +355,7 @@ fn bench_sharded_unbound_concurrent(c: &mut Criterion) { // Baseline C: RwLock using CachedRead (shared read lock). // UnboundCache uses StripedCounter (16-slot padded atomics) for hits/misses // to reduce false sharing on the counter words, but the global RwLock still - // serializes all writers. ShardedCache avoids the single global lock entirely + // serializes all writers. ShardedUnboundCache avoids the single global lock entirely // by keeping both the lock and the counters per-shard. let rw_unbound = Arc::new(RwLock::new({ let mut c = UnboundCache::builder().build().unwrap(); @@ -373,12 +373,14 @@ fn bench_sharded_unbound_concurrent(c: &mut Criterion) { }) }); - // ShardedCache: per-shard RwLocks eliminate inter-thread read contention. - let sharded = ShardedCache::::builder().build().unwrap(); + // ShardedUnboundCache: per-shard RwLocks eliminate inter-thread read contention. + let sharded = ShardedUnboundCache::::builder() + .build() + .unwrap(); for i in 0..N_KEYS { sharded.cache_set(i, i * 2).expect("infallible"); } - group.bench_function("ShardedCache", |b| { + group.bench_function("ShardedUnboundCache", |b| { b.iter_custom(|iters| { let cache = sharded.clone(); // Arc clone run_concurrent!(cache, iters, t, i, { @@ -390,7 +392,7 @@ fn bench_sharded_unbound_concurrent(c: &mut Criterion) { group.finish(); // ---- Write benchmark (distinct keys, measures lock contention on inserts) ---- - let mut group = c.benchmark_group("Concurrent Writes: ShardedCache vs single-lock"); + let mut group = c.benchmark_group("Concurrent Writes: ShardedUnboundCache vs single-lock"); group.throughput(Throughput::Elements(N_THREADS as u64)); let mutex_map_w: Arc>> = Arc::new(Mutex::new(HashMap::new())); @@ -403,8 +405,10 @@ fn bench_sharded_unbound_concurrent(c: &mut Criterion) { }) }); - let sharded_w = ShardedCache::::builder().build().unwrap(); - group.bench_function("ShardedCache", |b| { + let sharded_w = ShardedUnboundCache::::builder() + .build() + .unwrap(); + group.bench_function("ShardedUnboundCache", |b| { b.iter_custom(|iters| { let cache = sharded_w.clone(); run_concurrent!(cache, iters, t, i, { diff --git a/bin/tag-release.sh b/bin/tag-release.sh new file mode 100755 index 00000000..2617587c --- /dev/null +++ b/bin/tag-release.sh @@ -0,0 +1,93 @@ +#!/bin/bash + +# Create a git tag and GitHub release for every workspace crate whose current +# version is not yet tagged on the remote. +# +# Idempotent: a crate whose tag/release already exists is skipped. The script +# tags every publishable workspace crate that lacks a tag or release, which +# includes backfilling crates published in earlier runs as well as those just +# published. It leaves crates that are already fully tagged and released alone. +# +# Tag naming: +# - root crate `cached` -> vX.Y.Z (bare, kept for back-compat) +# - workspace subcrates -> -vX.Y.Z +# +# Requires: git, jq, cargo, and the gh CLI authenticated (GH_TOKEN in CI). + +set -euo pipefail + +# The root crate keeps the bare `vX.Y.Z` tag; subcrates are namespaced by name. +ROOT_CRATE="cached" + +# Use the bot identity for the annotated tags when running in CI; leave a local +# user's git config untouched otherwise. Scope the write to the repo-local config +# (`--local`) so it is explicit that only this checkout is affected, never the +# runner's global identity. +if [ "${GITHUB_ACTIONS:-}" = "true" ]; then + git config --local user.name "github-actions[bot]" + git config --local user.email "github-actions[bot]@users.noreply.github.com" +fi + +tag_exists_on_remote() { + # `git ls-remote` output format is "\trefs/tags/". Match the + # tab-prefixed ref name as a fixed string so dots in tag names (e.g. + # "v1.0.0") are not treated as regex metacharacters, and the leading tab + # ensures we match only the full ref field with no substring false positives + # (e.g. "v1.0.0" would not match "v1.0.0-rc1" because that tag would appear + # as " refs/tags/v1.0.0-rc1", not containing " refs/tags/v1.0.0" verbatim). + git ls-remote --tags origin "refs/tags/$1" | grep -qF -- " refs/tags/$1" +} + +release_exists() { + gh release view "$1" >/dev/null 2>&1 +} + +tag_and_release() { + local tag=$1 + # Cache both remote checks up front so each network call runs at most once. + local tag_remote release_remote + tag_exists_on_remote "$tag" && tag_remote=true || tag_remote=false + release_exists "$tag" && release_remote=true || release_remote=false + + if [ "$tag_remote" = true ] && [ "$release_remote" = true ]; then + echo "Tag $tag and its GitHub release already exist - skipping." + return 0 + fi + echo "Creating tag $tag and GitHub release..." + # Reuse a local tag left by a previous run whose push failed, rather than + # aborting on "tag already exists"; only create it when absent. + if ! git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then + git tag -a "$tag" -m "Release $tag" + fi + # Push only when the tag is not already on the remote (a prior run may + # have pushed the tag but failed before creating the release). + if [ "$tag_remote" = false ]; then + git push origin "$tag" + fi + # Create the release only when missing, so retrying after a failed + # `gh release create` is idempotent. + if [ "$release_remote" = false ]; then + gh release create "$tag" --generate-notes --title "$tag" + fi +} + +# One "name version" line per publishable workspace member. --no-deps excludes +# dependencies; the `.publish != []` filter drops members with `publish = false` +# (e.g. the wasm example), since those are never released. +# +# cargo-metadata contract: `publish = false` serializes as `"publish": []`, +# while an absent publish field (meaning "publish to all registries") serializes +# as `"publish": null`. Comparing `!= []` therefore keeps null (publishable) +# and drops [] (explicitly suppressed) without treating them the same way. +members=$(cargo metadata --no-deps --format-version 1 \ + | jq -r '.packages[] | select(.publish != []) | "\(.name) \(.version)"') + +while read -r name version; do + [ -z "$name" ] && continue + if [ "$name" = "$ROOT_CRATE" ]; then + tag="v$version" + else + tag="$name-v$version" + fi + tag_and_release "$tag" +done <<< "$members" diff --git a/cached_proc_macro/Cargo.toml b/cached_proc_macro/Cargo.toml index a2cccbb7..d2df3c2b 100644 --- a/cached_proc_macro/Cargo.toml +++ b/cached_proc_macro/Cargo.toml @@ -10,10 +10,13 @@ categories = ["caching"] keywords = ["caching", "cache", "memoize", "lru"] license = "MIT" edition = "2024" -rust-version = "1.85" +rust-version = "1.89" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lints] +workspace = true + [lib] proc-macro = true @@ -22,3 +25,4 @@ quote = "1.0.6" darling = "0.20.8" proc-macro2 = "1.0.49" syn = "2.0.52" +proc-macro-crate = "3" diff --git a/cached_proc_macro/src/cached.rs b/cached_proc_macro/src/cached.rs index 52a84308..59ba1f83 100644 --- a/cached_proc_macro/src/cached.rs +++ b/cached_proc_macro/src/cached.rs @@ -4,7 +4,7 @@ use darling::ast::NestedMeta; use proc_macro::TokenStream; use quote::quote; use syn::spanned::Spanned; -use syn::{Block, Ident, ItemFn, ReturnType, Type, parse_macro_input, parse_str}; +use syn::{Ident, ItemFn, ReturnType, Type, parse_macro_input, parse_str}; #[derive(Debug, Default, Eq, PartialEq)] enum SyncLock { @@ -36,8 +36,20 @@ struct CachedMacroArgs { /// Mirrors the `max_size` builder/constructor naming on the cache stores. #[darling(default)] max_size: Option, + /// A cache TTL expressed as a `Duration` expression in a string literal + /// (same convention as `create`/`convert`), e.g. + /// `ttl = "core::time::Duration::from_secs(60)"`. Mutually exclusive with + /// `ttl_secs`, `ttl_millis`, and `expires`. #[darling(default)] - ttl: Option, + ttl: Option, + /// TTL in whole seconds. Convenience alternative to `ttl`. Mutually + /// exclusive with `ttl`, `ttl_millis`, and `expires`. + #[darling(default)] + ttl_secs: Option, + /// TTL in milliseconds. A finer-grained alternative to `ttl_secs`. Mutually + /// exclusive with `ttl`, `ttl_secs`, and `expires` (#149). + #[darling(default)] + ttl_millis: Option, #[darling(default)] refresh: bool, #[darling(default)] @@ -50,13 +62,17 @@ struct CachedMacroArgs { #[darling(default)] key: Option, #[darling(default)] - convert: Option, + convert: Option, #[darling(default)] cache_err: bool, #[darling(default)] cache_none: bool, + /// `None` = not specified by user (defaults to `ByKey` for `#[cached]`). + /// `Some(Disabled)` = explicit `sync_writes = false`. + /// `Some(Default)` = explicit `sync_writes = true` / `"default"`. + /// `Some(ByKey)` = explicit `sync_writes = "by_key"`. #[darling(default)] - sync_writes: SyncWriteMode, + sync_writes: Option, #[darling(default = "default_sync_writes_buckets")] sync_writes_buckets: usize, #[darling(default)] @@ -66,13 +82,30 @@ struct CachedMacroArgs { #[darling(default)] ty: Option, #[darling(default)] - create: Option, + create: Option, #[darling(default)] result_fallback: bool, #[darling(default)] unsync_reads: bool, #[darling(default)] expires: bool, + /// A boolean expression over the function arguments; when it evaluates to + /// `true`, the cached value (if any) is bypassed and the function body is + /// re-run and re-cached. Orthogonal to `refresh` (which renews a TTL on a + /// cache hit). Both unquoted `{ expr }` and legacy quoted `"{ expr }"` forms + /// are accepted (#146). + #[darling(default)] + force_refresh: Option, + /// Override the visibility of the companion fns (`{fn}_no_cache`, + /// `{fn}_prime_cache`). Parsed as a `syn::Visibility` string. `None` (default) + /// inherits the cached fn's visibility. `""` means private. + #[darling(default)] + companions_vis: Option, + /// Allow the macro on a method that takes `self` inside an `impl` block. + /// The cache static is emitted inside the generated fn body (legal there) + /// and the receiver is preserved/forwarded (#16/#140). + #[darling(default)] + in_impl: bool, // Removed attributes intercepted to provide helpful error messages #[darling(default)] result: Option, @@ -84,6 +117,51 @@ fn default_sync_writes_buckets() -> usize { 64 } +/// When a `create` block is supplied the user fully constructs the store, so the +/// store-builder attributes the macro would otherwise apply are dropped. Reject +/// those attributes with a precise message instead of silently ignoring them - +/// otherwise (e.g. with `ttl_millis`) the store-type match no longer reaches the +/// `create` arm and the user sees the generic "cache types are mutually +/// exclusive" message rather than a specific one. Mirrors +/// `#[concurrent_cached]`'s `check_create_conflicts`. +fn check_create_conflicts( + args: &CachedMacroArgs, + span: proc_macro2::Span, +) -> Result<(), syn::Error> { + let mut conflicting = Vec::new(); + if args.ttl.is_some() { + conflicting.push("ttl"); + } + if args.ttl_secs.is_some() { + conflicting.push("ttl_secs"); + } + if args.ttl_millis.is_some() { + conflicting.push("ttl_millis"); + } + if args.refresh { + conflicting.push("refresh"); + } + if args.max_size.is_some() { + conflicting.push("max_size"); + } + if conflicting.is_empty() { + return Ok(()); + } + let list = conflicting + .iter() + .map(|a| format!("`{a}`")) + .collect::>() + .join(", "); + Err(syn::Error::new( + span, + format!( + "cannot specify {list} when passing a `create` block - `create` fully \ + constructs the store, so these store-builder attributes would be \ + silently ignored" + ), + )) +} + pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { let attr_args = match NestedMeta::parse_meta_list(args.into()) { Ok(v) => v, @@ -91,6 +169,9 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { return TokenStream::from(darling::Error::from(e).write_errors()); } }; + if let Err(e) = reject_concurrent_only_attrs("cached", &attr_args) { + return e.to_compile_error().into(); + } let args = match CachedMacroArgs::from_list(&attr_args) { Ok(v) => v, Err(e) => { @@ -105,19 +186,97 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { let signature = input.sig; let body = input.block; + // Resolve the path to the `cached` crate so generated code works when the + // dependency is renamed by the downstream crate (#157). + let krate = crate_path(); + + // Resolve the effective sync_writes mode. + // `None` (unspecified by user) defaults to `ByKey` for `#[cached]`. + // Explicit `sync_writes = false` => Disabled; `= true`/`"default"` => Default; + // `= "by_key"` => ByKey. + let sync_writes_explicit = args.sync_writes.is_some(); + let sync_writes = args.sync_writes.unwrap_or(SyncWriteMode::ByKey); + // pull out the parts of the function signature let fn_ident = signature.ident.clone(); let inputs = signature.inputs.clone(); let output = signature.output.clone(); let asyncness = signature.asyncness; - - if inputs + let has_receiver = inputs .iter() - .any(|input| matches!(input, syn::FnArg::Receiver(_))) + .any(|input| matches!(input, syn::FnArg::Receiver(_))); + + // Resolve companion fn visibility (#9). `companions_vis = None` inherits the + // cached fn's visibility. `companions_vis = Some(s)` parses `s` as a + // `syn::Visibility`; an empty string produces private visibility. + let companions_visibility = match &args.companions_vis { + None => quote! { #visibility }, + Some(s) if s.is_empty() => quote! {}, + Some(s) => match parse_str::(s) { + Ok(vis) => quote! { #vis }, + Err(e) => { + return syn::Error::new( + fn_ident.span(), + format!( + "unable to parse `companions_vis` as a visibility: {e}; \ + expected a Rust visibility, e.g. `\"pub\"`, `\"pub(crate)\"`, or `\"\"`" + ), + ) + .to_compile_error() + .into(); + } + }, + }; + + // Reject `self` methods unless `in_impl = true`. A `self` receiver only + // exists inside an `impl`/trait, and off the `in_impl` path the cache static + // is emitted at that same scope, where a `static` is not a valid item - so a + // `convert` block alone cannot rescue a `self` method (it would still fail + // later with an opaque error). `in_impl` is the only fix (#16/#140). + if has_receiver && !args.in_impl { + return syn::Error::new( + fn_ident.span(), + "#[cached] cannot be applied to methods that take `self`. \ + Set `in_impl = true` to cache the method inside its `impl` block \ + (a `convert` block alone is not sufficient: the generated cache \ + static cannot live at `impl` scope).", + ) + .to_compile_error() + .into(); + } + + // The inverse: `in_impl = true` on a function with no `self` receiver + // mis-compiles, because the generated `{fn}_no_cache(args)` call inside the + // impl cannot resolve without a `Self::` qualifier (a confusing "cannot find + // function" error downstream). Reject it here with a clear message. + if args.in_impl && !has_receiver { + return syn::Error::new( + fn_ident.span(), + "in_impl = true requires a method with a `self` receiver; \ + for a free function or an associated function without `self`, \ + remove in_impl.", + ) + .to_compile_error() + .into(); + } + + // Generic functions need the cache key pinned to a concrete type: the cache + // is a single monomorphic `static` and cannot name the function's type + // parameters. With an explicit `key` + `convert` the key type is concrete + // and generics work (see the generic-where tests). Without `convert` the + // default-key path would embed the type parameters in the key type, which + // cannot compile - reject it with a clear diagnostic and workaround (#80). + if (signature.generics.type_params().next().is_some() + || signature.generics.const_params().next().is_some()) + && args.convert.is_none() { return syn::Error::new( fn_ident.span(), - "#[cached] cannot be applied to methods that take `self`", + "#[cached] on a generic function requires `key` + `convert` to pin the cache key to a \ + concrete type: the cache is a single monomorphic static shared across all \ + instantiations and cannot name the function's type parameters. \ + Provide `key`/`convert` (and `ty`/`create` if the value type is also generic), or \ + wrap the generic function in a non-generic `#[cached]` function per concrete type.", ) .to_compile_error() .into(); @@ -130,16 +289,59 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { .to_compile_error() .into(); } - if matches!(args.ttl, Some(0)) { - return syn::Error::new(fn_ident.span(), "`ttl` must be >= 1") - .to_compile_error() - .into(); + // Run the `expires`-vs-ttl mutual-exclusion checks BEFORE resolving the TTL + // `Duration`. These need only presence (`is_some()`), not a parsed value, and + // surfacing "mutually exclusive" is more relevant than a `ttl` parse error + // when `expires` is also set. + if args.expires && args.ttl_secs.is_some() { + return syn::Error::new( + fn_ident.span(), + "`expires` and `ttl_secs` are mutually exclusive - \ + `expires` delegates expiry to the value via the `Expires` trait; \ + `ttl_secs` applies a uniform time-based TTL to all entries", + ) + .to_compile_error() + .into(); + } + if args.expires && args.ttl_millis.is_some() { + return syn::Error::new( + fn_ident.span(), + "`expires` and `ttl_millis` are mutually exclusive - \ + `expires` delegates expiry to the value via the `Expires` trait; \ + `ttl_millis` applies a uniform millisecond TTL to all entries", + ) + .to_compile_error() + .into(); } + if args.expires && args.ttl.is_some() { + return syn::Error::new( + fn_ident.span(), + "`expires` and `ttl` are mutually exclusive - \ + `expires` delegates expiry to the value via the `Expires` trait; \ + `ttl` applies a uniform time-based TTL to all entries", + ) + .to_compile_error() + .into(); + } + // Resolve the TTL `Duration` token from whichever of `ttl` (expr), `ttl_secs`, + // or `ttl_millis` is set. This performs the 3-way mutual-exclusion check, the + // `ttl_secs`/`ttl_millis` >= 1 validation, and parses the `ttl` expression. + let (has_ttl, ttl_duration) = match resolve_ttl_duration( + &krate, + &args.ttl, + args.ttl_secs, + args.ttl_millis, + fn_ident.span(), + ) { + Ok(v) => v, + Err(e) => return e.to_compile_error().into(), + }; if args.time.is_some() { return syn::Error::new( fn_ident.span(), - "`time` was renamed to `ttl` in cached 1.0; use `ttl = ...`", + "`time` (whole seconds) was renamed in cached 1.0; use `ttl_secs = ...` \ + (or `ttl = \"Duration::from_secs(...)\"` / `ttl_millis = ...`)", ) .to_compile_error() .into(); @@ -154,6 +356,16 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { .into(); } + if args.unbound { + return syn::Error::new( + fn_ident.span(), + "the `unbound` attribute has been removed. The default store (no `max_size`, \ + `ttl`, or `expires`) is already an `UnboundCache`, so use `#[cached]` without `unbound`.", + ) + .to_compile_error() + .into(); + } + if args.size.is_some() { return syn::Error::new( fn_ident.span(), @@ -185,21 +397,10 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { .into(); } - if args.expires && args.ttl.is_some() { - return syn::Error::new( - fn_ident.span(), - "`expires` and `ttl` are mutually exclusive — \ - `expires` delegates expiry to the value via the `Expires` trait; \ - `ttl` applies a uniform time-based TTL to all entries", - ) - .to_compile_error() - .into(); - } - if args.expires && args.ty.is_some() { return syn::Error::new( fn_ident.span(), - "`expires` and `ty` are mutually exclusive — \ + "`expires` and `ty` are mutually exclusive - \ `expires` generates the store type automatically", ) .to_compile_error() @@ -209,7 +410,7 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { if args.expires && args.create.is_some() { return syn::Error::new( fn_ident.span(), - "`expires` and `create` are mutually exclusive — \ + "`expires` and `create` are mutually exclusive - \ `expires` generates the store constructor automatically", ) .to_compile_error() @@ -219,7 +420,7 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { if args.expires && args.with_cached_flag { return syn::Error::new( fn_ident.span(), - "`expires` and `with_cached_flag` are mutually exclusive — \ + "`expires` and `with_cached_flag` are mutually exclusive - \ the `Return` wrapper does not implement `Expires`", ) .to_compile_error() @@ -229,7 +430,7 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { if args.expires && args.unsync_reads { return syn::Error::new( fn_ident.span(), - "`expires` and `unsync_reads` are mutually exclusive — \ + "`expires` and `unsync_reads` are mutually exclusive - \ `ExpiringCache` and `ExpiringLruCache` do not implement `CachedRead`", ) .to_compile_error() @@ -239,7 +440,7 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { if args.expires && args.refresh { return syn::Error::new( fn_ident.span(), - "`expires` and `refresh` are mutually exclusive — \ + "`expires` and `refresh` are mutually exclusive - \ `refresh` renews a TTL on cache hit, but `ExpiringCache` and \ `ExpiringLruCache` have no TTL to refresh; expiry is controlled by the value", ) @@ -247,20 +448,27 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { .into(); } - if args.expires && args.unbound { + // `refresh = true` renews a TTL on cache hit. The default `UnboundCache`/`LruCache` + // stores have no TTL to renew, so reject `refresh` unless a TTL is set (mirrors the + // check in `concurrent_cached.rs`). `expires` is handled by the dedicated + // mutual-exclusion check above, so exclude it here to avoid a confusing double error. + // When a `create` block is supplied, the store is user-constructed and `refresh` is + // rejected by `check_create_conflicts` below with a more specific message; skip here. + if args.refresh && !has_ttl && !args.expires && args.create.is_none() { return syn::Error::new( fn_ident.span(), - "`expires` and `unbound` are mutually exclusive — \ - `ExpiringCache` (the default store for `expires`) is already unbounded; \ - use `expires = true` alone for an unbounded expiring cache", + "`refresh` requires a TTL (`ttl`/`ttl_secs`/`ttl_millis`) to be set - \ + `refresh` renews a TTL on cache hit, but the default `UnboundCache`/`LruCache` \ + stores have no TTL to renew", ) .to_compile_error() .into(); } + if args.expires && args.cache_none { return syn::Error::new( fn_ident.span(), - "`expires = true` and `cache_none = true` are incompatible — `expires` requires \ + "`expires = true` and `cache_none = true` are incompatible - `expires` requires \ the cache value type to implement `Expires`, but `cache_none = true` stores \ `Option` as the value, which does not implement `Expires`. \ Remove `cache_none = true` (None values are not cached by default with `expires = true`).", @@ -271,7 +479,7 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { if args.expires && args.cache_err { return syn::Error::new( fn_ident.span(), - "`expires = true` and `cache_err = true` are incompatible — `expires` requires \ + "`expires = true` and `cache_err = true` are incompatible - `expires` requires \ the cache value type to implement `Expires`, but `cache_err = true` stores \ `Result` as the value, which does not implement `Expires`. \ Remove `cache_err = true` (Err values are not cached by default with `expires = true`).", @@ -329,7 +537,7 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { fn_ident.span(), "`cache_none = true` and `with_cached_flag = true` are structurally incompatible \ on `Option` returns: `with_cached_flag` stores the inner `T` from `Return` \ - while `cache_none = true` stores `Option` as the cached value — the same \ + while `cache_none = true` stores `Option` as the cached value - the same \ cache entry cannot hold both types. Use `with_cached_flag = true` alone (to get \ cache-state flags; `None` is not cached by default), or use `cache_none = true` \ alone (to force-cache `None` values).", @@ -351,7 +559,14 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { // make the cache identifier let cache_ident = match args.name { - Some(ref name) => Ident::new(name, fn_ident.span()), + Some(ref name) => { + if syn::parse_str::(name).is_err() { + return syn::Error::new(fn_ident.span(), "`name` must be a valid Rust identifier") + .to_compile_error() + .into(); + } + Ident::new(name, fn_ident.span()) + } None => Ident::new(&fn_ident.to_string().to_uppercase(), fn_ident.span()), }; @@ -361,54 +576,64 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { Err(error) => return error.to_compile_error().into(), }; + // `has_ttl` / `ttl_duration` were resolved above (from `ttl` expr, `ttl_secs`, + // or `ttl_millis`). `has_ttl` drives store selection and the + // `result_fallback`/`unsync_reads` TTL-presence checks (#149). + + // When a `create` block is supplied, reject the store-builder attributes the + // macro would otherwise apply before the store-type match below. Without this + // the match falls through to the generic "cache types are mutually exclusive" + // arm (e.g. for `ttl_millis`), masking the specific conflict (#149). + if args.create.is_some() + && let Err(error) = check_create_conflicts(&args, fn_ident.span()) + { + return error.to_compile_error().into(); + } + // make the cache type and create statement let (cache_ty, cache_create) = if args.expires { if let Some(size) = args.max_size { ( - quote! { cached::ExpiringLruCache<#cache_key_ty, #cache_value_ty> }, - quote! { cached::ExpiringLruCache::builder().max_size(#size).build().unwrap_or_else(|e| panic!("ExpiringLruCache build failed in #[cached]: {e}")) }, + quote! { #krate::ExpiringLruCache<#cache_key_ty, #cache_value_ty> }, + quote! { #krate::ExpiringLruCache::builder().max_size(#size).build().unwrap_or_else(|e| panic!("ExpiringLruCache build failed in #[cached]: {e}")) }, ) } else { ( - quote! { cached::ExpiringCache<#cache_key_ty, #cache_value_ty> }, - quote! { cached::ExpiringCache::builder().build().unwrap_or_else(|e| panic!("ExpiringCache build failed in #[cached]: {e}")) }, + quote! { #krate::ExpiringCache<#cache_key_ty, #cache_value_ty> }, + quote! { #krate::ExpiringCache::builder().build().unwrap_or_else(|e| panic!("ExpiringCache build failed in #[cached]: {e}")) }, ) } } else { match ( - &args.unbound, &args.max_size, - &args.ttl, + has_ttl, &args.ty, &args.create, &args.refresh, ) { - (true, None, None, None, None, _) => { - let cache_ty = quote! {cached::UnboundCache<#cache_key_ty, #cache_value_ty>}; - let cache_create = quote! {cached::UnboundCache::builder().build().unwrap_or_else(|e| panic!("UnboundCache build failed in #[cached]: {e}"))}; - (cache_ty, cache_create) - } - (false, Some(size), None, None, None, _) => { - let cache_ty = quote! {cached::LruCache<#cache_key_ty, #cache_value_ty>}; - let cache_create = quote! {cached::LruCache::builder().max_size(#size).build().unwrap_or_else(|e| panic!("LruCache build failed in #[cached]: {e}"))}; + (Some(size), false, None, None, _) => { + let cache_ty = quote! {#krate::LruCache<#cache_key_ty, #cache_value_ty>}; + let cache_create = quote! {#krate::LruCache::builder().max_size(#size).build().unwrap_or_else(|e| panic!("LruCache build failed in #[cached]: {e}"))}; (cache_ty, cache_create) } - (false, None, Some(ttl), None, None, refresh) => { - let cache_ty = quote! {cached::TtlCache<#cache_key_ty, #cache_value_ty>}; - let cache_create = quote! {cached::TtlCache::builder().ttl(::cached::time::Duration::from_secs(#ttl)).refresh_on_hit(#refresh).build().unwrap_or_else(|e| panic!("TtlCache build failed in #[cached]: {e}"))}; + (None, true, None, None, refresh) => { + let ttl_dur = ttl_duration.as_ref().expect("has_ttl implies ttl_duration"); + let cache_ty = quote! {#krate::TtlCache<#cache_key_ty, #cache_value_ty>}; + let cache_create = quote! {#krate::TtlCache::builder().ttl(#ttl_dur).refresh_on_hit(#refresh).build().unwrap_or_else(|e| panic!("TtlCache build failed in #[cached]: {e}"))}; (cache_ty, cache_create) } - (false, Some(size), Some(ttl), None, None, refresh) => { - let cache_ty = quote! {cached::LruTtlCache<#cache_key_ty, #cache_value_ty>}; - let cache_create = quote! {cached::LruTtlCache::builder().max_size(#size).ttl(::cached::time::Duration::from_secs(#ttl)).refresh_on_hit(#refresh).build().unwrap_or_else(|e| panic!("LruTtlCache build failed in #[cached]: {e}"))}; + (Some(size), true, None, None, refresh) => { + let ttl_dur = ttl_duration.as_ref().expect("has_ttl implies ttl_duration"); + let cache_ty = quote! {#krate::LruTtlCache<#cache_key_ty, #cache_value_ty>}; + let cache_create = quote! {#krate::LruTtlCache::builder().max_size(#size).ttl(#ttl_dur).refresh_on_hit(#refresh).build().unwrap_or_else(|e| panic!("LruTtlCache build failed in #[cached]: {e}"))}; (cache_ty, cache_create) } - (false, None, None, None, None, _) => { - let cache_ty = quote! {cached::UnboundCache<#cache_key_ty, #cache_value_ty>}; - let cache_create = quote! {cached::UnboundCache::builder().build().unwrap_or_else(|e| panic!("UnboundCache build failed in #[cached]: {e}"))}; + (None, false, None, None, _) => { + let cache_ty = quote! {#krate::UnboundCache<#cache_key_ty, #cache_value_ty>}; + let cache_create = quote! {#krate::UnboundCache::builder().build().unwrap_or_else(|e| panic!("UnboundCache build failed in #[cached]: {e}"))}; (cache_ty, cache_create) } - (false, None, None, Some(type_str), Some(create_str), _) => { + (None, false, Some(type_str), Some(create_expr), _) => { let ty = match parse_str::(type_str) { Ok(ty) => ty, Err(error) => { @@ -421,26 +646,16 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { } }; - let cache_create = match parse_str::(create_str) { - Ok(block) => block, - Err(error) => { - return syn::Error::new( - fn_ident.span(), - format!("unable to parse cache create block: {error}"), - ) - .to_compile_error() - .into(); - } - }; + let cache_create = expr_value_tokens(create_expr); - (quote! { #ty }, quote! { #cache_create }) + (quote! { #ty }, cache_create) } - (false, None, None, Some(_), None, _) => { + (None, false, Some(_), None, _) => { return syn::Error::new(fn_ident.span(), "`ty` requires `create` to also be set") .to_compile_error() .into(); } - (false, None, None, None, Some(_), _) => { + (None, false, None, Some(_), _) => { return syn::Error::new(fn_ident.span(), "`create` requires `ty` to also be set") .to_compile_error() .into(); @@ -448,7 +663,7 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { _ => { return syn::Error::new( fn_ident.span(), - "cache types (`unbound`, `max_size` and/or `ttl`, or `ty` and `create`) are mutually exclusive", + "cache types (`max_size` and/or `ttl`, or `ty` and `create`) are mutually exclusive", ) .to_compile_error() .into(); @@ -459,37 +674,38 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { // make the set cache and return cache blocks let (set_cache_block, return_cache_block) = match (is_smart_result, is_smart_option) { (false, false) => { - let set_cache_block = quote! { cache.set(key, result.clone()); }; + let set_cache_block = + quote! { __cached_cache.cache_set(__cached_key, __cached_result.clone()); }; let return_cache_block = if args.with_cached_flag { - quote! { let mut r = result.to_owned(); r.was_cached = true; return r } + quote! { let mut __cached_r = __cached_result.to_owned(); __cached_r.was_cached = true; return __cached_r } } else { - quote! { return result.to_owned() } + quote! { return __cached_result.to_owned() } }; (set_cache_block, return_cache_block) } (true, false) => { let set_cache_block = quote! { - if let Ok(result) = &result { - cache.set(key, result.clone()); + if let Ok(__cached_inner) = &__cached_result { + __cached_cache.cache_set(__cached_key, __cached_inner.clone()); } }; let return_cache_block = if args.with_cached_flag { - quote! { let mut r = result.to_owned(); r.was_cached = true; return Ok(r) } + quote! { let mut __cached_r = __cached_result.to_owned(); __cached_r.was_cached = true; return Ok(__cached_r) } } else { - quote! { return Ok(result.to_owned()) } + quote! { return Ok(__cached_result.to_owned()) } }; (set_cache_block, return_cache_block) } (false, true) => { let set_cache_block = quote! { - if let Some(result) = &result { - cache.set(key, result.clone()); + if let Some(__cached_inner) = &__cached_result { + __cached_cache.cache_set(__cached_key, __cached_inner.clone()); } }; let return_cache_block = if args.with_cached_flag { - quote! { let mut r = result.to_owned(); r.was_cached = true; return Some(r) } + quote! { let mut __cached_r = __cached_result.to_owned(); __cached_r.was_cached = true; return Some(__cached_r) } } else { - quote! { return Some(result.clone()) } + quote! { return Some(__cached_result.clone()) } }; (set_cache_block, return_cache_block) } @@ -500,7 +716,12 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { return error.to_compile_error().into(); } - if args.result_fallback && args.sync_writes != SyncWriteMode::Disabled { + // `result_fallback` is only mutually exclusive with EXPLICITLY set non-Disabled + // `sync_writes`. When `sync_writes` was not specified (unspecified, defaulting to + // `ByKey`), `result_fallback` implicitly selects `Disabled` instead (per spec). + // This lets `#[cached(result_fallback = true, ttl_secs = N)]` compile without + // needing the user to also write `sync_writes = false`. + if args.result_fallback && sync_writes_explicit && sync_writes != SyncWriteMode::Disabled { return syn::Error::new( fn_ident.span(), "`result_fallback` and `sync_writes` are mutually exclusive", @@ -509,6 +730,16 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { .into(); } + // When `result_fallback` is set and `sync_writes` was not explicitly specified, + // override the default-ByKey to Disabled (result_fallback and ByKey are also + // mutually exclusive, but we silently resolve the conflict for the unspecified case + // rather than erroring). + let sync_writes = if args.result_fallback && !sync_writes_explicit { + SyncWriteMode::Disabled + } else { + sync_writes + }; + if args.result_fallback && !is_result_return { return syn::Error::new( fn_ident.span(), @@ -518,13 +749,13 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { .into(); } - if args.result_fallback && args.ty.is_none() && args.ttl.is_none() && !args.expires { + if args.result_fallback && args.ty.is_none() && !has_ttl && !args.expires { return syn::Error::new( fn_ident.span(), "`result_fallback` requires a store that implements `CloneCached`. \ The default `UnboundCache` and `LruCache` (size without ttl) do not implement it. \ - Use `ttl` (for `TtlCache`), `max_size` + `ttl` (for `LruTtlCache`), \ - `expires` (for `ExpiringCache`/`ExpiringLruCache`), or a custom `ty`.", + Use `ttl`/`ttl_secs`/`ttl_millis` (for `TtlCache`), `max_size` + a TTL \ + (for `LruTtlCache`), `expires` (for `ExpiringCache`/`ExpiringLruCache`), or a custom `ty`.", ) .to_compile_error() .into(); @@ -539,7 +770,7 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { .into(); } - if args.unsync_reads && args.ty.is_none() && (args.max_size.is_some() || args.ttl.is_some()) { + if args.unsync_reads && args.ty.is_none() && (args.max_size.is_some() || has_ttl) { return syn::Error::new( fn_ident.span(), "`unsync_reads` requires a store that implements `CachedRead` (no mutation on reads). \ @@ -554,7 +785,7 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { let set_cache_and_return = quote! { #set_cache_block - result + __cached_result }; let use_rwlock = match args.sync_lock { @@ -565,15 +796,15 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { let lock_type = if use_rwlock { if asyncness.is_some() { - quote! { ::cached::async_sync::RwLock } + quote! { #krate::async_sync::RwLock } } else { - quote! { ::cached::sync_sync::RwLock } + quote! { #krate::sync_sync::RwLock } } } else { if asyncness.is_some() { - quote! { ::cached::async_sync::Mutex } + quote! { #krate::async_sync::Mutex } } else { - quote! { ::cached::sync_sync::Mutex } + quote! { #krate::sync_sync::Mutex } } }; @@ -595,6 +826,15 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { let no_cache_fn_ident = Ident::new(&format!("{}_no_cache", &fn_ident), fn_ident.span()); + // When the cached fn is a method (`in_impl`), the origin/no-cache fn is also + // a method on the same impl, so it must be invoked as `self.NAME_no_cache(...)` + // rather than the free-fn `NAME_no_cache(...)` (#16/#140). + let self_prefix = if has_receiver { + quote! { self. } + } else { + quote! {} + }; + // Build the origin ("no cache") function by cloning the full original // signature and renaming it. Quoting the whole `syn::Signature` (rather // than rebuilding it as `#generics (#inputs) #output`) preserves the @@ -606,23 +846,27 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { let function_no_cache; let function_call; - let ty; + // Build the cache static with a caller-supplied leading visibility token. The + // module-scope static keeps the method's `#visibility`, but the `in_impl` + // function-local static is emitted bare (no visibility): a visibility on a + // function-local item is meaningless and trips `unreachable_pub` (#7). + let make_static = |vis: &proc_macro2::TokenStream| match sync_writes { + SyncWriteMode::ByKey => quote! { + #vis static #cache_ident: ::std::sync::LazyLock<(#lock_type<#cache_ty>, Vec>>)> = ::std::sync::LazyLock::new(|| (#lock_type::new(#cache_create), (0..#sync_writes_buckets).map(|_| std::sync::Arc::new(#lock_type::new(()))).collect())); + }, + _ => quote! { + #vis static #cache_ident: ::std::sync::LazyLock<#lock_type<#cache_ty>> = ::std::sync::LazyLock::new(|| #lock_type::new(#cache_create)); + }, + }; + let module_ty = make_static("e! { #visibility }); + let body_ty = make_static("e! {}); if asyncness.is_some() { function_no_cache = quote! { #no_cache_sig #body }; function_call = quote! { - let result = #no_cache_fn_ident(#(#input_names),*).await; - }; - - ty = match args.sync_writes { - SyncWriteMode::ByKey => quote! { - #visibility static #cache_ident: ::std::sync::LazyLock<(#lock_type<#cache_ty>, Vec>>)> = ::std::sync::LazyLock::new(|| (#lock_type::new(#cache_create), (0..#sync_writes_buckets).map(|_| std::sync::Arc::new(#lock_type::new(()))).collect())); - }, - _ => quote! { - #visibility static #cache_ident: ::std::sync::LazyLock<#lock_type<#cache_ty>> = ::std::sync::LazyLock::new(|| #lock_type::new(#cache_create)); - }, + let __cached_result = #self_prefix #no_cache_fn_ident(#(#input_names),*).await; }; } else { function_no_cache = quote! { @@ -630,117 +874,165 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { }; function_call = quote! { - let result = #no_cache_fn_ident(#(#input_names),*); - }; - - ty = match args.sync_writes { - SyncWriteMode::ByKey => quote! { - #visibility static #cache_ident: ::std::sync::LazyLock<(#lock_type<#cache_ty>, Vec>>)> = ::std::sync::LazyLock::new(|| (#lock_type::new(#cache_create), (0..#sync_writes_buckets).map(|_| std::sync::Arc::new(#lock_type::new(()))).collect())); - }, - _ => quote! { - #visibility static #cache_ident: ::std::sync::LazyLock<#lock_type<#cache_ty>> = ::std::sync::LazyLock::new(|| #lock_type::new(#cache_create)); - }, + let __cached_result = #self_prefix #no_cache_fn_ident(#(#input_names),*); }; } + // `force_refresh`: an opt-in boolean expression block over the fn args, + // written in curly braces like `convert` (e.g. `force_refresh = "{ id == 0 }"`). + // When it evaluates `true`, the cached-hit early return is skipped so the body + // re-runs and re-caches. `if !(block)` guards each hit return; with no + // `force_refresh` the guard is `if true` (always take the cached value). + // This is orthogonal to `refresh` (TTL renewal on hit) (#146). + let force_refresh_guard = match build_force_refresh_guard(&args.force_refresh, fn_ident.span()) + { + Ok(guard) => guard, + Err(error) => return error.to_compile_error().into(), + }; + let (lock, do_set_return_block) = { - let lock = match args.sync_writes { + let lock = match sync_writes { SyncWriteMode::ByKey => { let key_lock_block = by_key_lock_block( - quote! { key }, - quote! { locks }, + quote! { __cached_key }, + quote! { __cached_locks }, lock_method.clone(), await_if_async.clone(), ); quote! { - let (cache_mutex, locks) = &*#cache_ident; + let (__cached_cache_mutex, __cached_locks) = &*#cache_ident; #key_lock_block - let mut cache = cache_mutex.#lock_method()#await_if_async; + let mut __cached_cache = __cached_cache_mutex.#lock_method()#await_if_async; } } _ => quote! { - let mut cache = #cache_ident.#lock_method()#await_if_async; + let mut __cached_cache = #cache_ident.#lock_method()#await_if_async; }, }; + // The `#force_refresh_guard` wraps the whole lookup (not just the + // early-return) so the `cache_get`/`cache_get_read` call is skipped when + // force-refreshing. On a `refresh_on_hit` TTL store, `cache_get` renews + // the entry's TTL as a side effect, which must not happen for a bypassed + // entry (#146). Locking stays outside the guard (it does not renew TTL). let cache_get_return_block = if args.unsync_reads { quote! { - let cache = #cache_ident.#read_lock_method()#await_if_async; - if let Some(result) = ::cached::CachedRead::cache_get_read(&*cache, &key) { - #return_cache_block + let __cached_cache = #cache_ident.#read_lock_method()#await_if_async; + #force_refresh_guard { + if let Some(__cached_result) = #krate::CachedRead::cache_get_read(&*__cached_cache, &__cached_key) { + #return_cache_block + } } } } else { quote! { - let mut cache = #cache_ident.#lock_method()#await_if_async; - if let Some(result) = cache.cache_get(&key) { - #return_cache_block - } - } - }; - - let default_unsync_cache_get_return_block = quote! { - let cache = #cache_ident.#read_lock_method()#await_if_async; - if ::cached::CachedPeek::cache_peek(&*cache, &key).is_some() { - if let Some(result) = ::cached::CachedRead::cache_get_read(&*cache, &key) { - #return_cache_block + let mut __cached_cache = #cache_ident.#lock_method()#await_if_async; + #force_refresh_guard { + if let Some(__cached_result) = __cached_cache.cache_get(&__cached_key) { + #return_cache_block + } } } }; let by_key_cache_get_return_block = if args.unsync_reads { quote! { - let cache = cache_mutex.#read_lock_method()#await_if_async; - if let Some(result) = ::cached::CachedRead::cache_get_read(&*cache, &key) { - #return_cache_block + let __cached_cache = __cached_cache_mutex.#read_lock_method()#await_if_async; + #force_refresh_guard { + if let Some(__cached_result) = #krate::CachedRead::cache_get_read(&*__cached_cache, &__cached_key) { + #return_cache_block + } } } } else { quote! { - let mut cache = cache_mutex.#lock_method()#await_if_async; - if let Some(result) = cache.cache_get(&key) { - #return_cache_block + let mut __cached_cache = __cached_cache_mutex.#lock_method()#await_if_async; + #force_refresh_guard { + if let Some(__cached_result) = __cached_cache.cache_get(&__cached_key) { + #return_cache_block + } } } }; - let do_set_return_block = match args.sync_writes { + let do_set_return_block = match sync_writes { SyncWriteMode::ByKey => { let key_lock_block = by_key_lock_block( - quote! { key }, - quote! { locks }, + quote! { __cached_key }, + quote! { __cached_locks }, lock_method.clone(), await_if_async.clone(), ); quote! { - let (cache_mutex, locks) = &*#cache_ident; + let (__cached_cache_mutex, __cached_locks) = &*#cache_ident; #key_lock_block { #by_key_cache_get_return_block } #function_call - let mut cache = cache_mutex.#lock_method()#await_if_async; + let mut __cached_cache = __cached_cache_mutex.#lock_method()#await_if_async; #set_cache_and_return } } SyncWriteMode::Default => { if args.unsync_reads { + // When `force_refresh` IS set, hoist its predicate into a single + // boolean binding so it is evaluated AT MOST ONCE per call. Without + // this, the predicate would be expanded inside the optimistic + // read-lock block AND again in the write-lock re-check below, + // double-evaluating any side-effects in the user's predicate block. + // + // `#force_refresh_guard { false } else { true }` is + // `if !(block) { false } else { true }` == `block`, so the binding + // holds the user's predicate value. + // + // When `force_refresh` is absent, emit NEITHER the binding nor a read + // of it: the two read sites below fall back to `#force_refresh_guard`, + // which is `if true` with no `force_refresh`, so the cached value is + // always taken (equivalent to `if !__cached_force_refreshing` when the + // flag would be `false`). This avoids emitting a constant + // `if true { false } else { true }` binding (a needless-bool smell). + let (force_refreshing_flag, read_guard) = if args.force_refresh.is_some() { + ( + quote! { + let __cached_force_refreshing = #force_refresh_guard { false } else { true }; + }, + quote! { if !__cached_force_refreshing }, + ) + } else { + (quote! {}, force_refresh_guard.clone()) + }; + let unsync_read_block = quote! { + let __cached_cache = #cache_ident.#read_lock_method()#await_if_async; + #read_guard { + if #krate::CachedPeek::cache_peek(&*__cached_cache, &__cached_key).is_some() { + if let Some(__cached_result) = #krate::CachedRead::cache_get_read(&*__cached_cache, &__cached_key) { + #return_cache_block + } + } + } + }; quote! { + #force_refreshing_flag { - #default_unsync_cache_get_return_block + #unsync_read_block } - let mut cache = #cache_ident.#lock_method()#await_if_async; - if let Some(result) = cache.cache_get(&key) { - #return_cache_block + let mut __cached_cache = #cache_ident.#lock_method()#await_if_async; + #read_guard { + if let Some(__cached_result) = __cached_cache.cache_get(&__cached_key) { + #return_cache_block + } } #function_call #set_cache_and_return } } else { quote! { - let mut cache = #cache_ident.#lock_method()#await_if_async; - if let Some(result) = cache.cache_get(&key) { - #return_cache_block + let mut __cached_cache = #cache_ident.#lock_method()#await_if_async; + #force_refresh_guard { + if let Some(__cached_result) = __cached_cache.cache_get(&__cached_key) { + #return_cache_block + } } #function_call #set_cache_and_return @@ -749,22 +1041,70 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { } SyncWriteMode::Disabled => { if args.result_fallback { - quote! { - let old_val = { - let mut cache = #cache_ident.#lock_method()#await_if_async; - let (result, has_expired) = cache.cache_get_with_expiry_status(&key); - if let (Some(result), false) = (&result, has_expired) { - #return_cache_block + // Capture the prior `Ok` value to fall back to when the refresh + // returns `Err`. The renewing `cache_get_with_expiry_status` + // (LRU promotion, hit-count, possible TTL renewal on `refresh`) + // serves the genuine early-return on a fresh hit. When + // `force_refresh` bypasses the entry, that renewing read must NOT + // run (#146): a bypassed entry must have no read side effects. + // `#force_refresh_guard` is `if !(block)` (taken when NOT + // bypassing); on the bypass path capture the fallback value with a + // non-renewing `cache_peek_with_expiry_status` (no promote/hit-count/ + // TTL-renew), which also returns expired entries (unlike `cache_peek` + // which returns `None` for expired entries, losing the stale fallback). + // With no `force_refresh` the guard is `if true`, so the peek arm is + // dead and behavior is unchanged. + let capture_old_val = if args.force_refresh.is_some() { + quote! { + if __cached_force_refreshing { + // Bypassed: peek without renewing/promoting/hit-counting. + // Also captures expired entries so an Err recompute over + // an expired entry still returns the stale Ok fallback. + let (__cached_peek_val, _) = #krate::CloneCached::cache_peek_with_expiry_status(&*__cached_cache, &__cached_key); + __cached_old_val = __cached_peek_val; + } else { + let (__cached_result, __cached_has_expired) = __cached_cache.cache_get_with_expiry_status(&__cached_key); + if let (Some(__cached_result), false) = (&__cached_result, __cached_has_expired) { + // Not bypassing (guard always taken here), so the + // early-return is unconditional on a fresh hit. + #return_cache_block + } + __cached_old_val = __cached_result; + } + } + } else { + quote! { + let (__cached_result, __cached_has_expired) = __cached_cache.cache_get_with_expiry_status(&__cached_key); + if let (Some(__cached_result), false) = (&__cached_result, __cached_has_expired) { + #force_refresh_guard { + #return_cache_block + } } - result + __cached_old_val = __cached_result; + } + }; + // Evaluate the `force_refresh` predicate once: `#force_refresh_guard` + // is `if !(block)`, so `if !(block) { false } else { true }` == `block`. + let force_refreshing_flag = if args.force_refresh.is_some() { + quote! { let __cached_force_refreshing = #force_refresh_guard { false } else { true }; } + } else { + quote! {} + }; + quote! { + #force_refreshing_flag + let __cached_old_val = { + let mut __cached_old_val = None; + let mut __cached_cache = #cache_ident.#lock_method()#await_if_async; + #capture_old_val + __cached_old_val }; #function_call - let mut cache = #cache_ident.#lock_method()#await_if_async; - let result = match (result.is_err(), old_val) { - (true, Some(old_val)) => { - Ok(old_val) + let mut __cached_cache = #cache_ident.#lock_method()#await_if_async; + let __cached_result = match (__cached_result.is_err(), __cached_old_val) { + (true, Some(__cached_old_val)) => { + Ok(__cached_old_val) } - _ => result + _ => __cached_result }; #set_cache_and_return } @@ -774,7 +1114,7 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { #cache_get_return_block } #function_call - let mut cache = #cache_ident.#lock_method()#await_if_async; + let mut __cached_cache = #cache_ident.#lock_method()#await_if_async; #set_cache_and_return } } @@ -794,10 +1134,15 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { let cache_ident_doc = format!("Cached static for the [`{}`] function.", fn_ident); let no_cache_fn_indent_doc = format!("Origin of the cached function [`{}`].", fn_ident); let prime_fn_indent_doc = format!("Primes the cached function [`{}`].", fn_ident); - let cache_fn_doc_extra = format!( - "This is a cached function that uses the [`{}`] cached static.", - cache_ident - ); + let cache_fn_doc_extra = if args.in_impl { + "This is a cached method; its cache static is function-local to the method body." + .to_string() + } else { + format!( + "This is a cached function that uses the [`{}`] cached static.", + cache_ident + ) + }; fill_in_attributes(&mut attributes, cache_fn_doc_extra); let prime_do_set_return_block = quote! { @@ -808,31 +1153,80 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { #set_cache_and_return }; + // When `in_impl`, the cache static cannot sit at impl scope (a `static` is + // not a valid impl item), so it is emitted inside each generated fn body - + // which is also valid Rust now (item-in-fn). This additionally fixes static + // collisions between same-named methods on different types (#16/#140). + // Off the `in_impl` path the static is emitted once at module scope. + let (module_static, body_static) = if args.in_impl { + // No `#[doc]`: a function-local static is not part of the public API and + // rustdoc ignores doc attributes on it, so the doc string would be dead. + // The function-local static is emitted bare (no visibility) - a meaningless + // visibility on a function-local item trips `unreachable_pub` (#7). + (quote! {}, quote! { #body_ty }) + } else { + ( + quote! { + #[doc = #cache_ident_doc] + #module_ty + }, + quote! {}, + ) + }; + + // The cache static is function-local when `in_impl = true`, so the cached + // method and a `{fn}_prime_cache` sibling would each get a distinct + // function-local static - priming would populate a static the cached method + // never reads (a silent no-op). A function-local static cannot be shared + // between two sibling methods, so a correct prime is impossible under + // `in_impl`; do not emit the companion at all. Calling a non-existent prime + // fn is then a clear compile error instead of a silent no-op (#16/#140). + let prime_fn = if args.in_impl { + quote! {} + } else { + quote! { + // Prime cached function. Priming is optional, so suppress + // `dead_code` for callers that generate but never call the companion. + #[doc = #prime_fn_indent_doc] + #[allow(dead_code)] + #(#attributes)* + #companions_visibility #prime_sig { + #body_static + use #krate::Cached; + let __cached_key = #key_convert_block; + #prime_do_set_return_block + } + } + }; + + // On the `in_impl` path the `{fn}_no_cache` origin is a public impl method, so + // it would otherwise surface in consumers' rustdoc as unintended API. Hide it + // with `#[doc(hidden)]` (it stays callable as an escape hatch). Off `in_impl` + // it is a free fn that keeps its descriptive origin doc. + let no_cache_fn_doc = if args.in_impl { + quote! { #[doc(hidden)] } + } else { + quote! { #[doc = #no_cache_fn_indent_doc] } + }; + // put it all together let expanded = quote! { - // Cached static - #[doc = #cache_ident_doc] - #ty + // Cached static (module scope unless `in_impl`) + #module_static // No cache function (origin of the cached function) - #[doc = #no_cache_fn_indent_doc] - #visibility #function_no_cache + #no_cache_fn_doc + #companions_visibility #function_no_cache // Cached function #(#attributes)* #visibility #signature_no_muts { - use cached::Cached; - use cached::CloneCached; - let key = #key_convert_block; + #body_static + use #krate::Cached; + use #krate::CloneCached; + let __cached_key = #key_convert_block; #do_set_return_block } - // Prime cached function - #[doc = #prime_fn_indent_doc] - #[allow(dead_code)] - #(#attributes)* - #visibility #prime_sig { - use cached::Cached; - let key = #key_convert_block; - #prime_do_set_return_block - } + // Prime cached function (omitted for `in_impl` methods) + #prime_fn }; expanded.into() diff --git a/cached_proc_macro/src/concurrent_cached.rs b/cached_proc_macro/src/concurrent_cached.rs index 332963e7..de96ee30 100644 --- a/cached_proc_macro/src/concurrent_cached.rs +++ b/cached_proc_macro/src/concurrent_cached.rs @@ -4,15 +4,12 @@ use darling::ast::NestedMeta; use proc_macro::TokenStream; use quote::quote; use syn::spanned::Spanned; -use syn::{ - Block, ExprClosure, GenericArgument, Ident, ItemFn, ReturnType, Type, parse_macro_input, - parse_str, -}; +use syn::{GenericArgument, Ident, ItemFn, ReturnType, Type, parse_macro_input, parse_str}; #[derive(FromMeta)] struct ConcurrentCachedArgs { #[darling(default)] - map_error: Option, + map_error: Option, #[darling(default)] disk: bool, #[darling(default)] @@ -20,11 +17,23 @@ struct ConcurrentCachedArgs { #[darling(default)] redis: bool, #[darling(default)] - cache_prefix_block: Option, + cache_prefix_block: Option, #[darling(default)] name: Option, + /// A TTL expressed as a `Duration` expression in a string literal (same + /// convention as `create`/`convert`), e.g. + /// `ttl = "core::time::Duration::from_secs(60)"`. Mutually exclusive with + /// `ttl_secs`, `ttl_millis`, and `expires`. #[darling(default)] - ttl: Option, + ttl: Option, + /// TTL in whole seconds. Convenience alternative to `ttl`. Mutually + /// exclusive with `ttl`, `ttl_millis`, and `expires`. + #[darling(default)] + ttl_secs: Option, + /// TTL in milliseconds. A finer-grained alternative to `ttl_secs`; + /// mutually exclusive with `ttl`, `ttl_secs`, and `expires` (#149). + #[darling(default)] + ttl_millis: Option, #[darling(default)] time: Option, #[darling(default)] @@ -33,11 +42,11 @@ struct ConcurrentCachedArgs { #[darling(default)] size: Option, #[darling(default)] - refresh: Option, + refresh: bool, #[darling(default)] key: Option, #[darling(default)] - convert: Option, + convert: Option, #[darling(default)] with_cached_flag: bool, #[darling(default)] @@ -45,7 +54,7 @@ struct ConcurrentCachedArgs { #[darling(default)] cache_none: bool, /// When `true`, an `Err` return serves the last cached `Ok` value for that key. - /// Requires `ttl`. The stale value is read from the primary TTL cache slot via + /// Requires `ttl`, `ttl_secs`, or `ttl_millis`. The stale value is read from the primary TTL cache slot via /// `ConcurrentCloneCached::cache_get_with_expiry_status` (no separate store is /// created) and re-cached with a fresh TTL window on `Err`. #[darling(default)] @@ -53,7 +62,7 @@ struct ConcurrentCachedArgs { #[darling(default)] ty: Option, #[darling(default)] - create: Option, + create: Option, #[darling(default)] durable: Option, /// Total LRU capacity for the default in-memory sharded store. @@ -67,12 +76,27 @@ struct ConcurrentCachedArgs { shards: Option, #[darling(default)] expires: bool, + /// A boolean expression over the function arguments; when `true`, the cached + /// value is bypassed and the body is re-run and re-cached. Orthogonal to + /// `refresh`. Both unquoted `{ expr }` and legacy quoted `"{ expr }"` forms + /// are accepted (#146). + #[darling(default)] + force_refresh: Option, + /// Override the visibility of the companion fns (`{fn}_no_cache`, + /// `{fn}_prime_cache`). `None` (default) inherits the cached fn's visibility. + #[darling(default)] + companions_vis: Option, + /// Allow the macro on a method that takes `self` inside an `impl` block. + /// The cache static is emitted inside the generated fn body and the receiver + /// is preserved/forwarded (#16/#140). + #[darling(default)] + in_impl: bool, } /// When a `create` block is supplied the user fully constructs the store, so /// every store-builder attribute the macro would otherwise apply is dropped. /// Reject those attributes with a precise message instead of silently ignoring -/// them — otherwise `disk_dir` / `durable` (and `ttl` / +/// them - otherwise `disk_dir` / `durable` (and `ttl` / /// `refresh` / `cache_prefix_block`) look applied but are not. fn check_create_conflicts( args: &ConcurrentCachedArgs, @@ -82,7 +106,13 @@ fn check_create_conflicts( if args.ttl.is_some() { conflicting.push("ttl"); } - if args.refresh.is_some() { + if args.ttl_secs.is_some() { + conflicting.push("ttl_secs"); + } + if args.ttl_millis.is_some() { + conflicting.push("ttl_millis"); + } + if args.refresh { conflicting.push("refresh"); } if args.cache_prefix_block.is_some() { @@ -111,7 +141,7 @@ fn check_create_conflicts( Err(syn::Error::new( span, format!( - "cannot specify {list} when passing a `create` block — `create` fully \ + "cannot specify {list} when passing a `create` block - `create` fully \ constructs the store, so these store-builder attributes would be \ silently ignored" ), @@ -141,9 +171,14 @@ fn reject_cached_only_attrs(attr_args: &[NestedMeta]) -> Result<(), syn::Error> Use `cache_none = true` to force caching `None` values.", ), "sync_writes" => Some( - "`sync_writes` is not supported by #[concurrent_cached]; concurrent stores \ + "`sync_writes` is not supported on `#[concurrent_cached]`; concurrent stores \ synchronize cache access internally but do not deduplicate first-call execution", ), + "sync_writes_buckets" => { + Some("`sync_writes_buckets` is not supported on `#[concurrent_cached]`") + } + "sync_lock" => Some("`sync_lock` is not supported on `#[concurrent_cached]`"), + "unsync_reads" => Some("`unsync_reads` is not supported on `#[concurrent_cached]`"), _ => None, }; if let Some(message) = message { @@ -177,19 +212,66 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { let signature = input.sig; let body = input.block; + // Resolve the path to the `cached` crate (renamed-dependency support, #157). + let krate = crate_path(); + // pull out the parts of the function signature let fn_ident = signature.ident.clone(); let inputs = signature.inputs.clone(); let output = signature.output.clone(); let asyncness = signature.asyncness; - - if inputs + let has_receiver = inputs .iter() - .any(|input| matches!(input, syn::FnArg::Receiver(_))) + .any(|input| matches!(input, syn::FnArg::Receiver(_))); + + // Reject `self` methods unless `in_impl = true`. A `self` receiver only + // exists inside an `impl`/trait, and off the `in_impl` path the cache static + // is emitted at that same scope, where a `static` is not a valid item - so a + // `convert` block alone cannot rescue a `self` method (it would still fail + // later with an opaque error). `in_impl` is the only fix (#16/#140). + if has_receiver && !args.in_impl { + return syn::Error::new( + fn_ident.span(), + "#[concurrent_cached] cannot be applied to methods that take `self`. \ + Set `in_impl = true` to cache the method inside its `impl` block \ + (a `convert` block alone is not sufficient: the generated cache \ + static cannot live at `impl` scope).", + ) + .to_compile_error() + .into(); + } + + // The inverse: `in_impl = true` on a function with no `self` receiver + // mis-compiles, because the generated `{fn}_no_cache(args)` call inside the + // impl cannot resolve without a `Self::` qualifier (a confusing "cannot find + // function" error downstream). Reject it here with a clear message. + if args.in_impl && !has_receiver { + return syn::Error::new( + fn_ident.span(), + "in_impl = true requires a method with a `self` receiver; \ + for a free function or an associated function without `self`, \ + remove in_impl.", + ) + .to_compile_error() + .into(); + } + + // Generic functions need the cache key pinned to a concrete type via + // `key` + `convert` (and a concrete store `ty`/`create`): the cache is a + // single monomorphic static and cannot name the function's type parameters. + // Without `convert` the default-key path embeds the type parameters in the + // key type and cannot compile - reject it with a clear diagnostic (#80). + if (signature.generics.type_params().next().is_some() + || signature.generics.const_params().next().is_some()) + && args.convert.is_none() { return syn::Error::new( fn_ident.span(), - "#[concurrent_cached] cannot be applied to methods that take `self`", + "#[concurrent_cached] on a generic function requires `key` + `convert` to pin the cache \ + key to a concrete type: the cache is a single monomorphic static shared across all \ + instantiations and cannot name the function's type parameters. \ + Provide `key`/`convert` (and a concrete `ty`/`create`), or wrap the generic function \ + in a non-generic `#[concurrent_cached]` function per concrete type.", ) .to_compile_error() .into(); @@ -198,7 +280,8 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { if args.time.is_some() { return syn::Error::new( fn_ident.span(), - "`time` was renamed to `ttl` in cached 1.0; use `ttl = ...`", + "`time` (whole seconds) was renamed in cached 1.0; use `ttl_secs = ...` \ + (or `ttl = \"Duration::from_secs(...)\"` / `ttl_millis = ...`)", ) .to_compile_error() .into(); @@ -222,19 +305,57 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { .into(); } + // Run the `expires`-vs-ttl mutual-exclusion checks BEFORE resolving the TTL + // `Duration`. These need only presence (`is_some()`), not a parsed value, and + // surfacing "mutually exclusive" is more relevant than a `ttl` parse error + // when `expires` is also set. + if args.expires && args.ttl_secs.is_some() { + return syn::Error::new( + fn_ident.span(), + "`expires` and `ttl_secs` are mutually exclusive - \ + `expires` delegates expiry to the value via the `Expires` trait", + ) + .to_compile_error() + .into(); + } + if args.expires && args.ttl_millis.is_some() { + return syn::Error::new( + fn_ident.span(), + "`expires` and `ttl_millis` are mutually exclusive - \ + `expires` delegates expiry to the value via the `Expires` trait; \ + `ttl_millis` applies a uniform millisecond TTL to all entries", + ) + .to_compile_error() + .into(); + } + if args.expires && args.ttl.is_some() { + return syn::Error::new( + fn_ident.span(), + "`expires` and `ttl` are mutually exclusive - `expires` delegates expiry to the value via the `Expires` trait", + ) + .to_compile_error() + .into(); + } + // Resolve the TTL `Duration` token from whichever of `ttl` (expr), `ttl_secs`, + // or `ttl_millis` is set. This performs the 3-way mutual-exclusion check, the + // `ttl_secs`/`ttl_millis` >= 1 validation, and parses the `ttl` expression. + // (A zero `ttl_secs`/`ttl_millis` is rejected here, before any store path runs.) + let (has_ttl, ttl_duration) = match resolve_ttl_duration( + &krate, + &args.ttl, + args.ttl_secs, + args.ttl_millis, + fn_ident.span(), + ) { + Ok(v) => v, + Err(e) => return e.to_compile_error().into(), + }; + if args.expires { - if args.ttl.is_some() { - return syn::Error::new( - fn_ident.span(), - "`expires` and `ttl` are mutually exclusive — `expires` delegates expiry to the value via the `Expires` trait", - ) - .to_compile_error() - .into(); - } if args.redis { return syn::Error::new( fn_ident.span(), - "`expires` and `redis` are mutually exclusive — `expires` selects sharded in-memory expiring stores", + "`expires` and `redis` are mutually exclusive - `expires` selects sharded in-memory expiring stores", ) .to_compile_error() .into(); @@ -242,7 +363,7 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { if args.disk { return syn::Error::new( fn_ident.span(), - "`expires` and `disk` are mutually exclusive — `expires` selects sharded in-memory expiring stores", + "`expires` and `disk` are mutually exclusive - `expires` selects sharded in-memory expiring stores", ) .to_compile_error() .into(); @@ -250,7 +371,7 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { if args.ty.is_some() { return syn::Error::new( fn_ident.span(), - "`expires` and `ty` are mutually exclusive — `expires` generates the store type automatically", + "`expires` and `ty` are mutually exclusive - `expires` generates the store type automatically", ) .to_compile_error() .into(); @@ -258,15 +379,15 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { if args.create.is_some() { return syn::Error::new( fn_ident.span(), - "`expires` and `create` are mutually exclusive — `expires` generates the store constructor automatically", + "`expires` and `create` are mutually exclusive - `expires` generates the store constructor automatically", ) .to_compile_error() .into(); } - if args.refresh.is_some() { + if args.refresh { return syn::Error::new( fn_ident.span(), - "`expires` and `refresh` are mutually exclusive — `expires` delegates expiry to the value via `Expires::is_expired`", + "`expires` and `refresh` are mutually exclusive - `expires` delegates expiry to the value via `Expires::is_expired`", ) .to_compile_error() .into(); @@ -274,7 +395,7 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { if args.cache_none { return syn::Error::new( fn_ident.span(), - "`expires = true` and `cache_none = true` are incompatible — `expires` requires \ + "`expires = true` and `cache_none = true` are incompatible - `expires` requires \ the cache value type to implement `Expires`, but `cache_none = true` stores \ `Option` as the value, which does not implement `Expires`. \ Remove `cache_none = true` (None values are not cached by default with `expires = true`).", @@ -285,11 +406,11 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { if args.result_fallback { return syn::Error::new( fn_ident.span(), - "`result_fallback = true` and `expires = true` are mutually exclusive — \ + "`result_fallback = true` and `expires = true` are mutually exclusive - \ `expires` selects a per-value expiry store; `result_fallback` requires \ a fixed-TTL store whose entry expiry can be detected and refreshed by \ the cache layer, which per-value expiry does not support. \ - Note: `ttl` and `expires` serve different purposes — `ttl` applies a fixed \ + Note: `ttl` and `expires` serve different purposes - `ttl` applies a fixed \ TTL to all entries, while `expires` delegates expiry to each value. \ If you need time-based expiry together with `result_fallback`, use `ttl` \ (not `expires`).", @@ -300,7 +421,7 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { if args.cache_err { return syn::Error::new( fn_ident.span(), - "`expires = true` and `cache_err = true` are mutually exclusive — `expires` \ + "`expires = true` and `cache_err = true` are mutually exclusive - `expires` \ requires the cached value to implement `Expires`, but `cache_err = true` \ stores `Result` as the value type, which does not implement `Expires`. \ Remove `cache_err = true`.", @@ -328,9 +449,9 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { let is_option_return = is_option_return_type(&output); let is_result_return = is_result_return_type(&output); - // `is_smart_option`: skip None, cache Some(T) — default for Option returns. + // `is_smart_option`: skip None, cache Some(T) - default for Option returns. // Opt out with `cache_none = true` to force caching None as well. - // `is_smart_result`: cache only Ok(T), skip Err — always true for Result returns here; + // `is_smart_result`: cache only Ok(T), skip Err - always true for Result returns here; // opt out with `cache_err = true` to force caching Err values (in-memory default only). let is_smart_option = is_option_return && !args.cache_none; let is_smart_result = is_result_return && !args.cache_err; @@ -372,7 +493,7 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { fn_ident.span(), "`result_fallback` and `with_cached_flag` are mutually exclusive: \ `result_fallback` stores the inner `Ok(T)` value directly, but \ - `with_cached_flag` wraps the `Ok` value in `Return` — the generated \ + `with_cached_flag` wraps the `Ok` value in `Return` - the generated \ code cannot simultaneously store `T` and expose `Return` through \ the cached function. Use `with_cached_flag = true` alone (without \ `result_fallback`) or `result_fallback = true` alone.", @@ -453,14 +574,14 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { } // `Option>` without smart-option mode would fall into the plain-Return - // branch and generate `result.value.clone()` on an `Option>` — a confusing + // branch and generate `result.value.clone()` on an `Option>` - a confusing // compile error with a bad span. Catch it here with a clear diagnostic. if args.with_cached_flag && !is_smart_option && is_option_return { return syn::Error::new( output_span, "`with_cached_flag = true` and `cache_none = true` are structurally incompatible \ on `Option` returns: `with_cached_flag` unwraps `Return` and stores `T`, \ - while `cache_none = true` stores `Option` as the cached value — the same \ + while `cache_none = true` stores `Option` as the cached value - the same \ store cannot satisfy both. Remove one: use `with_cached_flag = true` alone to \ receive a `Return` that signals cache hits, or use `cache_none = true` alone \ (without `with_cached_flag`) to cache `None` values.", @@ -482,7 +603,7 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { unreachable!("is_smart_result=true implies ReturnType::Type") }; - // The `Ok` type of the function's `Result<…, E>`. + // The `Ok` type of the function's `Result<..., E>`. let ok_ty = match first_type_arg( &ty, output_span, @@ -496,7 +617,7 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { if args.with_cached_flag { // Descend one more level into `cached::Return` to recover `T`. // `check_with_cache_flag` above already verified the `Ok` type is - // structurally `Return<…>`; gating on `with_cached_flag` (rather + // structurally `Return<...>`; gating on `with_cached_flag` (rather // than a bare-name token scan) avoids misclassifying an unrelated // type merely named `Return` (e.g. `Result`). let GenericArgument::Type(return_ty) = ok_ty else { @@ -559,7 +680,14 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { // make the cache identifier let cache_ident = match args.name { - Some(ref name) => Ident::new(name, fn_ident.span()), + Some(ref name) => { + if syn::parse_str::(name).is_err() { + return syn::Error::new(fn_ident.span(), "`name` must be a valid Rust identifier") + .to_compile_error() + .into(); + } + Ident::new(name, fn_ident.span()) + } None => Ident::new(&fn_ident.to_string().to_uppercase(), fn_ident.span()), }; let cache_name = cache_ident.to_string(); @@ -570,8 +698,13 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { Err(error) => return error.to_compile_error().into(), }; + // `has_ttl` / `ttl_duration` were resolved above (from `ttl` expr, `ttl_secs`, + // or `ttl_millis`). `has_ttl` drives store selection and the `result_fallback` + // TTL-presence check (#149). `ttl_duration` is passed into the store-selector + // helpers so every backend (redis/disk/sharded) honors whichever unit was used. + // Track whether the cache uses Infallible errors (the in-memory sharded default). - // When true, `map_error` is a compile error and cache ops use `.expect(…)`. + // When true, `map_error` is a compile error and cache ops use `.expect(...)`. let mut infallible_default = false; // make the cache type and create statement @@ -595,6 +728,8 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { } match get_redis_cache_type_and_create( &args, + &krate, + ttl_duration.as_ref(), &cache_ident, &cache_key_ty, &cache_value_ty, @@ -623,6 +758,8 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { } match get_disk_cache_type_and_create( &args, + &krate, + ttl_duration.as_ref(), &cache_name, &cache_key_ty, &cache_value_ty, @@ -652,6 +789,8 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { } else { match get_sharded_cache_type_and_create( &args, + &krate, + ttl_duration.as_ref(), &cache_key_ty, &cache_value_ty, &fn_ident, @@ -717,13 +856,13 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { .into(); } - if args.result_fallback && args.ttl.is_none() { + if args.result_fallback && !has_ttl { return syn::Error::new( fn_ident.span(), - "`result_fallback` requires `ttl` to be set (e.g. `ttl = 60`). It serves the last \ + "`result_fallback` requires a TTL (`ttl`/`ttl_secs`/`ttl_millis`) to be set (e.g. `ttl_secs = 60`). It serves the last \ cached `Ok` value when a refresh returns `Err`, but a refresh only happens after an \ entry expires. Without a TTL entries never expire, so the function body is never \ - re-run for a cached key and the fallback can never fire — making the option a no-op. \ + re-run for a cached key and the fallback can never fire - making the option a no-op. \ Set a TTL so cached entries expire and `result_fallback` has something to fall back to.", ) .to_compile_error() @@ -732,16 +871,18 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { // Resolve the cache-error handling strategy. For the default sharded // in-memory stores the error type is `Infallible`, so cache operations can - // never fail and `.expect(…)` is always correct. `map_error` is rejected on - // this path — there are no errors to map. + // never fail and `.expect(...)` is always correct. `map_error` is rejected on + // this path - there are no errors to map. // - // For the fallible redis / disk / custom paths the user must supply - // `map_error = "…"` and we keep the original `.map_err(#map_error)?` pattern. - let map_error_opt: Option = match (&args.map_error, infallible_default) { + // For the fallible redis / disk / custom paths, if the user supplies + // `map_error = |e| ...`, we emit `.map_err(closure)?`. If `map_error` is + // absent on a fallible path, we emit `.map_err(::std::convert::Into::into)?`, + // which works when the function's error type implements `From`. + let map_error_closure: Option = match (&args.map_error, infallible_default) { (Some(_), true) => { return syn::Error::new( fn_ident.span(), - "`map_error` is not applicable to the default in-memory sharded stores — \ + "`map_error` is not applicable to the default in-memory sharded stores - \ their error type is `Infallible` and cache operations cannot fail. \ Remove `map_error`, or add `redis = true`, `disk = true`, or a custom \ `ty`/`create` to use a store with a fallible error type.", @@ -749,220 +890,298 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { .to_compile_error() .into(); } - (Some(src), false) => match parse_str::(src) { - Ok(map_error) => Some(map_error), - Err(error) => { - return syn::Error::new( - fn_ident.span(), - format!("unable to parse `map_error` closure: {error}"), + (Some(expr), false) => { + // Verify the expression is a closure; other expression types are not + // valid for `map_error`. + if !matches!(expr, syn::Expr::Closure(_)) { + return syn::Error::new_spanned( + expr, + "`map_error` must be a closure, e.g. `map_error = |e| MyErr(e)`", ) .to_compile_error() .into(); } - }, - (None, true) => None, // infallible: use .expect(…) in generated code - (None, false) => { - return syn::Error::new( - fn_ident.span(), - "#[concurrent_cached] requires `map_error = \"…\"` when the cache type \ - has a fallible error (redis/disk/custom)", - ) - .to_compile_error() - .into(); + Some(expr.clone()) } + (None, true) => None, // infallible: use .expect(...) in generated code + (None, false) => None, // fallible but no map_error: use Into::into }; - // Emit either `.map_err(closure)?` for fallible stores or `.expect(…)` for - // infallible stores. The macro helpers below use these token-stream fragments. - // - // `infallible_default` is true for the default in-memory sharded stores; their error - // type is `Infallible`, so the ops always succeed and `.expect(…)` is correct. - // `map_error` on this path is rejected above as a compile error. + // Emit either `.map_err(closure)?`, bare `?`, or `.expect(...)` + // for fallible stores or infallible stores. let (cache_get_unwrap, cache_set_unwrap): (proc_macro2::TokenStream, proc_macro2::TokenStream) = if infallible_default { // The store's error type is `Infallible`; these `.expect()`s are unreachable. let msg = "cache operation on the default in-memory sharded store is infallible"; (quote! { .expect(#msg) }, quote! { .expect(#msg) }) - } else { - let me = map_error_opt - .as_ref() - .expect("fallible path requires map_error (validated above)"); + } else if let Some(me) = &map_error_closure { (quote! { .map_err(#me)? }, quote! { .map_err(#me)? }) + } else { + // No explicit map_error on a fallible path: use `?` directly so that the + // standard `From` trait machinery handles the conversion. `?` on a + // `Result<_, StoreError>` in a function returning `Result<_, E>` calls + // `E::from(e)`, which requires only `E: From`. This avoids the + // type-inference ambiguity that `.map_err(Into::into)` can produce when the + // target error type has multiple `From` implementations. + (quote! { ? }, quote! { ? }) }; + + // Resolve companion fn visibility (#9). + let companions_visibility = match &args.companions_vis { + None => quote! { #visibility }, + Some(s) if s.is_empty() => quote! {}, + Some(s) => match parse_str::(s) { + Ok(vis) => quote! { #vis }, + Err(e) => { + return syn::Error::new( + fn_ident.span(), + format!( + "unable to parse `companions_vis` as a visibility: {e}; \ + expected a Rust visibility, e.g. `\"pub\"`, `\"pub(crate)\"`, or `\"\"`" + ), + ) + .to_compile_error() + .into(); + } + }, + }; // For `await`-ed variants we need identical logic. let cache_get_unwrap_async = cache_get_unwrap.clone(); let cache_set_unwrap_async = cache_set_unwrap.clone(); + // Emit a cache-set call. `value_ref` is an expression that already evaluates to + // a `&V`. The set goes through the `__set_dispatch` autoref shim: when the + // concrete store implements `SerializeCached`/`SerializeCachedAsync` (redis, disk, + // or any custom `ty`/`create` store that does) the borrowed setter is used and the + // value is serialized from the reference with no pre-set clone (#196); otherwise + // the shim clones the value and calls the owned `cache_set`. The key is moved in + // either way (no key clone), matching the previous owned path. The `use ... as _;` + // brings the fallback trait into scope so method resolution can reach it; the + // inherent (serialize) method is always preferred when it applies. + let set_call = |value_ref: proc_macro2::TokenStream| { + if asyncness.is_some() { + quote! { + { + use #krate::__set_dispatch_async::SetDispatchAsyncFallback as _; + #krate::__set_dispatch_async::SetDispatchAsync::new(__cached_cache) + .cache_set_dispatch(__cached_key, #value_ref).await #cache_set_unwrap_async; + } + } + } else { + quote! { + { + use #krate::__set_dispatch::SetDispatchFallback as _; + #krate::__set_dispatch::SetDispatch::new(__cached_cache) + .cache_set_dispatch(__cached_key, #value_ref) #cache_set_unwrap; + } + } + } + }; + // make the set cache and return cache blocks let (set_cache_block, return_cache_block) = if with_cached_flag_result { // Result, E>: cache the inner T from Ok(Return). + let set = set_call(quote! { &__cached_inner.value }); ( - if asyncness.is_some() { - quote! { - if let Ok(result) = &result { - ::cached::ConcurrentCachedAsync::async_cache_set(cache, key, result.value.clone()).await #cache_set_unwrap_async; - } - } - } else { - quote! { - if let Ok(result) = &result { - ::cached::ConcurrentCached::cache_set(cache, key, result.value.clone()) #cache_set_unwrap; - } + quote! { + if let Ok(__cached_inner) = &__cached_result { + #set } }, - quote! { let mut r = ::cached::Return::new(result); r.was_cached = true; return Ok(r) }, + quote! { let mut __cached_r = #krate::Return::new(__cached_result); __cached_r.was_cached = true; return Ok(__cached_r) }, ) } else if with_cached_flag_option { // Option>: cache the inner T from Some(Return), skip None. + let set = set_call(quote! { &__cached_inner.value }); ( - if asyncness.is_some() { - quote! { - if let Some(result) = &result { - ::cached::ConcurrentCachedAsync::async_cache_set(cache, key, result.value.clone()).await #cache_set_unwrap_async; - } - } - } else { - quote! { - if let Some(result) = &result { - ::cached::ConcurrentCached::cache_set(cache, key, result.value.clone()) #cache_set_unwrap; - } + quote! { + if let Some(__cached_inner) = &__cached_result { + #set } }, - quote! { let mut r = ::cached::Return::new(result); r.was_cached = true; return Some(r) }, + quote! { let mut __cached_r = #krate::Return::new(__cached_result); __cached_r.was_cached = true; return Some(__cached_r) }, ) } else if args.with_cached_flag { // Plain Return: cache the inner T directly. + let set = set_call(quote! { &__cached_result.value }); ( - if asyncness.is_some() { - quote! { - ::cached::ConcurrentCachedAsync::async_cache_set(cache, key, result.value.clone()).await #cache_set_unwrap_async; - } - } else { - quote! { - ::cached::ConcurrentCached::cache_set(cache, key, result.value.clone()) #cache_set_unwrap; - } - }, - quote! { let mut r = ::cached::Return::new(result); r.was_cached = true; return r }, + set, + quote! { let mut __cached_r = #krate::Return::new(__cached_result); __cached_r.was_cached = true; return __cached_r }, ) } else if is_smart_result { // Result return type: cache only Ok(T), skip Err + let set = set_call(quote! { __cached_inner }); ( - if asyncness.is_some() { - quote! { - if let Ok(result) = &result { - ::cached::ConcurrentCachedAsync::async_cache_set(cache, key, result.clone()).await #cache_set_unwrap_async; - } - } - } else { - quote! { - if let Ok(result) = &result { - ::cached::ConcurrentCached::cache_set(cache, key, result.clone()) #cache_set_unwrap; - } + quote! { + if let Ok(__cached_inner) = &__cached_result { + #set } }, - quote! { return Ok(result) }, + quote! { return Ok(__cached_result) }, ) } else if is_smart_option { // Option: cache Some(T), skip None. infallible_default guaranteed. + let set = set_call(quote! { __cached_inner }); ( - if asyncness.is_some() { - quote! { - if let Some(result) = &result { - ::cached::ConcurrentCachedAsync::async_cache_set(cache, key, result.clone()).await #cache_set_unwrap_async; - } - } - } else { - quote! { - if let Some(result) = &result { - ::cached::ConcurrentCached::cache_set(cache, key, result.clone()) #cache_set_unwrap; - } + quote! { + if let Some(__cached_inner) = &__cached_result { + #set } }, - quote! { return Some(result) }, + quote! { return Some(__cached_result) }, ) } else { - // Plain return type — infallible_default is guaranteed true here. + // Plain return type - infallible_default is guaranteed true here. // No Ok/Err wrapping: the result is the value directly. + let set = set_call(quote! { &__cached_result }); + (set, quote! { return __cached_result }) + }; + + // Clone the full original signature and rename it to `__cached_inner`. Quoting + // the whole `syn::Signature` preserves the `where` clause (and lifetimes, + // const generics, etc.) - `#generics` alone drops the where clause. + // Unique per-function name so multiple `in_impl` methods on the same impl + // block do not collide on a shared `__cached_inner` sibling method. + let inner_fn_ident = Ident::new(&format!("{}_no_cache", &fn_ident), fn_ident.span()); + let mut inner_sig = signature.clone(); + inner_sig.ident = inner_fn_ident.clone(); + + // For `in_impl` methods the body may reference `self`, so `__cached_inner` + // must be a sibling impl method (a nested fn cannot capture `self`) invoked + // as `self.__cached_inner(...)`. For free functions it stays a nested fn + // defined inline in the body (#16/#140). + let self_prefix = if has_receiver { + quote! { self. } + } else { + quote! {} + }; + // The `in_impl` origin sibling is a public impl method; hide it from consumers' + // rustdoc with `#[doc(hidden)]` (it stays callable as an escape hatch). + let (inner_sibling_def, inner_nested_def) = if args.in_impl { ( - if asyncness.is_some() { - quote! { - ::cached::ConcurrentCachedAsync::async_cache_set(cache, key, result.clone()).await #cache_set_unwrap_async; - } - } else { - quote! { - ::cached::ConcurrentCached::cache_set(cache, key, result.clone()) #cache_set_unwrap; - } - }, - quote! { return result }, + quote! { #[doc(hidden)] #companions_visibility #inner_sig #body }, + quote! {}, ) + } else { + (quote! {}, quote! { #inner_sig #body }) }; - // Clone the full original signature and rename it to `inner`. Quoting the - // whole `syn::Signature` preserves the `where` clause (and lifetimes, - // const generics, etc.) — `#generics` alone drops the where clause. - let mut inner_sig = signature.clone(); - inner_sig.ident = Ident::new("inner", fn_ident.span()); + // `force_refresh`: opt-in boolean expression block over the fn args, in curly + // braces like `convert` (e.g. `force_refresh = "{ id == 0 }"`); when `true`, + // skip the cached-hit early return so the body re-runs and re-caches. + // Orthogonal to `refresh` (TTL renewal on hit) (#146). + // Parse the `force_refresh` predicate once; both the cached-hit guard and the + // `result_fallback` bypass token below are built from this single parsed block. + let force_refresh_block = match parse_force_refresh_block(&args.force_refresh, fn_ident.span()) + { + Ok(block) => block, + Err(error) => return error.to_compile_error().into(), + }; + + let force_refresh_guard = match &force_refresh_block { + Some(block) => quote! { if !(#block) }, + None => quote! { if true }, + }; + + // `force_refresh_bypass`: the force-refresh predicate as a plain boolean expression + // (`(#block)`), or constant `false` when there is no `force_refresh`. Used by the + // `result_fallback` path to decide, once, whether a present entry is being bypassed. + // When bypassing we read the stale fallback value via the non-renewing + // `cache_peek_with_expiry_status` so the bypassed entry sees no read side effects (#146); + // when not bypassing we use the renewing `cache_get_with_expiry_status`, which is the + // correct read for a genuine hit. With no `force_refresh` this is constant-false, so the + // renewing-read + early-return path is always taken — equivalent to the prior behavior. + let force_refresh_bypass = match &force_refresh_block { + Some(block) => quote! { (#block) }, + None => quote! { false }, + }; - // `do_set_return_block`: runs `inner`, sets the cache, returns the result. + // The cache-set used on the `result_fallback` Ok path. `__cached_ok_val` is a + // `&V` (bound via `if let Ok(__cached_ok_val) = &__cached_result`). Routing it + // through `set_call` keeps every set site on one path and moves the key instead + // of cloning it (the owned `cache_set` call cloned both key and value). + // + // Note: `result_fallback` is gated to the in-memory sharded stores, which do not + // implement `SerializeCached`/`SerializeCachedAsync`, so today the shim always + // resolves to its owned-fallback arm (clones the value, same as before). The + // borrowed clone-eliding arm is therefore currently unreachable here; it would + // engage automatically only if a serialize-backed store were ever admitted to + // the `result_fallback` path (which also needs `ConcurrentCloneCached`). + let fallback_set = set_call(quote! { __cached_ok_val }); + + // `do_set_return_block`: runs `__cached_inner`, sets the cache, returns the result. // For `result_fallback`, the expiry-aware lookup (via `ConcurrentCloneCached`) is folded // into this block; no separate `_FALLBACK` static is needed. + // + // When `force_refresh` bypasses a present entry, the stale fallback value is captured via + // the non-renewing `cache_peek_with_expiry_status` so the bypassed entry has no read side + // effects (#146); a genuine (non-bypass) hit still uses the renewing + // `cache_get_with_expiry_status` and takes the early `#return_cache_block`. let do_set_return_block = if args.result_fallback && asyncness.is_some() { quote! { - let cache = #cache_ident.get_or_init(|| async { #cache_create }).await; - let old_val = { - let (val, expired) = ::cached::ConcurrentCloneCached::cache_get_with_expiry_status(cache, &key); - match (val, expired) { - (Some(result), false) => { #return_cache_block } - (stale, _) => stale, + #inner_nested_def + let __cached_cache = #cache_ident.get_or_init(|| async { #cache_create }).await; + let __cached_old_val = if #force_refresh_bypass { + // Bypassing this entry: peek for the stale fallback without side effects. + let (__cached_stale, _) = #krate::ConcurrentCloneCached::cache_peek_with_expiry_status(__cached_cache, &__cached_key); + __cached_stale + } else { + let (__cached_val, __cached_expired) = #krate::ConcurrentCloneCached::cache_get_with_expiry_status(__cached_cache, &__cached_key); + match (__cached_val, __cached_expired) { + (Some(__cached_result), false) => { #return_cache_block } + (__cached_stale, _) => __cached_stale, } }; - #inner_sig #body - let result = inner(#(#input_names),*).await; - let result = match (result.is_err(), old_val) { - (true, Some(old_val)) => Ok(old_val), - _ => result, + let __cached_result = #self_prefix #inner_fn_ident(#(#input_names),*).await; + let __cached_result = match (__cached_result.is_err(), __cached_old_val) { + (true, Some(__cached_old_val)) => Ok(__cached_old_val), + _ => __cached_result, }; - if let Ok(ok_val) = &result { - ::cached::ConcurrentCachedAsync::async_cache_set(cache, key.clone(), ok_val.clone()).await #cache_set_unwrap_async; + if let Ok(__cached_ok_val) = &__cached_result { + #fallback_set } - result + __cached_result } } else if args.result_fallback { quote! { - let cache = &*#cache_ident; - let old_val = { - let (val, expired) = ::cached::ConcurrentCloneCached::cache_get_with_expiry_status(cache, &key); - match (val, expired) { - (Some(result), false) => { #return_cache_block } - (stale, _) => stale, + #inner_nested_def + let __cached_cache = &*#cache_ident; + let __cached_old_val = if #force_refresh_bypass { + // Bypassing this entry: peek for the stale fallback without side effects. + let (__cached_stale, _) = #krate::ConcurrentCloneCached::cache_peek_with_expiry_status(__cached_cache, &__cached_key); + __cached_stale + } else { + let (__cached_val, __cached_expired) = #krate::ConcurrentCloneCached::cache_get_with_expiry_status(__cached_cache, &__cached_key); + match (__cached_val, __cached_expired) { + (Some(__cached_result), false) => { #return_cache_block } + (__cached_stale, _) => __cached_stale, } }; - #inner_sig #body - let result = inner(#(#input_names),*); - let result = match (result.is_err(), old_val) { - (true, Some(old_val)) => Ok(old_val), - _ => result, + let __cached_result = #self_prefix #inner_fn_ident(#(#input_names),*); + let __cached_result = match (__cached_result.is_err(), __cached_old_val) { + (true, Some(__cached_old_val)) => Ok(__cached_old_val), + _ => __cached_result, }; - if let Ok(ok_val) = &result { - ::cached::ConcurrentCached::cache_set(cache, key.clone(), ok_val.clone()) #cache_set_unwrap; + if let Ok(__cached_ok_val) = &__cached_result { + #fallback_set } - result + __cached_result } } else if asyncness.is_some() { quote! { - #inner_sig #body - let result = inner(#(#input_names),*).await; - let cache = #cache_ident.get_or_init(|| async { #cache_create }).await; + #inner_nested_def + let __cached_result = #self_prefix #inner_fn_ident(#(#input_names),*).await; + let __cached_cache = #cache_ident.get_or_init(|| async { #cache_create }).await; #set_cache_block - result + __cached_result } } else { quote! { - #inner_sig #body - let result = inner(#(#input_names),*); - let cache = &*#cache_ident; + #inner_nested_def + let __cached_result = #self_prefix #inner_fn_ident(#(#input_names),*); + let __cached_cache = &*#cache_ident; #set_cache_block - result + __cached_result } }; @@ -983,28 +1202,28 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { fill_in_attributes(&mut attributes, cache_fn_doc_extra); // `prime_do_set_return_block`: used by the priming function. For `result_fallback`, - // prime unconditionally reruns the function and stores the result — no old_val fallback, + // prime unconditionally reruns the function and stores the result - no old_val fallback, // no early-return on fresh hit. For all other paths, prime reuses `do_set_return_block` // which already implements "run inner and set cache". let prime_do_set_return_block = if args.result_fallback && asyncness.is_some() { quote! { - #inner_sig #body - let result = inner(#(#input_names),*).await; - let cache = #cache_ident.get_or_init(|| async { #cache_create }).await; - if let Ok(ok_val) = &result { - ::cached::ConcurrentCachedAsync::async_cache_set(cache, key.clone(), ok_val.clone()).await #cache_set_unwrap_async; + #inner_nested_def + let __cached_result = #self_prefix #inner_fn_ident(#(#input_names),*).await; + let __cached_cache = #cache_ident.get_or_init(|| async { #cache_create }).await; + if let Ok(__cached_ok_val) = &__cached_result { + #fallback_set } - result + __cached_result } } else if args.result_fallback { quote! { - #inner_sig #body - let result = inner(#(#input_names),*); - let cache = &*#cache_ident; - if let Ok(ok_val) = &result { - ::cached::ConcurrentCached::cache_set(cache, key.clone(), ok_val.clone()) #cache_set_unwrap; + #inner_nested_def + let __cached_result = #self_prefix #inner_fn_ident(#(#input_names),*); + let __cached_cache = &*#cache_ident; + if let Ok(__cached_ok_val) = &__cached_result { + #fallback_set } - result + __cached_result } } else { do_set_return_block.clone() @@ -1013,15 +1232,21 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { // `initial_cache_lookup`: the early-return guard block emitted at the start of the cached // function body. For `result_fallback`, the lookup is folded into `do_set_return_block` // (via `ConcurrentCloneCached`), so we emit nothing here for that path. + // The `#force_refresh_guard` wraps the whole lookup (not just the early + // return) so the `cache_get` call is skipped when force-refreshing. On a + // `refresh_on_hit` TTL store, `cache_get` renews the entry's TTL as a side + // effect, which must not happen for a bypassed entry (#146). let initial_cache_lookup_async = if args.result_fallback { quote! {} } else { quote! { { // check if the result is cached - let cache = #cache_ident.get_or_init(|| async { #cache_create }).await; - if let Some(result) = ::cached::ConcurrentCachedAsync::async_cache_get(cache, &key).await #cache_get_unwrap_async { - #return_cache_block + let __cached_cache = #cache_ident.get_or_init(|| async { #cache_create }).await; + #force_refresh_guard { + if let Some(__cached_result) = #krate::ConcurrentCachedAsync::async_cache_get(__cached_cache, &__cached_key).await #cache_get_unwrap_async { + #return_cache_block + } } } } @@ -1032,54 +1257,99 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { quote! { { // check if the result is cached - let cache = &*#cache_ident; - if let Some(result) = ::cached::ConcurrentCached::cache_get(cache, &key) #cache_get_unwrap { - #return_cache_block + let __cached_cache = &*#cache_ident; + #force_refresh_guard { + if let Some(__cached_result) = #krate::ConcurrentCached::cache_get(__cached_cache, &__cached_key) #cache_get_unwrap { + #return_cache_block + } } } } }; + // The cache static cannot sit at impl scope when `in_impl`; emit it inside + // each generated fn body instead (also fixes same-named-method collisions). + // Build the static with a caller-supplied leading visibility token: the + // module-scope static keeps the method's `#visibility`, but the `in_impl` + // function-local static is emitted bare (no visibility): a visibility on a + // function-local item is meaningless and trips `unreachable_pub` (#7). + let make_static = |vis: &proc_macro2::TokenStream| { + if asyncness.is_some() { + quote! { + #vis static #cache_ident: #krate::async_sync::OnceCell<#cache_ty> = #krate::async_sync::OnceCell::new(); + } + } else { + quote! { + #vis static #cache_ident: ::std::sync::LazyLock<#cache_ty> = ::std::sync::LazyLock::new(|| #cache_create); + } + } + }; + let (module_static, body_static) = if args.in_impl { + // No `#[doc]`: a function-local static is not part of the public API and + // rustdoc ignores doc attributes on it, so the doc string would be dead. + (quote! {}, make_static("e! {})) + } else { + let static_decl = make_static("e! { #visibility }); + (quote! { #[doc = #cache_ident_doc] #static_decl }, quote! {}) + }; + + // The cache static is function-local when `in_impl = true`, so the cached + // method and a `{fn}_prime_cache` sibling would each get a distinct + // function-local static - priming would populate a static the cached method + // never reads (a silent no-op). A function-local static cannot be shared + // between two sibling methods, so a correct prime is impossible under + // `in_impl`; do not emit the companion at all. Calling a non-existent prime + // fn is then a clear compile error instead of a silent no-op (#16/#140). + let prime_fn = if args.in_impl { + quote! {} + } else { + quote! { + // Prime cached function. Priming is optional, so suppress + // `dead_code` for callers that generate but never call the companion. + #[doc = #prime_fn_indent_doc] + #[allow(dead_code)] + #companions_visibility #prime_sig { + #body_static + let __cached_key = #key_convert_block; + #prime_do_set_return_block + } + } + }; + // put it all together let expanded = if asyncness.is_some() { quote! { - // Cached static - #[doc = #cache_ident_doc] - #visibility static #cache_ident: ::cached::async_sync::OnceCell<#cache_ty> = ::cached::async_sync::OnceCell::const_new(); + // Cached static (module scope unless `in_impl`) + #module_static + // Inner origin fn as a sibling impl method (only when `in_impl`) + #inner_sibling_def // Cached function #(#attributes)* #visibility #signature_no_muts { - let key = #key_convert_block; + #body_static + let __cached_key = #key_convert_block; #initial_cache_lookup_async #do_set_return_block } - // Prime cached function - #[doc = #prime_fn_indent_doc] - #[allow(dead_code)] - #visibility #prime_sig { - let key = #key_convert_block; - #prime_do_set_return_block - } + // Prime cached function (omitted for `in_impl` methods) + #prime_fn } } else { quote! { - // Cached static - #[doc = #cache_ident_doc] - #visibility static #cache_ident: ::std::sync::LazyLock<#cache_ty> = ::std::sync::LazyLock::new(|| #cache_create); + // Cached static (module scope unless `in_impl`) + #module_static + // Inner origin fn as a sibling impl method (only when `in_impl`) + #inner_sibling_def // Cached function #(#attributes)* #visibility #signature_no_muts { - let key = #key_convert_block; + #body_static + let __cached_key = #key_convert_block; #initial_cache_lookup_sync #do_set_return_block } - // Prime cached function - #[doc = #prime_fn_indent_doc] - #[allow(dead_code)] - #visibility #prime_sig { - let key = #key_convert_block; - #prime_do_set_return_block - } + // Prime cached function (omitted for `in_impl` methods) + #prime_fn } }; @@ -1088,6 +1358,8 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { fn get_redis_cache_type_and_create( args: &ConcurrentCachedArgs, + krate: &proc_macro2::TokenStream, + ttl_duration: Option<&proc_macro2::TokenStream>, cache_ident: &Ident, cache_key_ty: &proc_macro2::TokenStream, cache_value_ty: &proc_macro2::TokenStream, @@ -1129,68 +1401,48 @@ fn get_redis_cache_type_and_create( } None => { if is_async { - quote! { cached::AsyncRedisCache<#cache_key_ty, #cache_value_ty> } + quote! { #krate::AsyncRedisCache<#cache_key_ty, #cache_value_ty> } } else { - quote! { cached::RedisCache<#cache_key_ty, #cache_value_ty> } + quote! { #krate::RedisCache<#cache_key_ty, #cache_value_ty> } } } }; let cache_create = match &args.create { - Some(cache_create) => { + Some(create_expr) => { check_create_conflicts(args, cache_ident.span())?; - let cache_create = parse_str::(cache_create.as_ref()).map_err(|e| { - syn::Error::new( - cache_ident.span(), - format!("unable to parse cache create block: {e}"), - ) - })?; - quote! { #cache_create } + expr_value_tokens(create_expr) } None => { - if let Some(ttl) = args.ttl { - let cache_prefix = if let Some(cp) = &args.cache_prefix_block { - cp.to_string() + if let Some(ttl_dur) = ttl_duration { + let cache_prefix_block: proc_macro2::TokenStream = if let Some(cp_expr) = + &args.cache_prefix_block + { + // User supplied a `cache_prefix_block` expression. + expr_value_tokens(cp_expr) } else { - format!( - " {{ \"cached::macros::concurrent_cached::{}\" }}", - cache_ident - ) + // Runtime key-prefix string: NOT a path into the `cached` + // crate, so it is intentionally left as the literal + // `cached::macros::...` namespace (do not rewrite to `#krate`). + let prefix_str = format!("cached::macros::concurrent_cached::{}", cache_ident); + quote! { { #prefix_str } } }; - let cache_prefix = parse_str::(cache_prefix.as_ref()).map_err(|e| { - syn::Error::new( - cache_ident.span(), - format!("unable to parse cache_prefix_block: {e}"), - ) - })?; - match args.refresh { - Some(refresh) => { - if is_async { - quote! { cached::AsyncRedisCache::new(#cache_prefix, ::cached::time::Duration::from_secs(#ttl)).refresh_on_hit(#refresh).build().await.unwrap_or_else(|e| panic!("error constructing AsyncRedisCache in #[concurrent_cached] macro: {e}")) } - } else { - quote! { - cached::RedisCache::new(#cache_prefix, ::cached::time::Duration::from_secs(#ttl)).refresh_on_hit(#refresh).build().unwrap_or_else(|e| panic!("error constructing RedisCache in #[concurrent_cached] macro: {e}")) - } - } - } - None => { - if is_async { - quote! { cached::AsyncRedisCache::new(#cache_prefix, ::cached::time::Duration::from_secs(#ttl)).build().await.unwrap_or_else(|e| panic!("error constructing AsyncRedisCache in #[concurrent_cached] macro: {e}")) } - } else { - quote! { - cached::RedisCache::new(#cache_prefix, ::cached::time::Duration::from_secs(#ttl)).build().unwrap_or_else(|e| panic!("error constructing RedisCache in #[concurrent_cached] macro: {e}")) - } - } + let refresh = args.refresh; + if is_async { + quote! { #krate::AsyncRedisCache::builder().prefix(#cache_prefix_block).ttl(#ttl_dur).refresh_on_hit(#refresh).build().await.unwrap_or_else(|e| panic!("error constructing AsyncRedisCache in #[concurrent_cached] macro: {e}")) } + } else { + quote! { + #krate::RedisCache::builder().prefix(#cache_prefix_block).ttl(#ttl_dur).refresh_on_hit(#refresh).build().unwrap_or_else(|e| panic!("error constructing RedisCache in #[concurrent_cached] macro: {e}")) } } } else if is_async { return Err(syn::Error::new( cache_ident.span(), - "AsyncRedisCache requires a `ttl` when `create` block is not specified", + "AsyncRedisCache requires a TTL (`ttl`/`ttl_secs`/`ttl_millis`) when `create` block is not specified", )); } else { return Err(syn::Error::new( cache_ident.span(), - "RedisCache requires a `ttl` when `create` block is not specified", + "RedisCache requires a TTL (`ttl`/`ttl_secs`/`ttl_millis`) when `create` block is not specified", )); } } @@ -1200,6 +1452,8 @@ fn get_redis_cache_type_and_create( fn get_disk_cache_type_and_create( args: &ConcurrentCachedArgs, + krate: &proc_macro2::TokenStream, + ttl_duration: Option<&proc_macro2::TokenStream>, cache_name: &str, cache_key_ty: &proc_macro2::TokenStream, cache_value_ty: &proc_macro2::TokenStream, @@ -1213,40 +1467,28 @@ fn get_disk_cache_type_and_create( quote! { #ty } } None => { - quote! { cached::RedbCache<#cache_key_ty, #cache_value_ty> } + quote! { #krate::RedbCache<#cache_key_ty, #cache_value_ty> } } }; let cache_create = match &args.create { - Some(cache_create) => { + Some(create_expr) => { check_create_conflicts(args, fn_ident.span())?; - let cache_create = parse_str::(cache_create.as_ref()).map_err(|e| { - syn::Error::new( - fn_ident.span(), - format!("unable to parse cache create block: {e}"), - ) - })?; - quote! { #cache_create } + expr_value_tokens(create_expr) } None => { let create = quote! { - cached::RedbCache::new(#cache_name) - }; - let create = match args.ttl { - None => create, - Some(ttl) => { - quote! { - (#create).ttl(::cached::time::Duration::from_secs(#ttl)) - } - } + #krate::RedbCache::builder().name(#cache_name) }; - let create = match args.refresh { + let create = match ttl_duration { None => create, - Some(refresh) => { + Some(ttl_dur) => { quote! { - (#create).refresh_on_hit(#refresh) + (#create).ttl(#ttl_dur) } } }; + let refresh = args.refresh; + let create = quote! { (#create).refresh_on_hit(#refresh) }; let create = match args.durable { None => create, Some(durable) => { @@ -1274,17 +1516,19 @@ fn get_disk_cache_type_and_create( /// /// | max_size | ttl | expires | store | /// |----------|-----|---------|-------| -/// | no | no | no | `ShardedCache` | +/// | no | no | no | `ShardedUnboundCache` | /// | yes | no | no | `ShardedLruCache` | /// | no | yes | no | `ShardedTtlCache` (requires `time_stores` feature on `cached`) | /// | yes | yes | no | `ShardedLruTtlCache` (requires `time_stores` feature on `cached`) | -/// | no | — | yes | `ShardedExpiringCache` (per-value expiry; `ttl` is rejected with `expires`) | -/// | yes | — | yes | `ShardedExpiringLruCache` (per-value expiry; `ttl` is rejected with `expires`) | +/// | no | - | yes | `ShardedExpiringCache` (per-value expiry; `ttl` is rejected with `expires`) | +/// | yes | - | yes | `ShardedExpiringLruCache` (per-value expiry; `ttl` is rejected with `expires`) | /// /// `shards = N` is honored on every variant and routes through the `_and_shards` /// shortcut constructor. fn get_sharded_cache_type_and_create( args: &ConcurrentCachedArgs, + krate: &proc_macro2::TokenStream, + ttl_duration: Option<&proc_macro2::TokenStream>, cache_key_ty: &proc_macro2::TokenStream, cache_value_ty: &proc_macro2::TokenStream, fn_ident: &Ident, @@ -1295,13 +1539,13 @@ fn get_sharded_cache_type_and_create( if matches!(args.max_size, Some(0)) { return Err(syn::Error::new(fn_ident.span(), "`max_size` must be >= 1")); } - if matches!(args.ttl, Some(0)) { - return Err(syn::Error::new(fn_ident.span(), "`ttl` must be >= 1")); - } - if args.refresh.is_some_and(|r| r) && args.ttl.is_none() { + // No `ttl`/`ttl_secs`/`ttl_millis` zero check here: a zero `ttl_secs`/`ttl_millis` + // is rejected at the top level of the macro (shared by every store path) before + // this helper runs, and `ttl` (a Duration expression) has no compile-time value. + if args.refresh && ttl_duration.is_none() { return Err(syn::Error::new( fn_ident.span(), - "`refresh` requires `ttl` to be set on the default in-memory sharded path", + "`refresh` requires a TTL (`ttl`/`ttl_secs`/`ttl_millis`) to be set on the default in-memory sharded path", )); } @@ -1334,64 +1578,63 @@ fn get_sharded_cache_type_and_create( let (cache_ty, cache_create) = if args.expires { match args.max_size { Some(size) => { - let ty = - quote! { ::cached::ShardedExpiringLruCache<#cache_key_ty, #cache_value_ty> }; + let ty = quote! { #krate::ShardedExpiringLruCache<#cache_key_ty, #cache_value_ty> }; let create = match args.shards { Some(n) => { - quote! { ::cached::ShardedExpiringLruCache::builder().max_size(#size).shards(#n).build().unwrap_or_else(|e| panic!("ShardedExpiringLruCache build failed in #[concurrent_cached]: {e}")) } + quote! { #krate::ShardedExpiringLruCache::builder().max_size(#size).shards(#n).build().unwrap_or_else(|e| panic!("ShardedExpiringLruCache build failed in #[concurrent_cached]: {e}")) } } None => { - quote! { ::cached::ShardedExpiringLruCache::builder().max_size(#size).build().unwrap_or_else(|e| panic!("ShardedExpiringLruCache build failed in #[concurrent_cached]: {e}")) } + quote! { #krate::ShardedExpiringLruCache::builder().max_size(#size).build().unwrap_or_else(|e| panic!("ShardedExpiringLruCache build failed in #[concurrent_cached]: {e}")) } } }; (ty, create) } None => { - let ty = quote! { ::cached::ShardedExpiringCache<#cache_key_ty, #cache_value_ty> }; + let ty = quote! { #krate::ShardedExpiringCache<#cache_key_ty, #cache_value_ty> }; let create = match args.shards { Some(n) => { - quote! { ::cached::ShardedExpiringCache::builder().shards(#n).build().unwrap_or_else(|e| panic!("ShardedExpiringCache build failed in #[concurrent_cached]: {e}")) } + quote! { #krate::ShardedExpiringCache::builder().shards(#n).build().unwrap_or_else(|e| panic!("ShardedExpiringCache build failed in #[concurrent_cached]: {e}")) } } None => { - quote! { ::cached::ShardedExpiringCache::builder().build().unwrap_or_else(|e| panic!("ShardedExpiringCache build failed in #[concurrent_cached]: {e}")) } + quote! { #krate::ShardedExpiringCache::builder().build().unwrap_or_else(|e| panic!("ShardedExpiringCache build failed in #[concurrent_cached]: {e}")) } } }; (ty, create) } } } else { - match (args.max_size, args.ttl) { + match (args.max_size, ttl_duration) { (None, None) => { - let ty = quote! { ::cached::ShardedCache<#cache_key_ty, #cache_value_ty> }; + let ty = quote! { #krate::ShardedUnboundCache<#cache_key_ty, #cache_value_ty> }; let create = match args.shards { Some(n) => { - quote! { ::cached::ShardedCache::builder().shards(#n).build().unwrap_or_else(|e| panic!("ShardedCache build failed in #[concurrent_cached]: {e}")) } + quote! { #krate::ShardedUnboundCache::builder().shards(#n).build().unwrap_or_else(|e| panic!("ShardedUnboundCache build failed in #[concurrent_cached]: {e}")) } } None => { - quote! { ::cached::ShardedCache::builder().build().unwrap_or_else(|e| panic!("ShardedCache build failed in #[concurrent_cached]: {e}")) } + quote! { #krate::ShardedUnboundCache::builder().build().unwrap_or_else(|e| panic!("ShardedUnboundCache build failed in #[concurrent_cached]: {e}")) } } }; (ty, create) } (Some(size), None) => { - let ty = quote! { ::cached::ShardedLruCache<#cache_key_ty, #cache_value_ty> }; + let ty = quote! { #krate::ShardedLruCache<#cache_key_ty, #cache_value_ty> }; let create = match args.shards { Some(n) => { - quote! { ::cached::ShardedLruCache::builder().max_size(#size).shards(#n).build().unwrap_or_else(|e| panic!("ShardedLruCache build failed in #[concurrent_cached]: {e}")) } + quote! { #krate::ShardedLruCache::builder().max_size(#size).shards(#n).build().unwrap_or_else(|e| panic!("ShardedLruCache build failed in #[concurrent_cached]: {e}")) } } None => { - quote! { ::cached::ShardedLruCache::builder().max_size(#size).build().unwrap_or_else(|e| panic!("ShardedLruCache build failed in #[concurrent_cached]: {e}")) } + quote! { #krate::ShardedLruCache::builder().max_size(#size).build().unwrap_or_else(|e| panic!("ShardedLruCache build failed in #[concurrent_cached]: {e}")) } } }; (ty, create) } - (None, Some(ttl)) => { - let ty = quote! { ::cached::ShardedTtlCache<#cache_key_ty, #cache_value_ty> }; - let refresh = args.refresh.unwrap_or(false); + (None, Some(ttl_dur)) => { + let ty = quote! { #krate::ShardedTtlCache<#cache_key_ty, #cache_value_ty> }; + let refresh = args.refresh; let create = match args.shards { Some(n) => quote! {{ - let __c = ::cached::ShardedTtlCache::builder() - .ttl(::cached::time::Duration::from_secs(#ttl)) + let __c = #krate::ShardedTtlCache::builder() + .ttl(#ttl_dur) .shards(#n) .refresh_on_hit(#refresh) .build() @@ -1399,8 +1642,8 @@ fn get_sharded_cache_type_and_create( __c }}, None => quote! {{ - let __c = ::cached::ShardedTtlCache::builder() - .ttl(::cached::time::Duration::from_secs(#ttl)) + let __c = #krate::ShardedTtlCache::builder() + .ttl(#ttl_dur) .refresh_on_hit(#refresh) .build() .unwrap_or_else(|e| panic!("ShardedTtlCache build failed in #[concurrent_cached]: {e}")); @@ -1409,14 +1652,14 @@ fn get_sharded_cache_type_and_create( }; (ty, create) } - (Some(size), Some(ttl)) => { - let ty = quote! { ::cached::ShardedLruTtlCache<#cache_key_ty, #cache_value_ty> }; - let refresh = args.refresh.unwrap_or(false); + (Some(size), Some(ttl_dur)) => { + let ty = quote! { #krate::ShardedLruTtlCache<#cache_key_ty, #cache_value_ty> }; + let refresh = args.refresh; let create = match args.shards { Some(n) => quote! {{ - let __c = ::cached::ShardedLruTtlCache::builder() + let __c = #krate::ShardedLruTtlCache::builder() .max_size(#size) - .ttl(::cached::time::Duration::from_secs(#ttl)) + .ttl(#ttl_dur) .shards(#n) .refresh_on_hit(#refresh) .build() @@ -1424,9 +1667,9 @@ fn get_sharded_cache_type_and_create( __c }}, None => quote! {{ - let __c = ::cached::ShardedLruTtlCache::builder() + let __c = #krate::ShardedLruTtlCache::builder() .max_size(#size) - .ttl(::cached::time::Duration::from_secs(#ttl)) + .ttl(#ttl_dur) .refresh_on_hit(#refresh) .build() .unwrap_or_else(|e| panic!("ShardedLruTtlCache build failed in #[concurrent_cached]: {e}")); @@ -1460,15 +1703,9 @@ fn get_custom_cache_type_and_create( } }; let cache_create = match &args.create { - Some(cache_create) => { + Some(create_expr) => { check_create_conflicts(args, fn_ident.span())?; - let cache_create = parse_str::(cache_create.as_ref()).map_err(|e| { - syn::Error::new( - fn_ident.span(), - format!("unable to parse cache create block: {e}"), - ) - })?; - quote! { #cache_create } + expr_value_tokens(create_expr) } None => { return Err(syn::Error::new( diff --git a/cached_proc_macro/src/helpers.rs b/cached_proc_macro/src/helpers.rs index d8ba9ddd..cef8d3fd 100644 --- a/cached_proc_macro/src/helpers.rs +++ b/cached_proc_macro/src/helpers.rs @@ -1,17 +1,96 @@ +use darling::ast::NestedMeta; use darling::{Error, FromMeta}; use proc_macro::TokenStream; +use proc_macro_crate::{FoundCrate, crate_name}; use proc_macro2::TokenStream as TokenStream2; use quote::__private::Span; -use quote::quote; +use quote::{format_ident, quote}; use std::ops::Deref; use syn::punctuated::Punctuated; +use syn::spanned::Spanned; use syn::token::Comma; use syn::{ Attribute, Block, FnArg, GenericArgument, Pat, PatType, PathArguments, ReturnType, Signature, Type, parse_quote, parse_str, }; -/// Returns `true` if `output` is a `Result<…>` type (last path segment is +/// Scan the raw attribute arguments for `#[cached]` and `#[once]` and reject +/// any that are only valid on `#[concurrent_cached]` with a clear message +/// directing the user to the correct macro. This runs before `FromMeta::from_list` +/// so the friendly message replaces darling's generic "Unknown field" error. +/// +/// The concurrent-only attributes are the I/O-backed store selectors: +/// - `disk` - selects the redb disk-backed store +/// - `redis` - selects the Redis-backed store +/// - `map_error` - converts the store error; only meaningful with `disk`/`redis` +pub(super) fn reject_concurrent_only_attrs( + macro_name: &str, + attr_args: &[NestedMeta], +) -> Result<(), syn::Error> { + for arg in attr_args { + let Some(meta) = (match arg { + NestedMeta::Meta(meta) => Some(meta), + NestedMeta::Lit(_) => None, + }) else { + continue; + }; + let Some(ident) = meta.path().get_ident().map(ToString::to_string) else { + continue; + }; + let message = match ident.as_str() { + "disk" => Some(format!( + "`disk` is not supported on `#[{macro_name}]`; \ + `disk` selects the redb disk-backed concurrent store. \ + Use `#[concurrent_cached(disk = true)]` instead." + )), + "redis" => Some(format!( + "`redis` is not supported on `#[{macro_name}]`; \ + `redis` selects the Redis-backed concurrent store. \ + Use `#[concurrent_cached(redis = true)]` instead." + )), + "map_error" => Some(format!( + "`map_error` is not supported on `#[{macro_name}]`; \ + `map_error` converts the store error on the `disk` or `redis` concurrent store. \ + Use `#[concurrent_cached]` with `disk = true` or `redis = true`." + )), + _ => None, + }; + if let Some(message) = message { + return Err(syn::Error::new(meta.span(), message)); + } + } + Ok(()) +} + +/// Resolve the path to the `cached` crate for use in generated code. +/// +/// Generated code that referred to `::cached::...` broke for downstream crates +/// that renamed the dependency (e.g. `cached = { package = "cached", ... }` under +/// a different name) - issue #157. `proc-macro-crate` looks up the actual import +/// name from the dependent crate's `Cargo.toml`: +/// +/// - `FoundCrate::Itself` (the macro is used inside `cached`'s own tests/examples) +/// resolves to `::cached`. +/// - `FoundCrate::Name(n)` resolves to `::n` (the renamed import). +/// - On error (no manifest / not found), fall back to `::cached` so the crate's +/// own test suite - where the lookup can fail - keeps working. This cannot be a +/// hard error: doctests and some build configs hit the `Err` path legitimately, +/// so propagating a diagnostic would break `cached`'s own build. The cost is +/// that a downstream crate that both renamed the dependency and trips the error +/// path gets an "unresolved import `::cached`" error rather than a manifest-lookup +/// message - a rare edge case where the import error is itself a usable signal. +pub(super) fn crate_path() -> TokenStream2 { + match crate_name("cached") { + Ok(FoundCrate::Itself) => quote! { ::cached }, + Ok(FoundCrate::Name(name)) => { + let ident = format_ident!("{}", name); + quote! { ::#ident } + } + Err(_) => quote! { ::cached }, + } +} + +/// Returns `true` if `output` is a `Result<...>` type (last path segment is /// exactly `"Result"` and carries type arguments). pub(super) fn is_result_return_type(output: &ReturnType) -> bool { match output { @@ -25,7 +104,7 @@ pub(super) fn is_result_return_type(output: &ReturnType) -> bool { } } -/// Returns `true` if `output` is an `Option<…>` type (last path segment is `"Option"` with type args). +/// Returns `true` if `output` is an `Option<...>` type (last path segment is `"Option"` with type args). pub(super) fn is_option_return_type(output: &ReturnType) -> bool { match output { ReturnType::Default => false, @@ -38,7 +117,102 @@ pub(super) fn is_option_return_type(output: &ReturnType) -> bool { } } -#[derive(Debug, Default, Eq, PartialEq)] +/// The migration message emitted when `ttl` is given as a bare integer literal +/// (the old `ttl = 60` whole-seconds form). Shared by all three macros so the +/// message stays identical everywhere. +pub(super) const TTL_INT_MIGRATION_MESSAGE: &str = "`ttl` now takes a Duration expression (e.g. `ttl = \"Duration::from_secs(60)\"`); \ + for whole seconds use `ttl_secs = 60`, for milliseconds use `ttl_millis = 500`."; + +/// Custom `FromMeta` type for the `ttl` macro attribute. +/// +/// `ttl` now accepts a `Duration` expression written as a string literal (the +/// same convention as `create`/`convert`), e.g. +/// `ttl = "core::time::Duration::from_secs(60)"`. The string is stored verbatim +/// here and parsed into a `syn::Expr` later by the macro. +/// +/// A bare integer literal (`ttl = 60`) was the old whole-seconds form. It is now +/// rejected with a helpful migration message pointing at `ttl_secs`/`ttl_millis` +/// (matching the crate's helpful-rename pattern), rather than darling's generic +/// "expected string" error. +#[derive(Debug, Clone)] +pub(super) struct TtlExpr { + pub expr: String, + pub span: Option, +} + +impl FromMeta for TtlExpr { + fn from_string(value: &str) -> darling::Result { + Ok(Self { + expr: value.to_string(), + span: None, + }) + } + + // Intercept any non-string literal (e.g. the old `ttl = 60` integer form) + // and emit the migration message instead of darling's "expected string". + fn from_value(value: &syn::Lit) -> darling::Result { + match value { + syn::Lit::Str(s) => Ok(Self { + expr: s.value(), + span: Some(s.span()), + }), + other => Err(darling::Error::custom(TTL_INT_MIGRATION_MESSAGE).with_span(other)), + } + } +} + +/// Build the internal `ttl_duration` token and `has_ttl` flag from the three +/// mutually exclusive TTL attributes (`ttl` expr, `ttl_secs`, `ttl_millis`). +/// +/// Returns `Ok((has_ttl, ttl_duration))` where `ttl_duration` is `Some` when any +/// TTL is set. Performs the 3-way mutual-exclusion check, the `ttl_secs >= 1` / +/// `ttl_millis >= 1` validation, and parses the `ttl` expression string. +pub(super) fn resolve_ttl_duration( + krate: &TokenStream2, + ttl: &Option, + ttl_secs: Option, + ttl_millis: Option, + span: Span, +) -> Result<(bool, Option), syn::Error> { + let set_count = usize::from(ttl.is_some()) + + usize::from(ttl_secs.is_some()) + + usize::from(ttl_millis.is_some()); + if set_count > 1 { + return Err(syn::Error::new( + span, + "`ttl`, `ttl_secs`, and `ttl_millis` are mutually exclusive - \ + `ttl` takes a `Duration` expression, `ttl_secs` whole seconds, \ + `ttl_millis` milliseconds; use exactly one", + )); + } + if matches!(ttl_secs, Some(0)) { + return Err(syn::Error::new(span, "`ttl_secs` must be >= 1")); + } + if matches!(ttl_millis, Some(0)) { + return Err(syn::Error::new(span, "`ttl_millis` must be >= 1")); + } + let ttl_duration = if let Some(ttl_expr) = ttl { + let err_span = ttl_expr.span.unwrap_or(span); + let expr = parse_str::(&ttl_expr.expr).map_err(|error| { + syn::Error::new( + err_span, + format!( + "unable to parse `ttl` as a Duration expression: {error}; \ + `ttl` takes a `Duration` expression as a string literal, e.g. \ + `ttl = \"core::time::Duration::from_secs(60)\"`" + ), + ) + })?; + Some(quote! { #expr }) + } else if let Some(secs) = ttl_secs { + Some(quote! { #krate::time::Duration::from_secs(#secs) }) + } else { + ttl_millis.map(|millis| quote! { #krate::time::Duration::from_millis(#millis) }) + }; + Ok((ttl_duration.is_some(), ttl_duration)) +} + +#[derive(Debug, Default, Clone, Copy, Eq, PartialEq)] pub(super) enum SyncWriteMode { #[default] Disabled, @@ -160,12 +334,12 @@ pub(super) fn find_value_type( (false, false) => Ok(output_ty), (true, true) => Err(syn::Error::new( output_ty.span(), - "the `result` and `option` attributes are mutually exclusive", + "a return type cannot be detected as both `Result` and `Option`", )), _ => match output.clone() { ReturnType::Default => Err(syn::Error::new( output_ty.span(), - "function must return something when `result` or `option` is set", + "function must return a `Result` or `Option` for its inner value to be cached", )), ReturnType::Type(_, ty) => { let span = ty.span(); @@ -199,9 +373,9 @@ pub(super) fn find_value_type( } /// Extracts the single angle-bracketed type argument from a path type's last -/// segment — e.g. the `T` in `Result` or `Return`. `not_path` is the +/// segment - e.g. the `T` in `Result` or `Return`. `not_path` is the /// error message when `ty` is not a simple path type; `no_arg` is the message -/// when the path has no usable `<…>` argument. Used by `#[concurrent_cached]` +/// when the path has no usable `<...>` argument. Used by `#[concurrent_cached]` /// to peel `Result` (and, with `with_cached_flag`, `cached::Return`). pub(super) fn first_type_arg<'a>( ty: &'a Type, @@ -227,28 +401,68 @@ pub(super) fn first_type_arg<'a>( // make the cache key type and block that converts the inputs into the key type pub(super) fn make_cache_key_type( key: &Option, - convert: &Option, + convert: &Option, ty: &Option, input_tys: Vec, - input_names: &Vec, + input_names: &[Pat], ) -> Result<(TokenStream2, TokenStream2), syn::Error> { match (key, convert, ty) { - (Some(key_str), Some(convert_str), _) => { - let cache_key_ty = parse_str::(key_str)?; + (Some(key_str), Some(convert_expr), _) => { + let cache_key_ty = parse_str::(key_str).map_err(|error| { + syn::Error::new( + Span::call_site(), + format!( + "unable to parse `key` as a type: {error}; \ + `key` must be a Rust type, e.g. `key = \"String\"` or \ + `key = \"(u32, String)\"`" + ), + ) + })?; - let key_convert_block = parse_str::(convert_str)?; + let key_convert_block = expr_to_block(convert_expr.clone()); Ok((quote! {#cache_key_ty}, quote! {#key_convert_block})) } - (None, Some(convert_str), Some(_)) => { - let key_convert_block = parse_str::(convert_str)?; + (None, Some(convert_expr), Some(_)) => { + let key_convert_block = expr_to_block(convert_expr.clone()); Ok((quote! {}, quote! {#key_convert_block})) } - (None, None, _) => Ok(( - quote! {(#(#input_tys),*)}, - quote! {(#(#input_names.clone()),*)}, - )), + (None, None, _) => { + // Default key: derive an owned key type + conversion from the + // function inputs. Reference inputs (`&T`/`&mut T` and + // `Option<&T>`/`Option<&mut T>`) are converted to owned key components + // so the cache can store them without borrowing from the call + // (#202/#203). The owned type is `::Owned` (so `&str` + // keys on `String`, `&[u8]` on `Vec`, `&Foo: Clone` on `Foo`): + // `&T` / `&mut T` -> key type `::Owned`, expr `name.to_owned()` + // `Option<&T>` / `Option<&mut T>` -> key type `Option<::Owned>`, expr `name.as_deref().map(|__cached_v| __cached_v.to_owned())` + // otherwise -> key type `T`, expr `name.clone()` + let mut key_tys: Vec = Vec::with_capacity(input_tys.len()); + let mut key_exprs: Vec = Vec::with_capacity(input_tys.len()); + for (ty, name) in input_tys.iter().zip(input_names.iter()) { + if let Some(inner) = strip_ref(ty) { + key_tys.push(quote! { <#inner as ::std::borrow::ToOwned>::Owned }); + key_exprs.push(quote! { #name.to_owned() }); + } else if let Some(inner) = option_ref_inner(ty) { + key_tys.push(quote! { Option<<#inner as ::std::borrow::ToOwned>::Owned> }); + // Use `as_deref()` to avoid moving `name` (Option<&mut T> is not + // Copy, and `.map()` would move it, causing a use-after-move error + // when `name` is reused in the `_no_cache` call). `as_deref` takes + // `&self` and yields `Option<&T>` for both `Option<&T>` and + // `Option<&mut T>` without consuming the Option (#FIX-C). + key_exprs + .push(quote! { #name.as_deref().map(|__cached_v| __cached_v.to_owned()) }); + } else { + key_tys.push(quote! { #ty }); + key_exprs.push(quote! { #name.clone() }); + } + } + // Match the original parenthesized-list shape (no trailing comma): + // a single input yields the bare element type `(T)` == `T` and expr + // `(name...)`, exactly as before; multiple inputs yield a tuple. + Ok((quote! {(#(#key_tys),*)}, quote! {(#(#key_exprs),*)})) + } (Some(_), None, _) => Err(syn::Error::new( Span::call_site(), "`key` requires `convert` to be set", @@ -260,6 +474,35 @@ pub(super) fn make_cache_key_type( } } +/// If `ty` is a reference `&T` (or `&mut T`), return the referent `T`. +/// Used by the default-key path to derive an owned key component (#202). +fn strip_ref(ty: &Type) -> Option<&Type> { + match ty { + Type::Reference(r) => Some(&r.elem), + _ => None, + } +} + +/// If `ty` is `Option<&T>` (or `Option<&mut T>`, including qualified +/// `std::option::Option`), return the referent `T`. Used by the default-key +/// path so `Option<&str>` keys on an owned `Option` (#203). +fn option_ref_inner(ty: &Type) -> Option<&Type> { + let Type::Path(tp) = ty else { + return None; + }; + let seg = tp.path.segments.last()?; + if seg.ident != "Option" { + return None; + } + let PathArguments::AngleBracketed(args) = &seg.arguments else { + return None; + }; + let GenericArgument::Type(inner) = args.args.first()? else { + return None; + }; + strip_ref(inner) +} + // if you define arguments as mutable, e.g. // #[once] // fn mutable_args(mut a: i32, mut b: i32) -> (i32, i32) { @@ -273,9 +516,12 @@ pub(super) fn make_cache_key_type( pub(super) fn get_input_names(inputs: &Punctuated) -> Vec { inputs .iter() - .map(|input| match input { - FnArg::Receiver(_) => panic!("methods (functions taking 'self') are not supported"), - FnArg::Typed(pat_type) => *match_pattern_type(&pat_type), + // Skip the receiver (`self`/`&self`/`&mut self`): it is not a keyable + // argument. `in_impl = true` / a custom `convert` allow `self` methods, + // and the `self.` prefix is re-prepended at the call site (#16/#140). + .filter_map(|input| match input { + FnArg::Receiver(_) => None, + FnArg::Typed(pat_type) => Some(*match_pattern_type(&pat_type)), }) .collect() } @@ -294,9 +540,10 @@ pub(super) fn fill_in_attributes(attributes: &mut Vec, cache_fn_doc_e pub(super) fn get_input_types(inputs: &Punctuated) -> Vec { inputs .iter() - .map(|input| match input { - FnArg::Receiver(_) => panic!("methods (functions taking 'self') are not supported"), - FnArg::Typed(pat_type) => *pat_type.ty.clone(), + // Skip the receiver (see `get_input_names`): `self` is not a keyable arg. + .filter_map(|input| match input { + FnArg::Receiver(_) => None, + FnArg::Typed(pat_type) => Some(*pat_type.ty.clone()), }) .collect() } @@ -319,21 +566,104 @@ pub(super) fn with_cache_flag_error(output_span: Span, output_type_display: Stri .into() } +/// Parse the `force_refresh` expression (`Option`, already parsed by +/// darling) into an `Option` for use in generated code. +/// +/// Returns `Ok(None)` when `force_refresh` is `None`. Shared by +/// `build_force_refresh_guard` and by `#[concurrent_cached]`, which needs the +/// same parsed block to build its `force_refresh_bypass` token, so the expression +/// is extracted only once per macro expansion. +/// +/// If the `Expr` is already `Expr::Block`, its inner `Block` is used directly. +/// Otherwise the expression is wrapped in a synthetic block so a bare expression +/// (e.g. `force_refresh = { id == 0 }`) also works. +pub(super) fn parse_force_refresh_block( + force_refresh: &Option, + _span: Span, +) -> Result, syn::Error> { + match force_refresh { + Some(expr) => { + let block = expr_to_block(expr.clone()); + Ok(Some(block)) + } + None => Ok(None), + } +} + +/// Convert a `syn::Expr` to a `syn::Block`. +/// +/// If `expr` is already `Expr::Block`, return its inner block directly. +/// Otherwise wrap it in a synthetic block `{ expr }`. +pub(super) fn expr_to_block(expr: syn::Expr) -> Block { + use syn::{Stmt, parse_quote}; + match expr { + syn::Expr::Block(eb) => eb.block, + other => { + let stmt: Stmt = parse_quote! { #other }; + Block { + brace_token: Default::default(), + stmts: vec![stmt], + } + } + } +} + +/// Emit an attribute expression for *value/argument* position (e.g. `create`, +/// `cache_prefix_block`, which expand into `Lock::new()` / `.prefix()`). +/// +/// A single-expression block (`{ Store::builder()...build().unwrap() }`, the natural +/// unquoted spelling, or the parsed legacy quoted form) is unwrapped to its inner +/// expression so the generated code is `Lock::new(Store::builder()...)` rather than +/// `Lock::new({ Store::builder()... })` (which trips `unused_braces` under `-D warnings`). +/// Bare expressions are emitted directly; multi-statement blocks are kept as-is (the +/// braces are load-bearing and `unused_braces` does not flag them). +pub(super) fn expr_value_tokens(expr: &syn::Expr) -> TokenStream2 { + if let syn::Expr::Block(eb) = expr + && eb.attrs.is_empty() + && eb.label.is_none() + && eb.block.stmts.len() == 1 + && let syn::Stmt::Expr(inner, None) = &eb.block.stmts[0] + { + return quote! { #inner }; + } + quote! { #expr } +} + +/// Build the `force_refresh` guard token that wraps a cached-hit early return. +/// +/// `force_refresh` is an opt-in boolean expression block over the function args, +/// in curly braces like `convert` (e.g. `force_refresh = { id == 0 }` or the +/// legacy quoted form `force_refresh = "{ id == 0 }"`). When it evaluates to +/// `true`, the cached-hit early return is skipped so the body re-runs and +/// re-caches. The returned token is `if !(block)`; with no `force_refresh` it is +/// `if true` (always take the cached value). Orthogonal to `refresh` (TTL renewal +/// on hit) (#146). Shared by `#[cached]`, `#[concurrent_cached]`, and `#[once]`. +pub(super) fn build_force_refresh_guard( + force_refresh: &Option, + span: Span, +) -> Result { + match parse_force_refresh_block(force_refresh, span)? { + Some(block) => Ok(quote! { if !(#block) }), + None => Ok(quote! { if true }), + } +} + pub(super) fn gen_return_cache_block( - time: Option, + krate: &TokenStream2, + ttl_duration: Option, expires: bool, return_cache_block: TokenStream2, ) -> TokenStream2 { if expires { quote! { - if !<_ as ::cached::Expires>::is_expired(result) { + if !<_ as #krate::Expires>::is_expired(__cached_result) { #return_cache_block } } - } else if let Some(time) = &time { + } else if let Some(ttl_duration) = &ttl_duration { quote! { - let (created_sec, result) = result; - if now.saturating_duration_since(*created_sec) < ::cached::time::Duration::from_secs(#time) { + let (__cached_created_sec, __cached_result) = __cached_result; + if __cached_now.saturating_duration_since(*__cached_created_sec) < #ttl_duration { #return_cache_block } } @@ -346,7 +676,7 @@ pub(super) fn gen_return_cache_block( // `Return`), descending through a single outer `Result<_, _>` / `Option<_>` // wrapper via its first type argument. A proc macro sees tokens, not resolved // types, so this still cannot see through a type alias -// (e.g. `use cached::Return as R;`) — but it correctly rejects an unrelated +// (e.g. `use cached::Return as R;`) - but it correctly rejects an unrelated // `Return` from another module (e.g. `other::Return`) instead of accepting // it and failing later with a confusing error. fn type_is_cached_return(ty: &Type) -> bool { diff --git a/cached_proc_macro/src/lib.rs b/cached_proc_macro/src/lib.rs index 17c7b518..126ab59a 100644 --- a/cached_proc_macro/src/lib.rs +++ b/cached_proc_macro/src/lib.rs @@ -11,8 +11,48 @@ use proc_macro::TokenStream; /// # Attributes /// - `name`: (optional, string) specify the name for the generated cache, defaults to the function name uppercase. /// - `max_size`: (optional, usize) specify an LRU max size, implies the cache type is a `LruCache` or `LruTtlCache`. -/// - `ttl`: (optional, u64) specify a cache TTL in seconds, implies the cache type is a `TtlCache` or `LruTtlCache` (requires the `time_stores` feature). +/// - `ttl`: (optional, Duration string) specify a cache TTL as a Duration-expression string literal, +/// e.g. `ttl = "Duration::from_secs(60)"`. Implies the cache type is a `TtlCache` or `LruTtlCache` +/// (requires the `time_stores` feature). Mutually exclusive with `ttl_secs`, `ttl_millis`, and `expires`. +/// - `ttl_secs`: (optional, u64) specify a cache TTL as a whole number of seconds. Equivalent to +/// `ttl = "Duration::from_secs(N)"` but accepts a bare integer. Mutually exclusive with `ttl`, +/// `ttl_millis`, and `expires`. +/// - `ttl_millis`: (optional, u64) specify a cache TTL in milliseconds. A finer-grained alternative +/// to `ttl_secs` with the same store selection (so it likewise requires the `time_stores` feature); +/// mutually exclusive with `ttl`, `ttl_secs`, and `expires`. On `#[cached]`'s default store selection this is an +/// in-memory store, so sub-second TTLs are honored exactly; a custom `ty`/`create` store honors them +/// only if that store itself supports sub-second granularity. /// - `refresh`: (optional, bool) specify whether to refresh the TTL on cache hits. +/// - `force_refresh`: (optional, expression block) a boolean expression over the function arguments, +/// written in curly braces like `convert` (it is evaluated, not a magic flag and not a required +/// bool parameter). When it evaluates to `true`, any cached value is bypassed and the function body +/// is re-run and re-cached. Typically the condition is computed from existing arguments, e.g. +/// `force_refresh = "{ id == 0 }"` to always recompute the sentinel id; there is no extra argument +/// and the default key is correct as-is. +/// +/// If instead you use a dedicated flag argument (e.g. `refresh: bool`), you must exclude it from +/// the cache key with `key` / `convert`: +/// `#[cached(key = "u64", convert = "{ id }", force_refresh = "{ refresh }")] fn fetch(id: u64, refresh: bool)`. +/// This is not optional. With the default key the flag is part of the key, so the two call shapes hit +/// different entries: a `refresh = true` call bypasses the read, recomputes, and stores under the +/// `(id, true)` key, while ordinary `refresh = false` calls read the `(id, false)` key. The forced +/// recompute therefore lands in an entry that normal calls never read, so the refresh is silently +/// lost (later `refresh = false` calls keep returning the stale value), and the `(id, true)` entry is +/// written but never read. Excluding the flag collapses both shapes onto the one `id` entry, so a +/// forced recompute overwrites exactly what subsequent calls read. This is orthogonal to `refresh` +/// (which renews a TTL on a cache hit): +/// `force_refresh` decides whether to use a cached value at all, `refresh` decides whether a +/// used cached value renews its TTL. With `result_fallback = true`, a force-refreshed call that +/// re-runs and returns `Err` still serves the previously cached `Ok` value (the fallback consults +/// the cache even though the hit was bypassed). +/// - `in_impl`: (optional, bool) allow `#[cached]` on a method that takes `self` inside an `impl` +/// block. The cache static is emitted inside the generated method body (so it does not collide with +/// same-named methods on other types). The receiver is not part of the cache key - the cache is +/// shared across all instances of the type. Note: the `{fn}_prime_cache` companion is not generated +/// for `in_impl` methods - the cache static is function-local and cannot be shared with a separate +/// prime sibling, so priming is not supported there. The `{fn}_no_cache` sibling (the uncached +/// origin function) is still generated and inherits the method's visibility, so a `pub` cached +/// method exposes a `pub {fn}_no_cache` cache-bypass sibling on the same `impl`. /// - `sync_writes`: (optional, bool or string) specify whether to synchronize the execution and writing of uncached values. /// When not specified or set to `false`, uncached calls execute without write synchronization. When set to `true` /// or `"default"`, all keys synchronize by locking the whole cache during uncached execution. When set to @@ -20,16 +60,16 @@ use proc_macro::TokenStream; /// - `sync_writes_buckets`: (optional, usize) number of per-key lock buckets used by /// `sync_writes = "by_key"`; defaults to 64. Each bucket is one `Arc>`. Keys /// hash into a bucket, so two different keys may share a bucket and serialize unnecessarily -/// (false sharing). Increase this if you observe contention under high concurrency — a value -/// around 2–4× your expected peak concurrency eliminates most false sharing. Must be > 0. +/// (false sharing). Increase this if you observe contention under high concurrency - a value +/// around 2-4x your expected peak concurrency eliminates most false sharing. Must be > 0. /// - `sync_lock`: (optional, string) choose the generated cache lock. Defaults to `"rwlock"`. Use `"mutex"` /// to force a mutex. `unsync_reads = true` requires an RwLock. /// - `unsync_reads`: (optional, bool) use `CachedRead::cache_get_read` under a shared read lock for the initial /// cache lookup, while keeping writes synchronized. This only works for stores that implement `CachedRead`; /// recency-updating or refresh-on-hit stores intentionally do not. For non-mutating diagnostic lookups, /// use the separate `CachedPeek` trait directly on stores. -/// - `ty`: (optional, string type) The cache store type to use. Defaults to `UnboundCache`. When `unbound` is -/// specified, defaults to `UnboundCache`. When `max_size` is specified, defaults to `LruCache`. +/// - `ty`: (optional, string type) The cache store type to use. Defaults to `UnboundCache`. +/// When `max_size` is specified, defaults to `LruCache`. /// When `ttl` is specified, defaults to `TtlCache`. /// When `max_size` and `ttl` are specified, defaults to `LruTtlCache`. When `ty` is /// specified, `create` must also be specified. @@ -41,16 +81,16 @@ use proc_macro::TokenStream; /// `key` or `ty` must also be set. /// - `cache_err`: (optional, bool) If your function returns a `Result`, also cache `Err` values (by default only `Ok` is cached). /// **Note:** when `cache_err = true`, the underlying store holds `Result` as its value type, -/// so a direct `.cache_get()` on the generated cache static returns `Option>` — the outer +/// so a direct `.cache_get()` on the generated cache static returns `Option>` - the outer /// `Option` is the cache hit/miss, the inner `Result` is the stored value. /// - `cache_none`: (optional, bool) If your function returns an `Option`, also cache `None` values (by default only `Some` is cached). /// **Note:** when `cache_none = true`, the underlying store holds `Option` as its value type, -/// so a direct `.cache_get()` on the generated cache static returns `Option>` — the outer +/// so a direct `.cache_get()` on the generated cache static returns `Option>` - the outer /// `Option` is the cache hit/miss, the inner `Option` is the stored value. /// - `with_cached_flag`: (optional, bool) If your function returns a `cached::Return`, /// `Result, E>`, or `Option>`, /// the `cached::Return.was_cached` flag will be updated when a cached value is returned. -/// The wrapper type **must** be `cached::Return` — either written fully +/// The wrapper type **must** be `cached::Return` - either written fully /// qualified, or imported from `cached` (`use cached::Return;`). A proc macro /// only sees tokens, not resolved types: an unrelated type that merely happens /// to be named `Return` passes the attribute check but then fails to @@ -60,7 +100,8 @@ use proc_macro::TokenStream; /// In other words, refreshes are best-effort - returning `Ok` refreshes as usual but `Err` falls back to the last `Ok`. /// This is useful, for example, for keeping the last successful result of a network operation even during network disconnects. /// *Note*, this option requires the cache type to implement `CloneCached`. The compatible built-in options are: -/// `ttl` (uses `TtlCache`), `max_size` + `ttl` (uses `LruTtlCache`), and `expires` (uses `ExpiringCache`/`ExpiringLruCache`). +/// `ttl`, `ttl_secs`, or `ttl_millis` (uses `TtlCache`), `max_size` + `ttl`/`ttl_secs`/`ttl_millis` (uses `LruTtlCache`), and +/// `expires` (uses `ExpiringCache`/`ExpiringLruCache`). /// A custom `ty` that implements `CloneCached` is also accepted. /// Requires a `Result` return type. Mutually exclusive with `cache_err` and `sync_writes`. /// Requires the cache key type to implement `Clone` (the fallback path re-caches the key). The @@ -70,12 +111,12 @@ use proc_macro::TokenStream; /// The return type must implement `Expires`; for `Result` or `Option` returns, the inner `T` must implement `Expires`. /// Without `max_size`, uses `ExpiringCache` (unbounded). /// With `max_size = N`, uses `ExpiringLruCache` (LRU-bounded to N entries). -/// Unlike `ttl`, expiry logic lives in each value — useful for caching OAuth tokens, +/// Unlike `ttl`, expiry logic lives in each value - useful for caching OAuth tokens, /// HTTP responses with `Cache-Control` headers, or any payload with its own expiration timestamp. /// Compatible with `result_fallback`: on `Err`, returns the last-cached `Ok` value wrapped in `Ok(...)`, /// even if that value's `is_expired()` returns `true`. Callers must check the value's expiry themselves /// if they need to distinguish a fresh result from a stale fallback. -/// Mutually exclusive with `ttl`, `ty`, `create`, `with_cached_flag`, `unsync_reads`, `refresh`, and `unbound`. +/// Mutually exclusive with `ttl`, `ty`, `create`, `with_cached_flag`, `unsync_reads`, and `refresh`. /// /// ## Note /// The `ty`, `create`, `key`, and `convert` attributes must be in a `String` @@ -84,9 +125,16 @@ use proc_macro::TokenStream; /// /// `Result`/`Option` detection is exact: the macro matches only the bare identifiers `Result` /// and `Option` (including qualified forms like `std::result::Result`). Type aliases are -/// never resolved, so an alias — even one named `MyResult` (`type MyResult = Result`) — +/// never resolved, so an alias - even one named `MyResult` (`type MyResult = Result`) - /// is treated as a plain return value and its `Err` / `None` will be cached. Return /// `Result` / `Option` directly when you need the default Ok-only / Some-only behavior. +/// +/// **Generic functions** require `key` + `convert` to pin the cache key to a concrete type. The +/// cache static is a single monomorphic store shared across all instantiations and cannot name the +/// function's type parameters, so a generic function with the default key (no `convert`) is a +/// compile error. Provide `key`/`convert` (and `ty`/`create` if the value type is also generic) - +/// see the generic-`where` tests - or wrap the generic function in a non-generic `#[cached]` +/// function for each concrete type. #[proc_macro_attribute] pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { cached::cached(args, input) @@ -98,9 +146,29 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { /// /// # Attributes /// - `name`: (optional, string) specify the name for the generated cache, defaults to the function name uppercase. -/// - `ttl`: (optional, u64) specify an expiry in seconds, after which the single cached value is -/// recomputed on the next call. `#[once]` always stores one value in an `Option` (timestamped -/// when `ttl` is set) — it is not a `TtlCache`/`LruTtlCache`. +/// - `ttl`: (optional, Duration string) specify an expiry as a Duration-expression string literal, +/// e.g. `ttl = "Duration::from_secs(60)"`, after which the single cached value is recomputed on +/// the next call. `#[once]` always stores one value in an `Option` (timestamped when `ttl` is +/// set) - it is not a `TtlCache`/`LruTtlCache`. Mutually exclusive with `ttl_secs`, `ttl_millis`, +/// and `expires`. +/// - `ttl_secs`: (optional, u64) specify an expiry as a whole number of seconds. Equivalent to +/// `ttl = "Duration::from_secs(N)"` but accepts a bare integer. Mutually exclusive with `ttl`, +/// `ttl_millis`, and `expires`. +/// - `ttl_millis`: (optional, u64) the same expiry expressed in milliseconds; mutually exclusive +/// with `ttl`, `ttl_secs`, and `expires`. +/// - `force_refresh`: (optional, expression block) a boolean expression over the function arguments, +/// in curly braces like `convert` (it is evaluated, not a magic flag), e.g. +/// `force_refresh = "{ stale }"`. When it evaluates to `true`, the single cached value is bypassed +/// and the body re-runs and re-caches. Because `#[once]` has no per-call key (one value is shared by +/// all callers), there is no "exclude the flag from the key" caveat as on `#[cached]`: a forced +/// recompute simply overwrites the one shared value. Orthogonal to `ttl` expiry. +/// - `in_impl`: (optional, bool) allow `#[once]` on a method that takes `self` inside an `impl` +/// block. Note: `#[once]` stores a single value for all calls, so an `in_impl` `#[once]` +/// method shares one cached value across every instance of the type. Priming is unavailable here: +/// the `{fn}_prime_cache` companion is not generated for `in_impl` methods, because the cache static +/// is function-local and cannot be shared with a separate prime sibling. The `{fn}_no_cache` sibling +/// (the uncached origin function) is still generated and inherits the method's visibility, so a +/// `pub` cached method exposes a `pub {fn}_no_cache` cache-bypass sibling on the same `impl`. /// - `sync_writes`: (optional, bool or string) specify whether to synchronize the execution of writing of uncached values. /// When set to `true` or `"default"`, uncached execution is synchronized with the whole cache. /// When omitted or set to `false`, uncached calls are not synchronized. `sync_writes = "by_key"` @@ -110,7 +178,7 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { /// - `with_cached_flag`: (optional, bool) If your function returns a `cached::Return`, /// `Result, E>`, or `Option>`, /// the `cached::Return.was_cached` flag will be updated when a cached value is returned. -/// The wrapper type **must** be `cached::Return` — either written fully +/// The wrapper type **must** be `cached::Return` - either written fully /// qualified, or imported from `cached` (`use cached::Return;`). A proc macro /// only sees tokens, not resolved types: an unrelated type that merely happens /// to be named `Return` passes the attribute check but then fails to @@ -121,14 +189,17 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { /// When a lookup finds the cached value reports `is_expired() == true`, the cached value is /// skipped and the function re-executes; on success the new value replaces the old one. /// If the function returns `Err`/`None`, the expired entry is left in place and the error/none -/// is returned to the caller — subsequent calls will re-execute the function until it succeeds. +/// is returned to the caller - subsequent calls will re-execute the function until it succeeds. /// Mutually exclusive with `ttl` and `with_cached_flag`. /// /// `Result`/`Option` detection is exact: the macro matches only the bare identifiers `Result` /// and `Option` (including qualified forms like `std::result::Result`). Type aliases are -/// never resolved, so an alias — even one named `MyResult` (`type MyResult = Result`) — +/// never resolved, so an alias - even one named `MyResult` (`type MyResult = Result`) - /// is treated as a plain return value and its `Err` / `None` will be cached. Return /// `Result` / `Option` directly when you need the default Ok-only / Some-only behavior. +/// +/// **Generic functions are supported** by `#[once]`: its static only holds the (concrete) value +/// type, never the function's type parameters, so no `key`/`convert` is required. #[proc_macro_attribute] pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { once::once(args, input) @@ -137,7 +208,7 @@ pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { /// Define a memoized function using a cache store that implements `cached::ConcurrentCached` (and /// `cached::ConcurrentCachedAsync` for async functions). /// -/// **The macro preserves the function's sync/async-ness — it does not make a function async.** +/// **The macro preserves the function's sync/async-ness - it does not make a function async.** /// Applied to a synchronous `fn`, it generates a synchronous `fn` you call without `.await` /// (it uses the `ConcurrentCached` trait). Applied to an `async fn`, it generates an `async fn` /// you call with `.await` (it uses `ConcurrentCachedAsync`). The `&self`-contract sharded stores @@ -149,14 +220,17 @@ pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { /// /// | Attributes | Store selected | /// |---|---| -/// | (none) | `ShardedCache` — unbounded, no TTL | -/// | `max_size = N` | `ShardedLruCache` — LRU-bounded | -/// | `ttl = T` | `ShardedTtlCache` — TTL-expiring, unbounded (`time_stores` feature) | -/// | `max_size = N, ttl = T` | `ShardedLruTtlCache` — LRU + TTL (`time_stores` feature) | -/// | `expires = true` | `ShardedExpiringCache` — per-value expiry, unbounded | -/// | `expires = true, max_size = N` | `ShardedExpiringLruCache` — per-value expiry, LRU-bounded | +/// | (none) | `ShardedUnboundCache` - unbounded, no TTL | +/// | `max_size = N` | `ShardedLruCache` - LRU-bounded | +/// | `ttl = T` | `ShardedTtlCache` - TTL-expiring, unbounded (`time_stores` feature) | +/// | `max_size = N, ttl = T` | `ShardedLruTtlCache` - LRU + TTL (`time_stores` feature) | +/// | `expires = true` | `ShardedExpiringCache` - per-value expiry, unbounded | +/// | `expires = true, max_size = N` | `ShardedExpiringLruCache` - per-value expiry, LRU-bounded | /// -/// On the default in-memory path, do **not** specify `map_error` — the sharded stores are +/// `ttl_millis = T` selects the same store as `ttl = T` (the `ShardedTtlCache`/`ShardedLruTtlCache` +/// rows), just with millisecond rather than second granularity. +/// +/// On the default in-memory path, do **not** specify `map_error` - the sharded stores are /// infallible (`Error = Infallible`) and supplying `map_error` is a compile error. /// Reserve `map_error` for `redis`/`disk`/custom `ty`/`create` stores where the error type is fallible. /// Functions may return a plain `T`, `Option`, or `Result`. Plain values are @@ -164,11 +238,11 @@ pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { /// to also cache `None`. `Result` caches only successful `Ok(T)` values and returns /// `Err(E)` without storing it; use `cache_err = true` to also cache `Err` values. /// `result_fallback = true` is supported: on an `Err` return, the last cached `Ok` value -/// for the same key is returned instead (requires `ttl`). +/// for the same key is returned instead (requires `ttl`, `ttl_secs`, or `ttl_millis`). /// /// Result detection is exact: the macro matches only the bare identifier `Result` (including /// qualified forms like `std::result::Result`). Type aliases are never resolved, so any -/// alias — even one whose name ends with `Result` (e.g. `type MyResult = Result`) — +/// alias - even one whose name ends with `Result` (e.g. `type MyResult = Result`) - /// is treated as a plain return value and its `Err` variant will be cached. Return `Result` /// directly when you need Ok-only caching behavior. /// @@ -188,7 +262,7 @@ pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { /// /// # Attributes /// - `map_error`: (required for `redis`/`disk` and custom `ty`/`create` stores; **not allowed** -/// on the default in-memory sharded path — those stores are infallible and supplying `map_error` +/// on the default in-memory sharded path - those stores are infallible and supplying `map_error` /// there is a compile error) a closure used to map store errors into the error type returned /// by your function. /// - `name`: (optional, string) specify the name for the generated cache, defaults to the function name uppercase. @@ -201,15 +275,49 @@ pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { /// - `max_size`: (optional, usize) total LRU capacity for the default in-memory store. Selects /// `ShardedLruCache` (or `ShardedLruTtlCache` when combined with `ttl`). A compile error is /// emitted when combined with `redis`, `disk`, or `create`. -/// **Note:** effective capacity may exceed `N` — shards enforce a 16-entry minimum floor, so +/// **Note:** effective capacity may exceed `N` - shards enforce a 16-entry minimum floor, so /// `max_size = 4` on an 8-shard build silently gives 128 effective slots. For a strict cap use /// `shards = 1` or the builder's `per_shard_max_size`. -/// - `ttl`: (optional, u64) TTL in seconds. For the default in-memory path, selects -/// `ShardedTtlCache` or `ShardedLruTtlCache` (requires the `time_stores` feature). For `redis` -/// and `disk` stores, sets the key/entry TTL on those backends. +/// - `ttl`: (optional, Duration string) TTL as a Duration-expression string literal, e.g. +/// `ttl = "Duration::from_secs(60)"`. For the default in-memory path, selects `ShardedTtlCache` +/// or `ShardedLruTtlCache` (requires the `time_stores` feature). For `redis` and `disk` stores, +/// sets the key/entry TTL on those backends. Mutually exclusive with `ttl_secs`, `ttl_millis`, +/// and `expires`. +/// - `ttl_secs`: (optional, u64) TTL as a whole number of seconds. Equivalent to +/// `ttl = "Duration::from_secs(N)"` but accepts a bare integer. Selects the same stores as `ttl`. +/// Mutually exclusive with `ttl`, `ttl_millis`, and `expires`. +/// - `ttl_millis`: (optional, u64) the same TTL expressed in milliseconds; mutually exclusive with +/// `ttl`, `ttl_secs`, and `expires`. On the default in-memory path it selects the same sharded TTL stores as `ttl` +/// (so it likewise requires the `time_stores` feature). Honored on every backend (in-memory sharded, +/// redis, and disk). The in-memory sharded and disk (redb) stores honor true sub-second expiry; only +/// the redis backend applies TTL at +/// whole-second granularity (any non-zero fractional second rounds up to the next whole second, so +/// `ttl_millis = 500` becomes 1s and `ttl_millis = 1500` becomes 2s on redis), so a +/// `ttl_millis` that is not a whole number of seconds gives finer expiry everywhere except redis. +/// - `force_refresh`: (optional, expression block) a boolean expression over the function arguments, +/// in curly braces like `convert` (it is evaluated, not a magic flag), e.g. +/// `force_refresh = "{ id == 0 }"`. When it evaluates to `true`, any cached value is bypassed and the +/// function body is re-run and re-cached. If instead you use a dedicated flag argument (e.g. +/// `refresh: bool`), you must exclude it from the cache key with `key` / `convert`. This is not +/// optional. With the default key the flag is part of the key, so the two call shapes hit different +/// entries: a `refresh = true` call bypasses the read, recomputes, and stores under the `(id, true)` +/// key, while ordinary `refresh = false` calls read the `(id, false)` key. The forced recompute +/// therefore lands in an entry that normal calls never read, so the refresh is silently lost (later +/// `refresh = false` calls keep returning the stale value), and the `(id, true)` entry is written but +/// never read. Excluding the flag collapses both shapes onto the one `id` entry, so a forced recompute +/// overwrites exactly what subsequent calls read. Orthogonal to `refresh` (TTL renewal on a hit). With +/// `result_fallback = true`, a force-refreshed call that re-runs and returns `Err` still serves the +/// previously cached `Ok` value (the fallback consults the cache even though the hit was bypassed). +/// - `in_impl`: (optional, bool) allow `#[concurrent_cached]` on a method that takes `self` inside an +/// `impl` block. The cache static is emitted inside the generated method body. The receiver is not +/// part of the cache key - the cache is shared across all instances of the type. Note: the +/// `{fn}_prime_cache` companion is not generated for `in_impl` methods - the cache static is +/// function-local and cannot be shared with a separate prime sibling, so priming is not supported there. +/// The `{fn}_no_cache` sibling (the uncached origin function) is still generated and inherits the +/// method's visibility, so a `pub` cached method exposes a `pub {fn}_no_cache` cache-bypass sibling. /// - `shards`: (optional, usize) number of shards for the default in-memory store. Rounded up to -/// the next power of two. If omitted, defaults to `available_parallelism() × 4`, clamped to -/// 8–1024; an explicit value is only rounded up to a power of two and is not clamped. +/// the next power of two. If omitted, defaults to `available_parallelism() x 4`, clamped to +/// 8-1024; an explicit value is only rounded up to a power of two and is not clamped. /// A compile error is emitted when combined with `redis`, `disk`, or `create`. /// - `refresh`: (optional, bool) refresh the TTL on cache hits (TTL stores only). On the default /// in-memory path, setting `refresh = true` without `ttl` is a compile error (`refresh = false` @@ -241,13 +349,13 @@ pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { /// By default `None` is returned without being stored; set `cache_none = true` to store `None` as well. /// Only supported on the default in-memory sharded path; combining it with `redis`/`disk`/custom `ty` is a compile error. /// **Note:** when `cache_none = true`, the underlying store holds `Option` as its value type, -/// so a direct `.cache_get()` call returns `Option>` — the outer `Option` is the +/// so a direct `.cache_get()` call returns `Option>` - the outer `Option` is the /// cache hit/miss indicator; the inner `Option` is the cached value. /// - `cache_err`: (optional, bool) If your function returns a `Result`, also cache `Err` values. /// By default only `Ok(T)` is cached; set `cache_err = true` to store `Err` values too. /// Only supported on the default in-memory sharded path; combining it with `redis`/`disk`/custom `ty` is a compile error. /// **Note:** when `cache_err = true`, the underlying store holds `Result` as its value type, -/// so a direct `.cache_get()` call returns `Option>` — the outer `Option` is the +/// so a direct `.cache_get()` call returns `Option>` - the outer `Option` is the /// cache hit/miss indicator; the inner `Result` is the cached value. /// - `result_fallback`: (optional, bool) If your function returns a `Result`, on an `Err` /// return the last cached `Ok` value for the same key is returned instead (wrapped back in @@ -255,13 +363,13 @@ pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { /// the cache was cleared), the original `Err` is returned as-is. Refreshes are best-effort: /// an `Ok` return refreshes the cache as usual; an `Err` return re-caches the stale value /// with a fresh TTL window. **Note:** the stale value's TTL is refreshed on *every* `Err` -/// call — if the backend stays down indefinitely, the stale entry will never expire. `ttl` +/// call - if the backend stays down indefinitely, the stale entry will never expire. `ttl` /// bounds staleness under normal (transient) failure; it does not bound it under permanent /// failure. This is useful for keeping the last successful result available during transient /// failures, e.g. network disconnects. -/// **Requires `ttl`** — only implemented on the expiry-capable sharded stores (`ShardedTtlCache` -/// and `ShardedLruTtlCache`). Setting `ttl` without `max_size` selects `ShardedTtlCache`; with -/// `max_size` selects `ShardedLruTtlCache`. Omitting `ttl` is a compile error. +/// **Requires `ttl`, `ttl_secs`, or `ttl_millis`** - only implemented on the expiry-capable sharded stores +/// (`ShardedTtlCache` and `ShardedLruTtlCache`). Setting `ttl`/`ttl_secs`/`ttl_millis` without `max_size` selects +/// `ShardedTtlCache`; with `max_size` selects `ShardedLruTtlCache`. Omitting all three is a compile error. /// Mutually exclusive with `cache_err`, `with_cached_flag`, `expires = true`, `redis = true`, /// `disk = true`, and custom `ty`/`create`. /// Requires the cache key type to implement `Clone` (the fallback path re-caches the key). The @@ -269,7 +377,7 @@ pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { /// - `with_cached_flag`: (optional, bool) If your function returns a `cached::Return`, /// `Result, E>`, or `Option>`, the /// `cached::Return.was_cached` flag will be updated when a cached value is returned. -/// The wrapper type **must** be `cached::Return` — either written fully +/// The wrapper type **must** be `cached::Return` - either written fully /// qualified, or imported from `cached` (`use cached::Return;`). A proc macro /// only sees tokens, not resolved types: an unrelated type that merely happens /// to be named `Return` passes the attribute check but then fails to @@ -287,8 +395,14 @@ pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { /// This is because darling, which is used for parsing the attributes, does not support directly parsing /// attributes into `Type`s or `Block`s. /// -/// `sync_writes` is not supported by `#[concurrent_cached]`. Use `#[cached(sync_writes = …)]` instead +/// `sync_writes` is not supported by `#[concurrent_cached]`. Use `#[cached(sync_writes = ...)]` instead /// if you need to serialize concurrent first-call execution. +/// +/// **Generic functions** require `key` + `convert` (and a concrete store `ty`/`create`) to pin the +/// cache key/value to concrete types: the cache static is monomorphic and cannot name the +/// function's type parameters, so a generic function with the default key is a compile error. Wrap +/// it in a non-generic `#[concurrent_cached]` function per concrete type if you cannot supply a +/// concrete key. #[proc_macro_attribute] pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { concurrent_cached::concurrent_cached(args, input) diff --git a/cached_proc_macro/src/once.rs b/cached_proc_macro/src/once.rs index 35e2f3cf..3f017a9a 100644 --- a/cached_proc_macro/src/once.rs +++ b/cached_proc_macro/src/once.rs @@ -4,18 +4,31 @@ use darling::ast::NestedMeta; use proc_macro::TokenStream; use quote::quote; use syn::spanned::Spanned; -use syn::{Ident, ItemFn, ReturnType, parse_macro_input}; +use syn::{Ident, ItemFn, ReturnType, parse_macro_input, parse_str}; #[derive(FromMeta)] struct OnceMacroArgs { #[darling(default)] name: Option, + /// An expiry expressed as a `Duration` expression in a string literal (same + /// convention as `create`/`convert`), e.g. + /// `ttl = "core::time::Duration::from_secs(60)"`. Mutually exclusive with + /// `ttl_secs`, `ttl_millis`, and `expires`. #[darling(default)] - ttl: Option, + ttl: Option, + /// Expiry in whole seconds. Convenience alternative to `ttl`. Mutually + /// exclusive with `ttl`, `ttl_millis`, and `expires`. + #[darling(default)] + ttl_secs: Option, + /// Expiry in milliseconds. A finer-grained alternative to `ttl_secs`; + /// mutually exclusive with `ttl`, `ttl_secs`, and `expires` (#149). + #[darling(default)] + ttl_millis: Option, #[darling(default)] time: Option, + /// `None` = not specified by user (defaults to `Disabled` for `#[once]`). #[darling(default)] - sync_writes: SyncWriteMode, + sync_writes: Option, #[darling(default = "default_sync_writes_buckets")] sync_writes_buckets: usize, #[darling(default)] @@ -26,11 +39,49 @@ struct OnceMacroArgs { with_cached_flag: bool, #[darling(default)] expires: bool, + /// Allow the macro on a method that takes `self` inside an `impl` block. + /// Note: `#[once]` stores a single value for *all* receivers, so an + /// `in_impl` `#[once]` method shares one cached value across every instance + /// (#16/#140). + #[darling(default)] + in_impl: bool, + /// Opt-in boolean expression over the fn args. Both unquoted `{ expr }` and + /// legacy quoted `"{ expr }"` forms are accepted. When it evaluates `true`, the + /// single cached value is bypassed and the body re-runs and re-caches. + /// `#[once]` has no per-call key, so unlike `#[cached]` there is no "exclude + /// the flag from the key" caveat: a forced recompute overwrites the one shared + /// value for all callers. + #[darling(default)] + force_refresh: Option, + /// Override the visibility of the companion fns (`{fn}_no_cache`, + /// `{fn}_prime_cache`). `None` (default) inherits the cached fn's visibility. + #[darling(default)] + companions_vis: Option, // Removed attributes intercepted to provide helpful error messages #[darling(default)] result: Option, #[darling(default)] option: Option, + // `#[cached]`-only attributes - intercepted to provide a clear error instead + // of darling's generic "unknown field" message. + #[darling(default)] + sync_lock: Option, + #[darling(default)] + unsync_reads: Option, + #[darling(default)] + result_fallback: Option, + #[darling(default)] + refresh: Option, + #[darling(default)] + max_size: Option, + #[darling(default)] + ty: Option, + #[darling(default)] + create: Option, + #[darling(default)] + key: Option, + #[darling(default)] + convert: Option, } fn default_sync_writes_buckets() -> usize { @@ -44,6 +95,9 @@ pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { return TokenStream::from(darling::Error::from(e).write_errors()); } }; + if let Err(e) = reject_concurrent_only_attrs("once", &attr_args) { + return e.to_compile_error().into(); + } let args = match OnceMacroArgs::from_list(&attr_args) { Ok(v) => v, Err(e) => { @@ -58,40 +112,112 @@ pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { let signature = input.sig; let body = input.block; + // Resolve the path to the `cached` crate (renamed-dependency support, #157). + let krate = crate_path(); + + // Resolve the effective sync_writes mode. + // `None` (unspecified by user) defaults to `Disabled` for `#[once]`. + // `#[once]` never changes its default based on other attrs. + let sync_writes = args.sync_writes.unwrap_or(SyncWriteMode::Disabled); + // pull out the parts of the function signature let fn_ident = signature.ident.clone(); let inputs = signature.inputs.clone(); let output = signature.output.clone(); let asyncness = signature.asyncness; - - if inputs + let has_receiver = inputs .iter() - .any(|input| matches!(input, syn::FnArg::Receiver(_))) - { + .any(|input| matches!(input, syn::FnArg::Receiver(_))); + + // Reject `self` methods unless `in_impl = true` (#[once] has no `convert`). + if has_receiver && !args.in_impl { + return syn::Error::new( + fn_ident.span(), + "#[once] cannot be applied to methods that take `self`. \ + Use `in_impl = true` to cache a method inside an `impl` block. \ + Note: `#[once]` stores a single value shared across all instances.", + ) + .to_compile_error() + .into(); + } + + // The inverse: `in_impl = true` on a function with no `self` receiver + // mis-compiles, because the generated `{fn}_no_cache(args)` call inside the + // impl cannot resolve without a `Self::` qualifier (a confusing "cannot find + // function" error downstream). Reject it here with a clear message. + if args.in_impl && !has_receiver { return syn::Error::new( fn_ident.span(), - "#[once] cannot be applied to methods that take `self`", + "in_impl = true requires a method with a `self` receiver; \ + for a free function or an associated function without `self`, \ + remove in_impl.", ) .to_compile_error() .into(); } + // Note: `#[once]` supports generic functions. Its static only holds the + // (concrete) value type, never the function's type parameters, so no + // generic-rejection check is needed here (unlike `#[cached]` / + // `#[concurrent_cached]`, whose key/value types can leak generics) (#80). + if args.time.is_some() { return syn::Error::new( fn_ident.span(), - "`time` was renamed to `ttl` in cached 1.0; use `ttl = ...`", + "`time` (whole seconds) was renamed in cached 1.0; use `ttl_secs = ...` \ + (or `ttl = \"Duration::from_secs(...)\"` / `ttl_millis = ...`)", ) .to_compile_error() .into(); } - // Reject a zero `ttl` at expansion time (matching `#[concurrent_cached]`), - // rather than letting the generated builder `build()` panic at first call. - if matches!(args.ttl, Some(0)) { - return syn::Error::new(fn_ident.span(), "`ttl` must be >= 1") - .to_compile_error() - .into(); + // Run the `expires`-vs-ttl mutual-exclusion checks BEFORE resolving the TTL + // `Duration`. These need only presence (`is_some()`), not a parsed value, and + // surfacing "mutually exclusive" is more relevant than a `ttl` parse error + // when `expires` is also set. + if args.expires && args.ttl_secs.is_some() { + return syn::Error::new( + fn_ident.span(), + "`expires` and `ttl_secs` are mutually exclusive - \ + `expires` delegates expiry to the value via the `Expires` trait; \ + `ttl_secs` applies a uniform time-based TTL", + ) + .to_compile_error() + .into(); + } + if args.expires && args.ttl_millis.is_some() { + return syn::Error::new( + fn_ident.span(), + "`expires` and `ttl_millis` are mutually exclusive - \ + `expires` delegates expiry to the value via the `Expires` trait; \ + `ttl_millis` applies a uniform millisecond TTL to all entries", + ) + .to_compile_error() + .into(); } + if args.expires && args.ttl.is_some() { + return syn::Error::new( + fn_ident.span(), + "`expires` and `ttl` are mutually exclusive - \ + `expires` delegates expiry to the value via the `Expires` trait; \ + `ttl` applies a uniform time-based TTL", + ) + .to_compile_error() + .into(); + } + // Resolve the TTL `Duration` token from whichever of `ttl` (expr), `ttl_secs`, + // or `ttl_millis` is set. This performs the 3-way mutual-exclusion check, the + // `ttl_secs`/`ttl_millis` >= 1 validation, and parses the `ttl` expression. + let (has_ttl, ttl_duration) = match resolve_ttl_duration( + &krate, + &args.ttl, + args.ttl_secs, + args.ttl_millis, + fn_ident.span(), + ) { + Ok(v) => v, + Err(e) => return e.to_compile_error().into(), + }; if args.result.is_some() { return syn::Error::new( @@ -115,12 +241,88 @@ pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { .into(); } - if args.expires && args.ttl.is_some() { + if args.sync_lock.is_some() { + return syn::Error::new(fn_ident.span(), "`sync_lock` is not supported on `#[once]`") + .to_compile_error() + .into(); + } + + if args.unsync_reads.is_some() { return syn::Error::new( fn_ident.span(), - "`expires` and `ttl` are mutually exclusive — \ - `expires` delegates expiry to the value via the `Expires` trait; \ - `ttl` applies a uniform time-based TTL", + "`unsync_reads` is not supported on `#[once]`", + ) + .to_compile_error() + .into(); + } + + // Reject the remaining `#[cached]`-only attributes. `#[once]` stores a single + // shared value (not a keyed map), so these store-shaping / keying attributes do + // not apply. Intercept each with a friendly message instead of darling's generic + // "unknown field" error (mirrors `reject_cached_only_attrs` in + // `concurrent_cached.rs`). + if args.result_fallback.is_some() { + return syn::Error::new( + fn_ident.span(), + "`result_fallback` is not supported on `#[once]`; \ + it returns the last cached `Ok` value from a keyed cache, but `#[once]` stores a \ + single value and already returns the one cached `Ok` on subsequent calls", + ) + .to_compile_error() + .into(); + } + if args.refresh.is_some() { + return syn::Error::new( + fn_ident.span(), + "`refresh` is not supported on `#[once]`; \ + `refresh` renews a per-entry TTL on cache hit, but `#[once]` stores a single value \ + and does not refresh on read - set `ttl`/`ttl_secs`/`ttl_millis` for time-based expiry", + ) + .to_compile_error() + .into(); + } + if args.max_size.is_some() { + return syn::Error::new( + fn_ident.span(), + "`max_size` is not supported on `#[once]`; \ + `#[once]` stores a single value, so there is no entry count to bound", + ) + .to_compile_error() + .into(); + } + if args.ty.is_some() { + return syn::Error::new( + fn_ident.span(), + "`ty` is not supported on `#[once]`; \ + `#[once]` manages its own single-value storage and does not take a custom store type", + ) + .to_compile_error() + .into(); + } + if args.create.is_some() { + return syn::Error::new( + fn_ident.span(), + "`create` is not supported on `#[once]`; \ + `#[once]` manages its own single-value storage and does not take a custom store \ + constructor", + ) + .to_compile_error() + .into(); + } + if args.key.is_some() { + return syn::Error::new( + fn_ident.span(), + "`key` is not supported on `#[once]`; \ + `#[once]` stores a single value for all arguments and has no per-call cache key", + ) + .to_compile_error() + .into(); + } + if args.convert.is_some() { + return syn::Error::new( + fn_ident.span(), + "`convert` is not supported on `#[once]`; \ + `#[once]` stores a single value for all arguments and has no per-call cache key to convert", ) .to_compile_error() .into(); @@ -129,7 +331,7 @@ pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { if args.expires && args.with_cached_flag { return syn::Error::new( fn_ident.span(), - "`expires` and `with_cached_flag` are mutually exclusive — \ + "`expires` and `with_cached_flag` are mutually exclusive - \ the `Return` wrapper does not implement `Expires`", ) .to_compile_error() @@ -139,7 +341,7 @@ pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { if args.expires && args.cache_none { return syn::Error::new( fn_ident.span(), - "`expires = true` and `cache_none = true` are incompatible — `expires` requires \ + "`expires = true` and `cache_none = true` are incompatible - `expires` requires \ the cache value type to implement `Expires`, but `cache_none = true` stores \ `Option` as the value, which does not implement `Expires`. \ Remove `cache_none = true` (None values are not cached by default with `expires = true`).", @@ -151,7 +353,7 @@ pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { if args.expires && args.cache_err { return syn::Error::new( fn_ident.span(), - "`expires = true` and `cache_err = true` are incompatible — `expires` requires \ + "`expires = true` and `cache_err = true` are incompatible - `expires` requires \ the cache value type to implement `Expires`, but `cache_err = true` stores \ `Result` as the value, which does not implement `Expires`. \ Remove `cache_err = true` (Err values are not cached by default with `expires = true`).", @@ -201,7 +403,7 @@ pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { fn_ident.span(), "`cache_none = true` and `with_cached_flag = true` are structurally incompatible \ on `Option` returns: `with_cached_flag` stores the inner `T` from `Return` \ - while `cache_none = true` stores `Option` as the cached value — the same \ + while `cache_none = true` stores `Option` as the cached value - the same \ cache entry cannot hold both types. Use `with_cached_flag = true` alone (to get \ cache-state flags; `None` is not cached by default), or use `cache_none = true` \ alone (to force-cache `None` values).", @@ -221,14 +423,21 @@ pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { // make the cache identifier let cache_ident = match args.name { - Some(name) => Ident::new(&name, fn_ident.span()), + Some(ref name) => { + if syn::parse_str::(name).is_err() { + return syn::Error::new(fn_ident.span(), "`name` must be a valid Rust identifier") + .to_compile_error() + .into(); + } + Ident::new(name, fn_ident.span()) + } None => Ident::new(&fn_ident.to_string().to_uppercase(), fn_ident.span()), }; if let Err(error) = validate_sync_writes_buckets(args.sync_writes_buckets, fn_ident.span()) { return error.to_compile_error().into(); } - if args.sync_writes == SyncWriteMode::ByKey { + if sync_writes == SyncWriteMode::ByKey { return syn::Error::new( fn_ident.span(), "`sync_writes = \"by_key\"` is not supported by `#[once]` because `#[once]` stores a single value for all arguments", @@ -238,83 +447,109 @@ pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { } let sync_writes_buckets = args.sync_writes_buckets; + // `has_ttl` / `ttl_duration` were resolved above (from `ttl` expr, `ttl_secs`, + // or `ttl_millis`); `has_ttl` gates the timestamped storage shape (#149). + // make the cache type and create statement - let (cache_ty, cache_create) = match &args.ttl { - None => (quote! { Option<#cache_value_ty> }, quote! { None }), - Some(_) => ( - quote! { Option<(::cached::time::Instant, #cache_value_ty)> }, + let (cache_ty, cache_create) = if has_ttl { + ( + quote! { Option<(#krate::time::Instant, #cache_value_ty)> }, quote! { None }, - ), + ) + } else { + (quote! { Option<#cache_value_ty> }, quote! { None }) + }; + + // `force_refresh`: when its expression evaluates `true`, the cached-hit early + // return is skipped so the body re-runs and re-caches the single shared value. + // The guard wraps the whole cached-value check (not just the return), so a + // TTL'd entry's expiry test is bypassed too and the body always re-runs. + let force_refresh_guard = match build_force_refresh_guard(&args.force_refresh, fn_ident.span()) + { + Ok(guard) => guard, + Err(error) => return error.to_compile_error().into(), }; // make the set cache and return cache blocks let (set_cache_block, return_cache_block) = match (is_smart_result, is_smart_option) { (false, false) => { - let set_cache_block = if args.ttl.is_some() { + let set_cache_block = if has_ttl { quote! { - *cached = Some((::cached::time::Instant::now(), result.clone())); + *__cached_cached = Some((#krate::time::Instant::now(), __cached_result.clone())); } } else { quote! { - *cached = Some(result.clone()); + *__cached_cached = Some(__cached_result.clone()); } }; let return_cache_block = if args.with_cached_flag { - quote! { let mut r = result.clone(); r.was_cached = true; return r } + quote! { let mut __cached_r = __cached_result.clone(); __cached_r.was_cached = true; return __cached_r } } else { - quote! { return result.clone() } + quote! { return __cached_result.clone() } }; - let return_cache_block = - gen_return_cache_block(args.ttl, args.expires, return_cache_block); + let return_cache_block = gen_return_cache_block( + &krate, + ttl_duration.clone(), + args.expires, + return_cache_block, + ); (set_cache_block, return_cache_block) } (true, false) => { - let set_cache_block = if args.ttl.is_some() { + let set_cache_block = if has_ttl { quote! { - if let Ok(result) = &result { - *cached = Some((::cached::time::Instant::now(), result.clone())); + if let Ok(__cached_inner) = &__cached_result { + *__cached_cached = Some((#krate::time::Instant::now(), __cached_inner.clone())); } } } else { quote! { - if let Ok(result) = &result { - *cached = Some(result.clone()); + if let Ok(__cached_inner) = &__cached_result { + *__cached_cached = Some(__cached_inner.clone()); } } }; let return_cache_block = if args.with_cached_flag { - quote! { let mut r = result.clone(); r.was_cached = true; return Ok(r) } + quote! { let mut __cached_r = __cached_result.clone(); __cached_r.was_cached = true; return Ok(__cached_r) } } else { - quote! { return Ok(result.clone()) } + quote! { return Ok(__cached_result.clone()) } }; - let return_cache_block = - gen_return_cache_block(args.ttl, args.expires, return_cache_block); + let return_cache_block = gen_return_cache_block( + &krate, + ttl_duration.clone(), + args.expires, + return_cache_block, + ); (set_cache_block, return_cache_block) } (false, true) => { - let set_cache_block = if args.ttl.is_some() { + let set_cache_block = if has_ttl { quote! { - if let Some(result) = &result { - *cached = Some((::cached::time::Instant::now(), result.clone())); + if let Some(__cached_inner) = &__cached_result { + *__cached_cached = Some((#krate::time::Instant::now(), __cached_inner.clone())); } } } else { quote! { - if let Some(result) = &result { - *cached = Some(result.clone()); + if let Some(__cached_inner) = &__cached_result { + *__cached_cached = Some(__cached_inner.clone()); } } }; let return_cache_block = if args.with_cached_flag { - quote! { let mut r = result.clone(); r.was_cached = true; return Some(r) } + quote! { let mut __cached_r = __cached_result.clone(); __cached_r.was_cached = true; return Some(__cached_r) } } else { - quote! { return Some(result.clone()) } + quote! { return Some(__cached_result.clone()) } }; - let return_cache_block = - gen_return_cache_block(args.ttl, args.expires, return_cache_block); + let return_cache_block = gen_return_cache_block( + &krate, + ttl_duration.clone(), + args.expires, + return_cache_block, + ); (set_cache_block, return_cache_block) } (true, true) => unreachable!("return type cannot be both Result and Option"), @@ -322,70 +557,135 @@ pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { let set_cache_and_return = quote! { #set_cache_block - result + __cached_result }; - // Clone the full original signature and rename it to `inner`. Quoting the - // whole `syn::Signature` preserves the `where` clause (and lifetimes, - // const generics, etc.) — `#generics` alone drops the where clause. + // Clone the full original signature and rename it to `_no_cache`. Quoting + // the whole `syn::Signature` preserves the `where` clause (and lifetimes, + // const generics, etc.) - `#generics` alone drops the where clause. + // Unique per-function name so multiple `in_impl` methods on the same impl + // block do not collide on a shared `_no_cache` sibling method. + let inner_fn_ident = Ident::new(&format!("{}_no_cache", &fn_ident), fn_ident.span()); let mut inner_sig = signature.clone(); - inner_sig.ident = Ident::new("inner", fn_ident.span()); + inner_sig.ident = inner_fn_ident.clone(); + + // For `in_impl` methods the body may reference `self`, so `_no_cache` + // must be a sibling impl method (a nested fn cannot capture `self`); it is + // invoked as `self._no_cache(...)`. For free functions it stays a nested + // fn defined inline in the body (#16/#140). + let self_prefix = if has_receiver { + quote! { self. } + } else { + quote! {} + }; + // The `in_impl` origin sibling is a public impl method; hide it from consumers' + // rustdoc with `#[doc(hidden)]` (it stays callable as an escape hatch). + // Resolve companion fn visibility (#9). Needs to come before inner_sibling_def. + let companions_visibility = match &args.companions_vis { + None => quote! { #visibility }, + Some(s) if s.is_empty() => quote! {}, + Some(s) => match parse_str::(s) { + Ok(vis) => quote! { #vis }, + Err(e) => { + return syn::Error::new( + fn_ident.span(), + format!( + "unable to parse `companions_vis` as a visibility: {e}; \ + expected a Rust visibility, e.g. `\"pub\"`, `\"pub(crate)\"`, or `\"\"`" + ), + ) + .to_compile_error() + .into(); + } + }, + }; + + let (inner_sibling_def, inner_nested_def) = if args.in_impl { + ( + quote! { #[doc(hidden)] #companions_visibility #inner_sig #body }, + quote! {}, + ) + } else { + (quote! {}, quote! { #inner_sig #body }) + }; let r_lock; let w_lock; let function_call; - let ty; + // Build the cache static with a caller-supplied leading visibility token. The + // module-scope static keeps the method's `#visibility`, but the `in_impl` + // function-local static is emitted bare (no visibility): a visibility on a + // function-local item is meaningless and trips `unreachable_pub` (#7). + let make_static: Box proc_macro2::TokenStream>; if asyncness.is_some() { w_lock = quote! { // try to get a write lock - let mut cached = #cache_ident.write().await; + let mut __cached_cached = #cache_ident.write().await; }; r_lock = quote! { // try to get a read lock - let cached = #cache_ident.read().await; + let __cached_cached = #cache_ident.read().await; }; function_call = quote! { - #inner_sig #body - let result = inner(#(#input_names),*).await; + #inner_nested_def + let __cached_result = #self_prefix #inner_fn_ident(#(#input_names),*).await; }; - ty = match args.sync_writes { - SyncWriteMode::ByKey => quote! { - #visibility static #cache_ident: ::std::sync::LazyLock<(::cached::async_sync::RwLock<#cache_ty>, Vec>>)> = ::std::sync::LazyLock::new(|| (::cached::async_sync::RwLock::new(#cache_create), (0..#sync_writes_buckets).map(|_| std::sync::Arc::new(::cached::async_sync::RwLock::new(()))).collect())); - }, - _ => quote! { - #visibility static #cache_ident: ::std::sync::LazyLock<::cached::async_sync::RwLock<#cache_ty>> = ::std::sync::LazyLock::new(|| ::cached::async_sync::RwLock::new(#cache_create)); - }, - }; + let cache_ident = cache_ident.clone(); + let cache_ty = cache_ty.clone(); + let cache_create = cache_create.clone(); + let krate = krate.clone(); + let is_by_key = sync_writes == SyncWriteMode::ByKey; + make_static = Box::new(move |vis: &proc_macro2::TokenStream| { + if is_by_key { + quote! { + #vis static #cache_ident: ::std::sync::LazyLock<(#krate::async_sync::RwLock<#cache_ty>, Vec>>)> = ::std::sync::LazyLock::new(|| (#krate::async_sync::RwLock::new(#cache_create), (0..#sync_writes_buckets).map(|_| std::sync::Arc::new(#krate::async_sync::RwLock::new(()))).collect())); + } + } else { + quote! { + #vis static #cache_ident: ::std::sync::LazyLock<#krate::async_sync::RwLock<#cache_ty>> = ::std::sync::LazyLock::new(|| #krate::async_sync::RwLock::new(#cache_create)); + } + } + }); } else { w_lock = quote! { // try to get a lock first - let mut cached = #cache_ident.write(); + let mut __cached_cached = #cache_ident.write(); }; r_lock = quote! { // try to get a read lock - let cached = #cache_ident.read(); + let __cached_cached = #cache_ident.read(); }; function_call = quote! { - #inner_sig #body - let result = inner(#(#input_names),*); + #inner_nested_def + let __cached_result = #self_prefix #inner_fn_ident(#(#input_names),*); }; - ty = match args.sync_writes { - SyncWriteMode::ByKey => quote! { - #visibility static #cache_ident: ::std::sync::LazyLock<(::cached::sync_sync::RwLock<#cache_ty>, Vec>>)> = ::std::sync::LazyLock::new(|| (::cached::sync_sync::RwLock::new(#cache_create), (0..#sync_writes_buckets).map(|_| std::sync::Arc::new(::cached::sync_sync::RwLock::new(()))).collect())); - }, - _ => quote! { - #visibility static #cache_ident: ::std::sync::LazyLock<::cached::sync_sync::RwLock<#cache_ty>> = ::std::sync::LazyLock::new(|| ::cached::sync_sync::RwLock::new(#cache_create)); - }, - }; + let cache_ident = cache_ident.clone(); + let cache_ty = cache_ty.clone(); + let cache_create = cache_create.clone(); + let krate = krate.clone(); + let is_by_key = sync_writes == SyncWriteMode::ByKey; + make_static = Box::new(move |vis: &proc_macro2::TokenStream| { + if is_by_key { + quote! { + #vis static #cache_ident: ::std::sync::LazyLock<(#krate::sync_sync::RwLock<#cache_ty>, Vec>>)> = ::std::sync::LazyLock::new(|| (#krate::sync_sync::RwLock::new(#cache_create), (0..#sync_writes_buckets).map(|_| std::sync::Arc::new(#krate::sync_sync::RwLock::new(()))).collect())); + } + } else { + quote! { + #vis static #cache_ident: ::std::sync::LazyLock<#krate::sync_sync::RwLock<#cache_ty>> = ::std::sync::LazyLock::new(|| #krate::sync_sync::RwLock::new(#cache_create)); + } + } + }); } + let module_ty = make_static("e! { #visibility }); + let body_ty = make_static("e! {}); - let prime_do_set_return_block = match args.sync_writes { + let prime_do_set_return_block = match sync_writes { SyncWriteMode::ByKey => unreachable!("ByKey rejected above"), _ => quote! { #w_lock @@ -397,22 +697,67 @@ pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { let r_lock_return_cache_block = quote! { { #r_lock - if let Some(result) = &*cached { - #return_cache_block + #force_refresh_guard { + if let Some(__cached_result) = &*__cached_cached { + #return_cache_block + } } } }; - let do_set_return_block = match args.sync_writes { - SyncWriteMode::Default => quote! { - #r_lock_return_cache_block - #w_lock - if let Some(result) = &*cached { - #return_cache_block + let do_set_return_block = match sync_writes { + SyncWriteMode::Default => { + // When `force_refresh` IS set, hoist its predicate into a single + // boolean so it is evaluated ONCE per call. Without this the + // predicate would be expanded both inside the optimistic read-lock + // block and again in the write-lock re-check below, double-evaluating + // any side-effects in the user's predicate block (#FIX-B). + // + // `#force_refresh_guard { false } else { true }` is + // `if !(block) { false } else { true }` == `block`, so the binding + // holds the user's predicate value. + // + // When `force_refresh` is absent, emit NEITHER the binding nor a read + // of it: the two read sites below fall back to `#force_refresh_guard`, + // which is `if true` with no `force_refresh`, so the cached value is + // always taken (equivalent to `if !__cached_force_refreshing` when the + // flag would be `false`). This avoids emitting a constant + // `if true { false } else { true }` binding (a needless-bool smell). + let (force_refreshing_flag, read_guard) = if args.force_refresh.is_some() { + ( + quote! { + let __cached_force_refreshing = #force_refresh_guard { false } else { true }; + }, + quote! { if !__cached_force_refreshing }, + ) + } else { + (quote! {}, force_refresh_guard.clone()) + }; + // Inline read-lock block using the already-computed guard so the + // predicate is not re-evaluated here. + let r_lock_return_cache_block_hoisted = quote! { + { + #r_lock + #read_guard { + if let Some(__cached_result) = &*__cached_cached { + #return_cache_block + } + } + } + }; + quote! { + #force_refreshing_flag + #r_lock_return_cache_block_hoisted + #w_lock + #read_guard { + if let Some(__cached_result) = &*__cached_cached { + #return_cache_block + } + } + #function_call + #set_cache_and_return } - #function_call - #set_cache_and_return - }, + } SyncWriteMode::ByKey => unreachable!("ByKey rejected above"), SyncWriteMode::Disabled => quote! { #r_lock_return_cache_block @@ -438,29 +783,67 @@ pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { fill_in_attributes(&mut attributes, cache_fn_doc_extra); // put it all together - let now_block = if args.ttl.is_some() { - quote! { let now = ::cached::time::Instant::now(); } + let now_block = if has_ttl { + quote! { let __cached_now = #krate::time::Instant::now(); } } else { quote! {} }; + // The cache static cannot sit at impl scope when `in_impl`; emit it inside + // each generated fn body instead (also fixes same-named-method collisions). + let (module_static, body_static) = if args.in_impl { + // No `#[doc]`: a function-local static is not part of the public API and + // rustdoc ignores doc attributes on it, so the doc string would be dead. + // The function-local static is emitted bare (no visibility) - a meaningless + // visibility on a function-local item trips `unreachable_pub` (#7). + (quote! {}, quote! { #body_ty }) + } else { + ( + quote! { + #[doc = #cache_ident_doc] + #module_ty + }, + quote! {}, + ) + }; + + // The cache static is function-local when `in_impl = true`, so the cached + // method and a `{fn}_prime_cache` sibling would each get a distinct + // function-local static - priming would populate a static the cached method + // never reads (a silent no-op). A function-local static cannot be shared + // between two sibling methods, so a correct prime is impossible under + // `in_impl`; do not emit the companion at all. Calling a non-existent prime + // fn is then a clear compile error instead of a silent no-op (#16/#140). + let prime_fn = if args.in_impl { + quote! {} + } else { + quote! { + // Prime cached function. Priming is optional, so suppress + // `dead_code` for callers that generate but never call the companion. + #[doc = #prime_fn_indent_doc] + #[allow(dead_code)] + #companions_visibility #prime_sig { + #body_static + #now_block + #prime_do_set_return_block + } + } + }; + let expanded = quote! { - // Cached static - #[doc = #cache_ident_doc] - #ty + // Cached static (module scope unless `in_impl`) + #module_static + // Inner origin fn as a sibling impl method (only when `in_impl`) + #inner_sibling_def // Cached function #(#attributes)* #visibility #signature_no_muts { + #body_static #now_block #do_set_return_block } - // Prime cached function - #[doc = #prime_fn_indent_doc] - #[allow(dead_code)] - #visibility #prime_sig { - #now_block - #prime_do_set_return_block - } + // Prime cached function (omitted for `in_impl` methods) + #prime_fn }; expanded.into() diff --git a/cached_proc_macro_types/Cargo.toml b/cached_proc_macro_types/Cargo.toml index 02c2b92b..d9fcdb49 100644 --- a/cached_proc_macro_types/Cargo.toml +++ b/cached_proc_macro_types/Cargo.toml @@ -9,7 +9,7 @@ readme = "README.md" categories = ["caching"] keywords = ["caching", "cache", "memoize", "cached"] license = "MIT" -edition = "2021" -rust-version = "1.80" +edition = "2024" +rust-version = "1.89" [dependencies] diff --git a/docs/dev/issue-closeout-tracker.md b/docs/dev/issue-closeout-tracker.md new file mode 100644 index 00000000..fc5f1c84 --- /dev/null +++ b/docs/dev/issue-closeout-tracker.md @@ -0,0 +1,811 @@ +# Issue close-out tracker + +Source of truth for the close-out pass after `260609.next-major-batch` lands. +**Do not post or close anything until that branch is merged.** + +Generated from the approved plan at `.claude/plans/joyful-tickling-bee.md`. + +--- + +## Bucket 1: close-now + +These issues are already resolved by work that landed before this batch (1.0/2.0/redb/existing +features). Close immediately after this tracker is reviewed; no batch landing required. + +| # | Title | Reporter | Resolving feature | +|---|-------|----------|-------------------| +| [#257](https://github.com/jaemk/cached/issues/257) | Interest in feature disabling use of expiring_cache / std::time? | @randombit | `time_stores` feature flag | +| [#248](https://github.com/jaemk/cached/issues/248) | Consider using `std::sync::LazyLock` instead of `once_cell::sync::Lazy` | @lbeschastny | `std::sync::LazyLock` already used | +| [#246](https://github.com/jaemk/cached/issues/246) | Dynamic entry ttl. | @ruseinov | `expires=true` / `Expires` trait | +| [#238](https://github.com/jaemk/cached/issues/238) | Question: Way to specify alternative lifetime for new entry in cached method? | @hcldan | `expires=true` / `Expires` trait | +| [#229](https://github.com/jaemk/cached/issues/229) | io_cached doesn't support sync_writes | @mharkins-cosm | `#[cached(sync_writes = "by_key")]` (concurrent stores synchronize internally) | +| [#219](https://github.com/jaemk/cached/issues/219) | `io_cached` doesn't have `result` and `option` flags | @omid | native `Result` / `Option` handling on `concurrent_cached` | +| [#217](https://github.com/jaemk/cached/issues/217) | Cached proc macro doesn't keep where clause | @gerald-pinder-omnicell | where clauses preserved | +| [#215](https://github.com/jaemk/cached/issues/215) | cached crate with proc macros broken | @demoray | resolved in 2.x | +| [#209](https://github.com/jaemk/cached/issues/209) | Cache hit / miss rate metrics when using `cached` procedural macro | @kembofly | `cache_hits` / `cache_misses` fields | +| [#206](https://github.com/jaemk/cached/issues/206) | DiskCache blobs aren't cleaned on overwrite | @RustyNova016 | redb DiskCache (no blob leaks) | +| [#197](https://github.com/jaemk/cached/issues/197) | Cache clear operation | @9999years | `cache_clear` on `Cached` trait | +| [#187](https://github.com/jaemk/cached/issues/187) | Async disk cache | @bschreck | `AsyncRedbCache` | +| [#184](https://github.com/jaemk/cached/issues/184) | 2021 edition? | @jqnatividad | Rust 2021 edition | +| [#164](https://github.com/jaemk/cached/issues/164) | Retrieve cache expiration time from cached function result | @inferrna | `expires=true` / `Expires` trait | +| [#158](https://github.com/jaemk/cached/issues/158) | sync_writes isn't working correctly when different values for function parameters are used | @0xForerunner | `sync_writes="by_key"` | +| [#144](https://github.com/jaemk/cached/issues/144) | Consider only changing patch versions when making non breaking releases | @samanpa | semver followed since 1.0 | +| [#142](https://github.com/jaemk/cached/issues/142) | Mio & Tokio causing wasm build to fail | @samdenty | optional async features | +| [#141](https://github.com/jaemk/cached/issues/141) | Cron-like cache clearing | @arxdeus | `time_stores` + manual invalidation | +| [#136](https://github.com/jaemk/cached/issues/136) | Errors seen while using AsyncRedisCache | @rajesh-blueshift | `std::sync::LazyLock` (no once_cell dep) | +| [#135](https://github.com/jaemk/cached/issues/135) | Feature Request: Ignore certain function arguments | @phayes | `convert` attribute | +| [#134](https://github.com/jaemk/cached/issues/134) | `#![feature]` may not be used on the stable release channel from thiserror | @inferrna | thiserror updated / stable | +| [#119](https://github.com/jaemk/cached/issues/119) | associated `static` items are not allowed | @gitmalong | resolved in 2.x | +| [#115](https://github.com/jaemk/cached/issues/115) | Feature Request: A cache where the value knows how to determine whether it is expired | @absoludity | `expires=true` / `Expires` trait | +| [#113](https://github.com/jaemk/cached/issues/113) | Behavior when function returns a `Result`? | @phayes | `result=true` attribute | +| [#111](https://github.com/jaemk/cached/issues/111) | `cached` compile error "expected struct / found reference" | @bkontur | resolved in 2.x | +| [#99](https://github.com/jaemk/cached/issues/99) | Cache not refreshed. | @lz1998 | `refresh` attribute | +| [#96](https://github.com/jaemk/cached/issues/96) | Compile error if function args are `mut` | @jherman3 | `mut` args supported | +| [#92](https://github.com/jaemk/cached/issues/92) | Feature request: soft and hard timeouts for Result/Option | @axos88 | `result_fallback` attribute | +| [#83](https://github.com/jaemk/cached/issues/83) | Would using RwLock instead of Mutex make sense? | @adambezecny | `sync_writes="by_key"` uses key-level locks | +| [#62](https://github.com/jaemk/cached/issues/62) | Could there be a "locked" version? | @bbigras | `sync_writes="by_key"` | +| [#58](https://github.com/jaemk/cached/issues/58) | Add more documentation around proc macros | @jaemk | extensive proc macro docs added | +| [#49](https://github.com/jaemk/cached/issues/49) | Need to serialize/deserialize the cache | @Stargateur | `DiskCache` / `RedisCache` (serde) | +| [#48](https://github.com/jaemk/cached/issues/48) | Lifetimes not added to inner fn in proc macro | @coadler | resolved in 2.x | +| [#43](https://github.com/jaemk/cached/issues/43) | make a copy of macro tests for the proc macro | @jaemk | proc macro tests exist | +| [#38](https://github.com/jaemk/cached/issues/38) | Naming on Cached trait | @Stargateur | unprefixed `cache_get`/`cache_set`/etc. aliases | +| [#254](https://github.com/jaemk/cached/issues/254) | Question: using this crate in a library I'm building for C | @hcldan | support answer | +| [#163](https://github.com/jaemk/cached/issues/163) | Any plan on Set collection? | @lzy1g1225 | stale / out of scope | + +### Drafted replies + +--- + +**#257** — @randombit + +``` +Hi @randombit, + +This is covered by the `time_stores` feature flag (opt-in since 2.0). Omitting it removes +the `std::time` dependency entirely. Closing. +``` + +--- + +**#248** — @lbeschastny + +``` +Hi @lbeschastny, + +Done. The macros now emit `std::sync::LazyLock` instead of `once_cell::sync::Lazy` — the +`once_cell` dependency was dropped entirely in 2.0. Closing. +``` + +--- + +**#246** — @ruseinov + +``` +Hi @ruseinov, + +This is supported via the `expires=true` macro attribute combined with the `Expires` trait. +Each function call can return a custom TTL by implementing `Expires` on the return type, or +by wrapping the value in `ExpiresValue`. See the `expires_per_key` example. Closing. +``` + +--- + +**#238** — @hcldan + +``` +Hi @hcldan, + +Per-entry lifetimes are supported via the `expires=true` attribute and the `Expires` trait +(or `ExpiresValue` wrapper). The TTL is determined from the returned value on each call. +Closing. +``` + +--- + +**#229** — @mharkins-cosm + +``` +Hi @mharkins-cosm, + +`io_cached` was replaced by `concurrent_cached` in 2.x, which intentionally does not take +`sync_writes` (the backing store synchronizes internally; uncached calls are not deduplicated). +Per-key deduplication of concurrent first calls is available on the in-memory path via +`#[cached(sync_writes = "by_key")]`. Closing. +``` + +--- + +**#219** — @omid + +``` +Hi @omid, + +`io_cached` was replaced by `concurrent_cached` in 2.x. `Result` handling is built in (`Ok` is +cached, `Err` is not; `cache_err = true` caches failures), and `Option` returns are supported on +the in-memory sharded path (`None` not cached; `cache_none = true` to cache it). Closing. +``` + +--- + +**#217** — @gerald-pinder-omnicell + +``` +Hi @gerald-pinder-omnicell, + +Where clauses are preserved on the generated inner function. Closing. +``` + +--- + +**#215** — @demoray + +``` +Hi @demoray, + +This was resolved in the 2.x rewrite. Closing. +``` + +--- + +**#209** — @kembofly + +``` +Hi @kembofly, + +The generated static cache has `cache_hits` and `cache_misses` fields you can read directly, +e.g. `MY_FN.read().cache_hits`. Closing. +``` + +--- + +**#206** — @RustyNova016 + +``` +Hi @RustyNova016, + +The sled-backed `DiskCache` has been replaced with a redb backend. redb's transactional +writes mean overwrites replace the entry atomically with no orphaned blobs. Closing. +``` + +--- + +**#197** — @9999years + +``` +Hi @9999years, + +`cache_clear` is a required method on the `Cached` trait and is implemented by all store +types. Closing. +``` + +--- + +**#187** — @bschreck + +``` +Hi @bschreck, + +An async disk cache (`AsyncRedbCache`) backed by redb is now available. Closing. +``` + +--- + +**#184** — @jqnatividad + +``` +Hi @jqnatividad, + +The crate has used the Rust 2021 edition since 1.0. Closing. +``` + +--- + +**#164** — @inferrna + +``` +Hi @inferrna, + +The `expires=true` attribute plus the `Expires` trait lets each function call return a +custom TTL alongside the cached value. See the `expires_per_key` example. Closing. +``` + +--- + +**#158** — @0xForerunner + +``` +Hi @0xForerunner, + +This is fixed by `sync_writes="by_key"`, which uses a per-key lock so concurrent calls +with different arguments proceed independently. Closing. +``` + +--- + +**#144** — @samanpa + +``` +Hi @samanpa, + +The crate has followed semver strictly since 1.0 — patch releases for bug fixes, minor for +additive changes, major for breaking changes. Closing. +``` + +--- + +**#142** — @samdenty + +``` +Hi @samdenty, + +Async dependencies (tokio, smol) are behind optional feature flags and are not pulled in +by default. Closing. +``` + +--- + +**#141** — @arxdeus + +``` +Hi @arxdeus, + +Time-based caches are available via the `time_stores` feature (`TtlCache`, +`LruTtlCache`, etc.). Manual invalidation is possible via `cache_remove` or +`cache_clear` on the static cache handle. Closing. +``` + +--- + +**#136** — @rajesh-blueshift + +``` +Hi @rajesh-blueshift, + +The `once_cell` dependency was removed in 2.0; the macros now use `std::sync::LazyLock` +from stable Rust. This should resolve the build errors you saw. Closing. +``` + +--- + +**#135** — @phayes + +``` +Hi @phayes, + +The `convert` attribute lets you ignore arguments that shouldn't be part of the cache key. +For example: `#[cached(convert = "{ arg_to_use.clone() }")]`. Closing. +``` + +--- + +**#134** — @inferrna + +``` +Hi @inferrna, + +The `thiserror` dependency was updated and no nightly features are required. Closing. +``` + +--- + +**#119** — @gitmalong + +``` +Hi @gitmalong, + +Associated statics in impl blocks are no longer generated; the macro uses module-level +statics. This was resolved in the 2.x rewrite. Closing. +``` + +--- + +**#115** — @absoludity + +``` +Hi @absoludity, + +The `expires=true` attribute combined with the `Expires` trait does exactly this: the +cached value itself determines its own expiry. Closing. +``` + +--- + +**#113** — @phayes + +``` +Hi @phayes, + +`result=true` unwraps `Result` — only `Ok` values are cached, `Err` values pass +through without caching. `option=true` does the same for `Option`. Closing. +``` + +--- + +**#111** — @bkontur + +``` +Hi @bkontur, + +Reference inputs are handled correctly in current versions. Closing. +``` + +--- + +**#99** — @lz1998 + +``` +Hi @lz1998, + +The `refresh = true` attribute resets the TTL on each cache hit, keeping hot entries +alive. Closing. +``` + +--- + +**#96** — @jherman3 + +``` +Hi @jherman3, + +`mut` function arguments are supported without issue. Closing. +``` + +--- + +**#92** — @axos88 + +``` +Hi @axos88, + +The `result_fallback=true` attribute supports this pattern: when the function returns `Err`, +the macro returns the previously-cached value (if any) rather than the error. Closing. +``` + +--- + +**#83** — @adambezecny + +``` +Hi @adambezecny, + +`sync_writes="by_key"` uses a per-key lock (DashMap-based sharded mutex), so concurrent +calls with different keys don't block each other. Closing. +``` + +--- + +**#62** — @bbigras + +``` +Hi @bbigras, + +`sync_writes="by_key"` serializes concurrent calls for the same key so only one fetch runs +and the rest wait for the cached result. Closing. +``` + +--- + +**#58** — @jaemk + +``` +Hi @jaemk, + +Extensive proc macro documentation is now in the crate root (`src/lib.rs`) and the README, +covering all attributes with examples. Closing. +``` + +--- + +**#49** — @Stargateur + +``` +Hi @Stargateur, + +`DiskCache` (backed by redb) and `RedisCache` both serialize/deserialize via serde, enabling +persistent caches that survive process restarts. Closing. +``` + +--- + +**#48** — @coadler + +``` +Hi @coadler, + +Lifetimes on function signatures are passed through to the inner function correctly. +Closing. +``` + +--- + +**#43** — @jaemk + +``` +Hi @jaemk, + +Proc macro tests are in `tests/cached.rs` and `tests/` alongside UI / compile-fail tests. +Closing. +``` + +--- + +**#38** — @Stargateur + +``` +Hi @Stargateur, + +The `Cached` trait now exposes unprefixed aliases (`cache_get`, `cache_set`, `cache_remove`, +`cache_clear`, `cache_size`) alongside the original names. Closing. +``` + +--- + +**#254** — @hcldan (support answer) + +``` +Hi @hcldan, + +The `cached` crate is a pure Rust library and doesn't expose a C API. For FFI use you +would need to write a thin `extern "C"` wrapper crate on top of it yourself. Closing. +``` + +--- + +**#163** — @lzy1g1225 (stale) + +``` +Hi @lzy1g1225, + +There are no plans to add a Set collection type to this crate. Closing as out of scope. +``` + +--- + +## Bucket 2: close-when-this-batch-lands + +Close these after `260609.next-major-batch` (the next major release) is merged and published. +Reply text says "fixed in the upcoming major release." + +| # | Title | Reporter | Resolved by | +|---|-------|----------|-------------| +| [#230](https://github.com/jaemk/cached/issues/230) | `key` argument name collision | @publicqi | binding hygiene (`__cached_` prefix) | +| [#114](https://github.com/jaemk/cached/issues/114) | Cannot use key as a function argument | @paulvt | binding hygiene (`__cached_` prefix) | +| [#202](https://github.com/jaemk/cached/issues/202) | proc_macro: support args which are `&T` and `Option<&T>` | @BaxHugh | reference inputs | +| [#203](https://github.com/jaemk/cached/issues/203) | Support `&T` and `Option<&T>` in input | @BaxHugh | reference inputs | +| [#157](https://github.com/jaemk/cached/issues/157) | Allow reexport | @inferrna | re-export hygiene (`proc-macro-crate`) | +| [#149](https://github.com/jaemk/cached/issues/149) | Feature Request: Floating-Point ttl | @waterlubber | `ttl_millis` attribute | +| [#146](https://github.com/jaemk/cached/issues/146) | Feature Request: bool argument that forces a cache refresh | @0xForerunner | `force_refresh` attribute | +| [#16](https://github.com/jaemk/cached/issues/16) | macro: Not working inside impl blocks | @behnam | `in_impl=true` attribute | +| [#140](https://github.com/jaemk/cached/issues/140) | Feature request: Skip self field to allow caching methods | @Serock3 | `in_impl=true` attribute | +| [#179](https://github.com/jaemk/cached/issues/179) | Unnecessary `&mut V` with `get_or_set_with` | @hanako-eo | `get_or_set_with` returns `&V` | +| [#196](https://github.com/jaemk/cached/issues/196) | Borrowed keys and values for IOCached::set_cache | @9999years | `SerializeCached` / `cache_set_ref` | +| [#195](https://github.com/jaemk/cached/issues/195) | Borrowed keys and values for `IOCached::set_cache` | @9999years | `SerializeCached` / `cache_set_ref` | +| [#231](https://github.com/jaemk/cached/issues/231) | Rustls support for Redis | @rbozan | `redis_tokio_rustls` / `redis_smol_rustls` features | +| [#200](https://github.com/jaemk/cached/issues/200) | Add `cache_clear` operation | @9999years | `cache_clear` on Redis stores | +| [#180](https://github.com/jaemk/cached/issues/180) | Ability to configure a SizedCache `size` based on runtime data | @hcldan | `LruCache::set_max_size` | +| [#260](https://github.com/jaemk/cached/issues/260) | Debian: cargo test --no-default-features | @kpcyrd | `no_run` doctest fix | +| [#78](https://github.com/jaemk/cached/issues/78) | Document how this should work on floats? | @EvanCarroll | README float/convert docs | +| [#21](https://github.com/jaemk/cached/issues/21) | Examples: cache invalidation | @flavius | `basic.rs` invalidation example | +| [#236](https://github.com/jaemk/cached/issues/236) | Add example of passing dyn trait instance | @breadrock1 | `struct_method.rs` dyn/free-fn example | +| [#80](https://github.com/jaemk/cached/issues/80) | How to approach generics? | @big-lip-bob | generic-fn error + README workaround | +| [#245](https://github.com/jaemk/cached/issues/245) | Consider creating git tags and eventually GitHub releases | @flavio | automated release tagging | +| [#237](https://github.com/jaemk/cached/issues/237) | Replace sled crate? | @jqnatividad | redb DiskCache (sled removed) | +| [#206](https://github.com/jaemk/cached/issues/206) | DiskCache blobs aren't cleaned on overwrite | @RustyNova016 | redb DiskCache (also in close-now; lands with major) | +| [#20](https://github.com/jaemk/cached/issues/20) | Add ability to store cache to disk | @cjbassi | redb `DiskCache` / `AsyncRedbCache` | + +> Note: #206 appears in both close-now (already resolved by the redb backend that is staged +> on master) and close-when-batch-lands per the plan's "also close when the major ships (redb)" +> note. It can be closed as part of either pass; once is sufficient. + +### Drafted replies + +--- + +**#230** — @publicqi + +``` +Hi @publicqi, + +Fixed in the upcoming major release. The macro now uses `__cached_`-prefixed internal +bindings, so user argument names like `key`, `result`, `cache`, and `lock` no longer +collide with macro-generated variables. +``` + +--- + +**#114** — @paulvt + +``` +Hi @paulvt, + +Fixed in the upcoming major release. Internal macro bindings are now prefixed with +`__cached_`, so a function argument named `key` (or `result`, `cache`, `lock`, etc.) no +longer shadows or collides with them. +``` + +--- + +**#202** — @BaxHugh + +``` +Hi @BaxHugh, + +Fixed in the upcoming major release. `&T` arguments are now automatically owned for the +cache key (via `.to_owned()`), and `Option<&T>` is mapped with `.map(|v| (*v).to_owned())`. +No `convert` workaround needed. +``` + +--- + +**#203** — @BaxHugh + +``` +Hi @BaxHugh, + +Fixed in the upcoming major release. `&T` and `Option<&T>` inputs are now handled +automatically in the default key derivation path. See #202 for the linked fix. +``` + +--- + +**#157** — @inferrna + +``` +Hi @inferrna, + +Fixed in the upcoming major release. The proc macro crate now uses `proc-macro-crate` to +resolve its own crate path at expansion time, so it works correctly when re-exported under +a different name. +``` + +--- + +**#149** — @waterlubber + +``` +Hi @waterlubber, + +Fixed in the upcoming major release via the new `ttl_millis` attribute. You can now write +`#[cached(ttl_millis = 500)]` for sub-second TTLs without floating-point. `ttl` (seconds) +and `ttl_millis` are mutually exclusive. +``` + +--- + +**#146** — @0xForerunner + +``` +Hi @0xForerunner, + +Added in the upcoming major release via the `force_refresh` attribute. Pass a boolean +expression block (curly braces, like `convert`) over the function arguments. For a dedicated +flag, exclude it from the key so forced and normal calls share one entry: + + #[cached(key = "u32", convert = "{ id }", force_refresh = "{ bypass }")] + fn fetch(id: u32, bypass: bool) -> Data { ... } + +When `bypass` is true the cache is skipped and the fresh result overwrites the stored entry. +``` + +--- + +**#16** — @behnam + +``` +Hi @behnam, + +Fixed in the upcoming major release via the new `in_impl = true` attribute: + + impl MyStruct { + #[cached(in_impl = true)] + fn compute(&self, key: i32) -> i32 { ... } + } + +This moves the cache static inside the function body so multiple methods can share a name +without collision. +``` + +--- + +**#140** — @Serock3 + +``` +Hi @Serock3, + +Added in the upcoming major release via `in_impl = true`. See #16 for details. +``` + +--- + +**#179** — @hanako-eo + +``` +Hi @hanako-eo, + +Fixed in the upcoming major release. `cache_get_or_set_with` and `cache_try_get_or_set_with` +(and their async counterparts) now return `&V` / `Result<&V, E>`. The previous `&mut V` +behavior is preserved in new `*_mut` variants (`cache_get_or_set_with_mut`, etc.). +``` + +--- + +**#196** — @9999years + +``` +Hi @9999years, + +Added in the upcoming major release. The new `SerializeCached` trait exposes +`cache_set_ref(&K, &V)` for stores that serialize internally (redb, Redis), avoiding the +clone that `cache_set(K, V)` requires. `SerializeCachedAsync` covers the async case. +``` + +--- + +**#195** — @9999years + +``` +Hi @9999years, + +This is addressed by #196. The new `SerializeCached` / `SerializeCachedAsync` traits +(added in the upcoming major release) accept borrowed keys and values directly. Closing +as a duplicate of #196. +``` + +--- + +**#231** — @rbozan + +``` +Hi @rbozan, + +Added in the upcoming major release. New Cargo features `redis_tokio_rustls`, +`redis_tokio_native_tls`, `redis_smol_rustls`, and `redis_smol_native_tls` let you pick +your TLS backend explicitly. The base `redis_tokio` / `redis_smol` features are now +TLS-agnostic. +``` + +--- + +**#200** — @9999years + +``` +Hi @9999years, + +Added in the upcoming major release. Both `RedisCache` and `AsyncRedisCache` now implement +`cache_clear` / `async_cache_clear`, which scan the cache's namespace prefix and delete all +matching keys. Note that this is O(n) and scoped to the cache's namespace, not a server flush. +``` + +--- + +**#180** — @hcldan + +``` +Hi @hcldan, + +Added in the upcoming major release. `LruCache` now has `set_max_size(&mut self, usize)` +(and `try_set_max_size`) to resize at runtime. If the new capacity is smaller than the +current size, the least-recently-used entries are evicted immediately. You can pass the +initial size via the `create` attribute for startup sizing: + + #[cached(create = "{ LruCache::with_size(load_config().cache_size) }")] +``` + +--- + +**#260** — @kpcyrd + +``` +Hi @kpcyrd, + +Fixed in the upcoming major release. The problematic doctest in `src/lib.rs` is now marked +`no_run` (or gated appropriately), so `cargo test --no-default-features` completes without +error. This unblocks Debian package builds. +``` + +--- + +**#78** — @EvanCarroll + +``` +Hi @EvanCarroll, + +Documented in the upcoming major release. The README now calls out floats and structs +containing floats as the canonical case for `convert`, with a short example using +`OrderedFloat` or `format!` to derive a hashable key. +``` + +--- + +**#21** — @flavius + +``` +Hi @flavius, + +Added in the upcoming major release. `examples/basic.rs` now includes an `invalidate_*` +function showing `cache_remove` to evict a single entry and demonstrating that the next +call recomputes. +``` + +--- + +**#236** — @breadrock1 + +``` +Hi @breadrock1, + +Added in the upcoming major release. `examples/struct_method.rs` now shows the canonical +workaround: extract the logic into a free `#[cached]` function and call it from the method. +A `dyn Trait` variant keyed on an object ID is also included. +``` + +--- + +**#80** — @big-lip-bob + +``` +Hi @big-lip-bob, + +Addressed in the upcoming major release. Generic functions now produce a clear compile +error from the macro, and the README documents the monomorphic-wrapper pattern as the +recommended workaround: + + fn generic(x: T) -> String { cached_inner(x.to_string()) } + #[cached] fn cached_inner(s: String) -> String { ... } +``` + +--- + +**#245** — @flavio + +``` +Hi @flavio, + +Added in the upcoming major release. The release workflow now creates a git tag (`vX.Y.Z`) +and a GitHub release with auto-generated notes after each successful publish. Older releases +(v2.0.0 through v2.0.2) have been backfilled. +``` + +--- + +**#237** — @jqnatividad + +``` +Hi @jqnatividad, + +Done in the upcoming major release. `sled` has been replaced with `redb` as the disk cache +backend. `redb` is actively maintained, compiles cleanly on current stable Rust, and uses +ACID transactions that eliminate the blob-leak bug (#206). Migration: swap +`DiskCacheBuilder` usage; the public API is unchanged. +``` + +--- + +**#20** — @cjbassi + +``` +Hi @cjbassi, + +Disk caching has been available for a while and has been significantly improved in the +upcoming major release. `DiskCache` (sync) and `AsyncRedbCache` (async) are backed by redb +and support TTL, namespacing, and serde serialization. Closing. +``` + +--- + +## Bucket 3: defer + +These are kept open. No reply drafted. A short note on why each is deferred. + +| # | Title | Reporter | Note | +|---|-------|----------|------| +| [#239](https://github.com/jaemk/cached/issues/239) | Speed up compilation by replacing `darling` with `attrs` | @aatifsyed | Deferred to a later minor: meaningful compile-time improvement but large churn across all three macro arg structs; no user-visible breakage either way. | +| [#147](https://github.com/jaemk/cached/issues/147) | Update cached value asynchronously, outside the thread that returns the value | @kpears201 | Deferred: stale-while-revalidate (SWR) cluster (#147/#233/#228/#91) is a significant feature requiring a background task runtime; scoped out of this batch. | +| [#233](https://github.com/jaemk/cached/issues/233) | Using old cache while new data is being fetched | @NCura | Deferred: SWR cluster — same as #147. | +| [#228](https://github.com/jaemk/cached/issues/228) | stale-while-revalidate feature | @mharkins-cosm | Deferred: SWR cluster — same as #147. | +| [#91](https://github.com/jaemk/cached/issues/91) | Auto-refresh when remaining TTL is below a threshold | @gitmalong | Deferred: SWR cluster — same as #147. | +| [#222](https://github.com/jaemk/cached/issues/222) | Compression support | @buinauskas | Deferred to a later minor: adds a dependency and serialization complexity; no strong demand signal yet. | +| [#220](https://github.com/jaemk/cached/issues/220) | Add support with `moka`? | @xuxiaocheng0201 | Deferred to a later minor: moka is a quality cache but adding it as a backend is a significant integration effort. | +| [#32](https://github.com/jaemk/cached/issues/32) | adaptive replacement cache | @dvc94ch | Deferred to a later minor: ARC eviction policy is non-trivial and no maintained Rust ARC crate is a clear choice. | +| [#64](https://github.com/jaemk/cached/issues/64) | Supporting references | @szunami | Deferred: returning references into the cache is fundamentally a lifetime/borrow-checker problem that would require unsafe or an `Arc`-based API redesign. | +| [#188](https://github.com/jaemk/cached/issues/188) | Add helper attribute to ignore arguments | @ModProg | Deferred to a later minor: `convert` already covers this use case; a dedicated `ignore` attribute is a convenience improvement with no urgency. | diff --git a/docs/migrations/2.0-to-unreleased.md b/docs/migrations/2.0-to-unreleased.md index 45c20fa7..a7b4e5f7 100644 --- a/docs/migrations/2.0-to-unreleased.md +++ b/docs/migrations/2.0-to-unreleased.md @@ -38,10 +38,35 @@ store.cache_set(k, v).await?; let v = store.async_cache_get(&k).await?; store.async_cache_set(k, v).await?; ``` -The synchronous `ConcurrentCached` methods (`cache_get`/`cache_set`/…) are unchanged. The sync config methods on the async trait (`ttl`/`set_ttl`/`unset_ttl`/`set_refresh_on_hit`) are unchanged. This rename lets you import both `ConcurrentCached` and `ConcurrentCachedAsync` without `E0034`. +The synchronous `ConcurrentCached` methods (`cache_get`/`cache_set`/…) are unchanged. This rename, together with the trait split below, lets you import both `ConcurrentCached` and `ConcurrentCachedAsync` without `E0034`. If you have a custom `impl ConcurrentCachedAsync for YourStore`, rename the methods in the impl block too (`fn cache_get` → `fn async_cache_get`, etc.); otherwise the impl no longer matches the trait and fails to compile. +### 2b. Concurrent introspection/TTL helpers moved to `ConcurrentCacheBase` / `ConcurrentCacheTtl` + +`ConcurrentCached` and `ConcurrentCachedAsync` used to each declare the same synchronous helpers (`cache_size`, `len`, `is_empty`, `ttl`, `set_ttl`, `unset_ttl`, `refresh_on_hit`, `set_refresh_on_hit`). On a store implementing *both* traits (`RedbCache`, every `Sharded*` store), calling one of those through method syntax with both traits in scope (as `cached::prelude::*` brings them) produced `error[E0034]: multiple applicable items in scope`. + +Those helpers now live on two new shared traits: +- `ConcurrentCacheBase` — owns `type Error` and the introspection methods (`cache_size`, `len`, `is_empty`). It is the supertrait of both `ConcurrentCached` and `ConcurrentCachedAsync` (mirroring how the single-owner `Cached` core is shared). +- `ConcurrentCacheTtl` — owns the global-TTL controls (`ttl`, `set_ttl`, `unset_ttl`, `refresh_on_hit`, `set_refresh_on_hit`) plus a new validated `try_set_ttl(Duration) -> Result, SetTtlError>` that rejects a zero `Duration` with `SetTtlError::ZeroTtl`. Only the TTL-capable concurrent stores implement it: `ShardedTtlCache`, `ShardedLruTtlCache`, `RedisCache`, `AsyncRedisCache`, `RedbCache`. Non-TTL sharded stores (`ShardedUnboundCache`, `ShardedLruCache`, `ShardedExpiringCache`, `ShardedExpiringLruCache`) no longer expose `set_ttl`/`ttl`/etc. + +Both new traits are re-exported from the crate root and from `cached::prelude`. + +Action: +- Callers using `cached::prelude::*`: no change — both new traits come in with the glob. +- Callers importing the concurrent traits individually (e.g. `use cached::ConcurrentCached;`) and calling `cache_size`/`len`/`is_empty` or `set_ttl`/`ttl`/`unset_ttl`/`set_refresh_on_hit`: add `ConcurrentCacheBase` and/or `ConcurrentCacheTtl` to the import. +- Custom `impl ConcurrentCached`/`ConcurrentCachedAsync` for your own store: move the `type Error` (and any `cache_size`/`len`/`is_empty` override) into a single `impl ConcurrentCacheBase for YourStore` block, and any TTL behavior into `impl ConcurrentCacheTtl for YourStore`: + ```rust + use cached::{ConcurrentCacheBase, ConcurrentCached}; + + impl ConcurrentCacheBase for MyStore { + type Error = std::convert::Infallible; + } + impl ConcurrentCached for MyStore { + // cache_get / cache_set / cache_remove / cache_remove_entry / cache_clear / cache_reset ... + } + ``` + ### 3. `CacheTtl` / `CacheEvict` removed from concurrent stores `CacheTtl` and `CacheEvict` are now single-owner (`&mut self`) traits, no longer implemented for `DiskCache`, `RedisCache`, `AsyncRedisCache`, or the sharded stores. @@ -49,19 +74,20 @@ If you have a custom `impl ConcurrentCachedAsync for YourStore`, rename the meth Detection: grep for `CacheTtl::set_ttl`/`set_ttl(&mut` and `CacheEvict::evict`/`evict(&mut` on a sharded/disk/redis store. Action: -- TTL on a concurrent store: use the `&self` methods from `ConcurrentCached` / `ConcurrentCachedAsync` (already present): +- TTL on a concurrent store: use the `&self` methods from `ConcurrentCacheTtl`: ```rust + use cached::ConcurrentCacheTtl; // Before CacheTtl::set_ttl(&mut store, Duration::from_secs(30)); // After - ConcurrentCached::set_ttl(&store, Duration::from_secs(30)); // works through Arc/static + ConcurrentCacheTtl::set_ttl(&store, Duration::from_secs(30)); // works through Arc/static ``` - Eviction on a sharded store: use the new `ConcurrentCacheEvict` trait or the inherent `evict(&self)`: ```rust use cached::ConcurrentCacheEvict; let n = store.evict(); // &self ``` - `ConcurrentCacheEvict` is implemented only by the four TTL/expiring sharded stores (`ShardedTtlCache`, `ShardedLruTtlCache`, `ShardedExpiringCache`, `ShardedExpiringLruCache`). `ShardedCache` and `ShardedLruCache` have no expiry and never implemented `CacheEvict`, so they gain nothing here. + `ConcurrentCacheEvict` is implemented only by the four TTL/expiring sharded stores (`ShardedTtlCache`, `ShardedLruTtlCache`, `ShardedExpiringCache`, `ShardedExpiringLruCache`). `ShardedUnboundCache` and `ShardedLruCache` have no expiry and never implemented `CacheEvict`, so they gain nothing here. Single-owner in-memory stores (`TtlCache`, `LruTtlCache`, `TtlSortedCache`, `ExpiringCache`, `ExpiringLruCache`) keep `CacheTtl`/`CacheEvict` unchanged. ### 4. `CacheMetrics.size` renamed to `entry_count` @@ -84,10 +110,11 @@ Action: rename the builder method call to `.refresh_on_hit(`. The `#[cached(refr - Action: - Remove any use of `DiskCacheBuilder::connection_config(...)` and the `connection_config = "..."` `#[concurrent_cached]` attribute (removed). - Remove any use of `DiskCache::connection()` / `connection_mut()` (removed; the redb handle is not exposed). - - `RedbCacheError::Storage(sled::Error)` / `RedbCacheBuildError::Connection(sled::Error)` now wrap `redb::Error`; `RedbCacheBuildError` has a new `Io` variant and no longer has the (never-constructed) `MissingDiskPath` variant. Update exhaustive matches. + - `RedbCacheError::Storage` / `RedbCacheBuildError::Storage` now wrap `redb::Error` (both are struct variants with a `source` field, see #7); `RedbCacheBuildError` has a new `Io` variant and no longer has the (never-constructed) `MissingDiskPath` variant. Update exhaustive matches. - `durable` defaults to `true` (durable, fsync per write). Set `false` to trade durability for write throughput: writes then use `Durability::None` and are not persisted until a later durable commit (so they can be lost on process exit or crash); call `RedbCache::flush()` / `async_flush()` to force one. Note that this is a change in default: the sled-backed store defaulted to non-durable (no fsync per write). If you relied on that fast default, set `durable(false)` (or the `durable = false` macro attribute) to keep the old write-throughput behavior. - The builder method and `#[concurrent_cached]` attribute `sync_to_disk_on_cache_change` were renamed to `durable` (detection: grep for `sync_to_disk_on_cache_change`; action: rename to `durable`). - Concurrency: redb is single-writer, so write operations on one `RedbCache` are serialized (reads stay concurrent via MVCC), whereas sled allowed concurrent writers. This is fine for read-heavy caching; for write-heavy concurrent use, spread load across multiple `RedbCache` instances, each with a distinct cache name (redb holds an exclusive lock on its file, so two instances cannot share one name/path). No code change required. Through the `#[concurrent_cached(disk = true)]` macro the store builder panics on build failure, so failing to acquire that exclusive file lock (for example a second concurrent process of the same binary sharing the default path) surfaces as a panic at the first cache call rather than through your function's `Result`. Give each process a distinct `disk_dir` / cache name if they must run concurrently. +- Cache name validation: `cache_name` is used as a filename component and is now validated to reject a path separator (`/` or `\`) or a path-traversal component (`.` or `..`), which would otherwise silently create nested subdirectories or escape the cache directory. A `:` is still allowed (it is established usage in module-path-derived names). If you pass a dynamic `cache_name` (e.g. via `RedbCache::builder(name)` or the `name = "..."` macro attribute), avoid embedding path separators. Detection: a `RedbCacheBuildError::InvalidCacheName` at cache construction time if the name contains a forbidden component. - `RedbCache::remove_expired_entries` now returns `Result` (the number of entries swept) instead of `Result<(), _>`. Detection: a type mismatch on the old `()` return. Action: bind or ignore the count (`let _ = cache.remove_expired_entries()?;`). ### 7. Store error enums: variants renamed and `#[non_exhaustive]` @@ -98,20 +125,881 @@ Detection: grep for matches on `RedbCacheError`, `RedbCacheBuildError`, `RedisCa Action: - Rename matched variants: - - `RedbCacheError`: `StorageError` → `Storage`, `CacheDeserializationError` → `CacheDeserialization`, `CacheSerializationError` → `CacheSerialization` (`BackgroundTaskFailed` unchanged). - - `RedbCacheBuildError`: `ConnectionError` → `Connection`. + - `RedbCacheError`: `StorageError` → `Storage`, `CacheDeserializationError` → `CacheDeserialization`, `CacheSerializationError` → `CacheSerialization` (`BackgroundTaskFailed` removed, see #38). + - `RedbCacheBuildError`: `ConnectionError` → `Storage` (the variant names the backend, matching `RedbCacheError::Storage`; redb has no connection). - `RedisCacheError`: `RedisCacheError` → `Redis`, `PoolError` → `Pool`, `CacheDeserializationError` → `CacheDeserialization`, `CacheSerializationError` → `CacheSerialization`. - `RedisCacheBuildError` was already suffix-free; no change. +- The `RedbCacheError` and `RedbCacheBuildError` variants are now struct variants (named fields) matching the redis enums, so tuple patterns like `CacheSerialization(e)` become `CacheSerialization { source }`. The serialize/deserialize variants on both backends carry the MessagePack error types (see #46): `CacheSerialization { source: rmp_serde::encode::Error }` and `CacheDeserialization { source: rmp_serde::decode::Error, cached_value: Vec }` (the raw bytes that failed to decode). `RedisCacheError`'s serialize/deserialize variants change from `serde_json::Error` to the same `rmp_serde` types. - These enums (plus `cached::stores::BuildError` and the `TtlSortedCache` error) are now `#[non_exhaustive]`, so an exhaustive `match` must add a wildcard arm (`_ => ...`). -## New APIs (additive — no action required) +### 8. Redis TLS features split + +`redis_tokio` and `redis_smol` no longer imply native-tls; they now enable the TLS-agnostic +`redis/tokio-comp` / `redis/smol-comp` connection paths. + +Detection: a `rediss://` (TLS) Redis URL that previously connected now fails, or a build/link +error about a missing TLS connector. This includes users who relied solely on `redis_async_cache` +(which previously implied `native-tls` and no longer does). + +Action: add a TLS backend feature alongside the runtime feature: +- Tokio + system TLS: `redis_tokio_native_tls`; Tokio + rustls: `redis_tokio_rustls`. +- smol + system TLS: `redis_smol_native_tls`; smol + rustls: `redis_smol_rustls`. + +`redis_async_cache` is now also TLS-agnostic (pulls `redis_tokio` + `redis/cache-aio`). Add +`redis_tokio_native_tls` or `redis_tokio_rustls` alongside it if TLS is required. +`redis_connection_manager` is unchanged from 2.x. + +### 9. `RedisCacheBuilder::build()` / `AsyncRedisCacheBuilder::build()` return `EmptyScope` when namespace and prefix are both empty + +`EmptyScope` fires only when the namespace (after trimming all trailing `:` characters) resolves to an empty string AND the prefix is also empty. The default namespace is `"cached-redis-store:"`, which trims to the non-empty `"cached-redis-store"`, so a plain `RedisCacheBuilder::new("", ttl)` (empty prefix, default namespace) does NOT trigger this error. `EmptyScope` is only reachable if you explicitly set the namespace to `""` (or an all-colon string such as `":::"`) AND leave the prefix empty. + +Detection: a `build()` call returns `Err(RedisCacheBuildError::EmptyScope)` at runtime, or (on the `redis = true` macro path, where the builder is called internally) a panic at the first cache call. + +Action: set a non-empty namespace (`.namespace("my_ns")`) or a non-empty prefix (`.prefix("my_prefix")`) on the builder before calling `build()`. The `#[concurrent_cached(redis = true)]` macro uses the function name as the cache prefix by default, so macro-generated caches are unaffected; only hand-constructed builders that explicitly set the namespace to empty AND leave the prefix empty are impacted. + +### 10. `get_or_set_with` family returns `&V` instead of `&mut V` + +`Cached::cache_get_or_set_with` / `cache_try_get_or_set_with` (and their `get_or_set_with` / +`try_get_or_set_with` aliases) and `CachedAsync::async_get_or_set_with` / +`async_try_get_or_set_with` now return `&V` / `Result<&V, E>` (previously `&mut V` / +`Result<&mut V, E>`). + +Detection: a compile error mutating the returned reference, or a type mismatch binding it as `&mut V`. A custom `impl Cached` will fail with a missing-required-method error for `cache_get_or_set_with_mut` and `cache_try_get_or_set_with_mut` (not a mutation error on the call site). `CachedAsync::async_get_or_set_with` now returns `&V` / `Result<&V, E>`; the new `async_get_or_set_with_mut` preserves the `&mut V` / `Result<&mut V, E>` behavior. + +Action: if you only read the value, most call sites need no change (a `&mut V` reborrows to `&V` +implicitly). The exception is a binding that explicitly names the old type (e.g. +`let v: &mut V = cache.get_or_set_with(...)`) or a pattern expecting `&mut V`: drop the `mut` from +the annotation. To actually mutate, switch to the new `*_mut` variants (`cache_get_or_set_with_mut`, +`cache_try_get_or_set_with_mut`, `get_or_set_with_mut`, `try_get_or_set_with_mut`, +`async_get_or_set_with_mut`, `async_try_get_or_set_with_mut`). Custom `impl`s of `Cached` / +`CachedAsync` must update the method signatures and implement the new required methods: +`cache_get_or_set_with_mut` and `cache_try_get_or_set_with_mut` (and, for `CachedAsync`, +`async_get_or_set_with_mut` and `async_try_get_or_set_with_mut`). The `get_or_set_with_mut` / +`try_get_or_set_with_mut` aliases and the non-`mut` `&V` methods are provided as defaults. + +### 11. `new()` added to in-memory stores; `new()` removed from I/O stores + +**In-memory stores** (`UnboundCache`, `LruCache`, `TtlCache`, `LruTtlCache`, `TtlSortedCache`, +`ExpiringCache`, `ExpiringLruCache`, and the six sharded variants) now have a `new()` (or +`new(required_field)`) constructor that returns a ready-to-use cache. This is additive: `builder()` +still exists and is preferred when you need non-default configuration. + +```rust +// Now valid — previously these stores had no ::new() +let cache: UnboundCache = UnboundCache::new(); +let cache: LruCache = LruCache::new(100); // max_size required +let cache: TtlCache = TtlCache::new(Duration::from_secs(60)); // ttl required +let cache: LruTtlCache = LruTtlCache::new(100, Duration::from_secs(60)); +``` + +**I/O stores** (`RedbCache`, `RedisCache`, `AsyncRedisCache`) previously had a `new(...)` that +returned a *builder*, which conflicted with the convention that `new()` returns a ready store. +That `new()` is now removed. Use `builder(...)` directly. + +```rust +// Before +let builder = RedbCache::::new("my-cache"); +// After +let builder = RedbCache::::builder("my-cache"); + +// Before +let builder = RedisCache::::new("prefix", ttl); +// After +let builder = RedisCache::::builder("prefix", ttl); + +// Before +let builder = AsyncRedisCache::::new("prefix", ttl); +// After +let builder = AsyncRedisCache::::builder("prefix", ttl); +``` + +Detection: grep for `RedbCache::new(`, `RedisCache::new(`, `AsyncRedisCache::new(`. + +Action: replace `::new(` with `::builder(` on the cache types `RedbCache` / `RedisCache` / +`AsyncRedisCache` only. The `*Builder::new` constructors (`RedbCacheBuilder::new`, +`RedisCacheBuilder::new`, `AsyncRedisCacheBuilder::new`) still exist and are unchanged, so scope +the change to the three cache types and do not rewrite those. The rest of the builder chain +(`.ttl(...)`, `.build()`, etc.) is unchanged. + +### 12. Macro `ttl` attribute changed: `ttl_secs`, `ttl_millis`, or Duration expression + +The `#[cached]` / `#[once]` / `#[concurrent_cached]` macro attribute `ttl = ` (whole +seconds as a bare integer) is removed. Three mutually exclusive forms replace it: + +| Attribute | Meaning | +|---|---| +| `ttl_secs = N` | N whole seconds (replaces the old `ttl = N` integer form) | +| `ttl_millis = N` | N milliseconds (new in this release) | +| `ttl = "Duration::from_secs(N)"` | arbitrary Duration expression (string literal) | + +Using more than one of these on the same annotation, or combining any of them with `expires`, is +a compile error. `ttl_secs` and `ttl_millis` must be >= 1. + +Using the old `ttl = ` form now produces an error like: +"`ttl` now takes a Duration expression (e.g. `ttl = \"Duration::from_secs(60)\"`); for whole +seconds use `ttl_secs = 60`, for milliseconds use `ttl_millis = 500`." + +**Detection:** grep for `ttl = ` inside `#[cached(...)]` / `#[once(...)]` / `#[concurrent_cached(...)]` +where the value is a bare integer. + +**Action — `#[cached]`:** +```rust +// Before +#[cached(ttl = 60)] +fn fetch_data(id: u32) -> String { ... } + +// After: option A - whole seconds (preferred) +#[cached(ttl_secs = 60)] +fn fetch_data(id: u32) -> String { ... } + +// After: option B - Duration expression +#[cached(ttl = "cached::time::Duration::from_secs(60)")] +fn fetch_data(id: u32) -> String { ... } +``` +Note: option B inlines the expression verbatim, so `Duration` must be in scope at the +call site (use the fully-qualified path above, or add `use cached::time::Duration;`). +Option A (`ttl_secs`) needs no import. + +**Action — `#[once]`:** +```rust +// Before +#[once(ttl = 30)] +fn config() -> Config { ... } + +// After +#[once(ttl_secs = 30)] +fn config() -> Config { ... } +``` + +**Action — `#[concurrent_cached]`:** +```rust +// Before +#[concurrent_cached(ttl = 120)] +async fn fetch(id: u32) -> Result { ... } + +// Before (combined with max_size) +#[concurrent_cached(max_size = 500, ttl = 120)] +async fn fetch(id: u32) -> Result { ... } + +// After +#[concurrent_cached(ttl_secs = 120)] +async fn fetch(id: u32) -> Result { ... } + +#[concurrent_cached(max_size = 500, ttl_secs = 120)] +async fn fetch(id: u32) -> Result { ... } +``` + +**Builder `.ttl(Duration)` is unchanged.** The Rust method `.ttl(Duration::from_secs(60))` on a +builder still works. Additionally, builders now have convenience methods `.ttl_secs(n)` and +`.ttl_millis(n)` that map to `Duration::from_secs(n)` / `Duration::from_millis(n)`. All three +builder methods target the same underlying field; whichever is called last wins. + +```rust +// All equivalent at the builder level: +TtlCache::builder() + .ttl(Duration::from_secs(60)) + .build()?; + +TtlCache::builder() + .ttl_secs(60) + .build()?; + +TtlCache::builder() + .ttl_millis(500) + .build()?; +``` + +Note: the builder-level `.ttl_secs` / `.ttl_millis` methods do not enforce mutual exclusion at +compile time (last call wins). The three-way mutual exclusion is a macro attribute constraint only. + +### 13. Short method aliases moved to `CachedExt` / `ConcurrentCachedExt` + +The `Cached` and `ConcurrentCached` traits keep only the `cache_`-prefixed methods (`cache_get`, +`cache_set`, `cache_remove`, `cache_clear`, `cache_size`, ...). The short aliases (`get`, `set`, +`remove`, `clear`, `len`, `is_empty`, `delete`, `try_set`, `contains`, `hits`, `misses`, +`metrics`, and the short `get_or_set_with` family) now live on blanket extension traits +`CachedExt` / `ConcurrentCachedExt`, implemented for every `Cached` / `ConcurrentCached` type. +This shrinks the surface a custom store must implement (only the `cache_`-prefixed methods) while +keeping every caller-facing name. + +Detection: `no method named get`/`set`/`len`/... found on a cache value, where the short alias +previously resolved through `Cached` / `ConcurrentCached`. + +Action: bring the extension trait into scope. `use cached::prelude::*;` imports both the core and +extension traits. Otherwise add `use cached::CachedExt;` (or `use cached::ConcurrentCachedExt;`) +alongside the core trait, or call the `cache_`-prefixed method on the core trait directly. A +custom `impl Cached` / `impl ConcurrentCached` no longer implements the short aliases (they are +provided by the blanket impl); remove any short-alias methods from such impls. + +### 14. Sharded `*Base` `new()` / `builder()` constrained to the default hasher + +`new()` and `builder()` on each sharded `*Base` type (`ShardedUnboundCacheBase`, `ShardedLruCacheBase`, +`ShardedTtlCacheBase`, `ShardedLruTtlCacheBase`, `ShardedExpiringCacheBase`, +`ShardedExpiringLruCacheBase`) are now defined only on the default-hasher specialization +(`*Base`, i.e. the named alias). Previously they were on the fully +generic `*Base` impl but always returned a `DefaultShardHasher` builder, so a +`*Base::<_, _, CustomHasher>::builder()` (or `::new()`) turbofish compiled but silently dropped +the custom hasher. + +Detection: a `*Base::<_, _, H>::new()` / `::builder()` turbofish with an explicit non-default `H` +now fails to compile (`E0599`, with a note that the function "was found for `ShardedUnboundCacheBase`"). + +Action: a custom hasher is specified on the builder, not the constructor's type parameter: + +```rust +// Before (compiled, but the turbofish hasher was ignored - the cache used DefaultShardHasher): +let cache = ShardedLruCacheBase::::builder() + .max_size(1000) + .build() + .unwrap(); + +// After: start from the default-hasher builder and call .hasher(h), which switches the +// builder's hasher type so the built cache actually uses MyHasher: +let cache = ShardedLruCache::::builder() + .hasher(MyHasher::default()) + .max_size(1000) + .build() + .unwrap(); +``` + +Only the misleading `*Base::<_, _, H>::new()` / `::builder()` turbofish form is affected. Calls +through the named alias (`ShardedLruCache::builder()`, the default `*Base::::builder()` with +`H` defaulted) and the `.hasher(h)` path are unchanged. + +### 15. `cache_clear` / `cache_reset` are now required on the concurrent traits + +Only affects custom `impl ConcurrentCached` / `impl ConcurrentCachedAsync` for your own store. Their previous no-op `Ok(())` defaults silently did nothing; every built-in store already overrides them. + +Detection: a custom concurrent-store impl that does not define `cache_clear` / `cache_reset` (or `async_cache_clear` / `async_cache_reset`) now fails with `E0046` ("not all trait items implemented"). + +Action: implement them. +```rust +impl ConcurrentCached for MyStore { + // ...existing methods... + fn cache_clear(&self) -> Result<(), Self::Error> { self.0.lock().unwrap().clear(); Ok(()) } + fn cache_reset(&self) -> Result<(), Self::Error> { self.0.lock().unwrap().clear(); Ok(()) } +} +``` +`cache_reset_metrics` keeps its no-op default, so you only add it if your store tracks metrics. + +### 16. `cache_peek_with_expiry_status` is now required on `CloneCached` / `ConcurrentCloneCached` + +Only affects custom expiry-capable stores that implement these traits. The old provided defaults returned a wrong result (`(None, false)`, or a side-effecting delegate) that silently broke `force_refresh` + `result_fallback`. + +Detection: a custom `CloneCached` / `ConcurrentCloneCached` impl without `cache_peek_with_expiry_status` now fails with `E0046`. + +Action: implement a genuinely side-effect-free peek (no LRU promotion, no counter change, no TTL renewal) that still reports `(Some(v), true)` for a present-but-expired entry. + +### 17. `ShardedCache` renamed to `ShardedUnboundCache` + +Detection: grep for `ShardedCache`, `ShardedCacheBase`, `ShardedCacheBuilder`. + +Action: rename to `ShardedUnboundCache` / `ShardedUnboundCacheBase` / `ShardedUnboundCacheBuilder`. There is no deprecated alias. +```rust +// Before +let c: ShardedCache = ShardedCache::new(); +// After +let c: ShardedUnboundCache = ShardedUnboundCache::new(); +``` +The `#[concurrent_cached]` macro (no `max_size`/`ttl`/`expires`) still selects this store automatically; only the type name changed. + +### 18. `ttl_sorted::Error` / `TtlSortedCacheError` removed; use `CacheSetError` + +Detection: grep for `ttl_sorted::Error` or `TtlSortedCacheError`. + +Action: both names are gone. `TtlSortedCache` now uses the shared `cached::CacheSetError` +(variant `TimeBounds`), the same error type as `TtlCache` and `LruTtlCache`. Replace any +reference to either old name with `CacheSetError`. The unused +`From for std::io::Error` conversion is also removed; if you relied on `?` +turning this error into an `io::Error`, convert explicitly. +```rust +// Before +use cached::TtlSortedCacheError; +let _: Result, TtlSortedCacheError> = cache.try_set(k, v); +// After +use cached::CacheSetError; +let _: Result, CacheSetError> = cache.try_set(k, v); +``` + +### 19. `unbound` macro attribute removed + +Detection: grep for `unbound` inside `#[cached(...)]`. + +Action: remove it. A bare `#[cached]` (no `max_size`/`ttl`/`expires`) already builds an `UnboundCache`, so `#[cached(unbound)]` was redundant. The macro emits a migration error if it is still present. + +### 20. Inherent `refresh_on_hit` / `set_refresh_on_hit` removed from `TtlCache` / `LruTtlCache` + +The inherent methods shadowed the `CacheTtl` trait methods, and the inherent setter returned `()` instead of the previous value. + +Detection: a `.refresh_on_hit()` / `.set_refresh_on_hit()` call on a `TtlCache` / `LruTtlCache` value (not the builder) that fails to resolve, or that previously discarded the setter's `()` return. + +Action: bring `CacheTtl` into scope; the trait setter returns the previous `bool`. +```rust +use cached::CacheTtl; +let prev = cache.set_refresh_on_hit(true); // now returns the previous flag +``` +The builder method `TtlCacheBuilder::refresh_on_hit(self, bool) -> Self` is unchanged. + +### 21. `wasm` cargo feature removed + +Detection: grep your `Cargo.toml` for `"wasm"` in the `cached` features list. + +Action: remove it. It gated nothing - `web-time` provides wasm-compatible time types transparently, so no opt-in feature is needed for wasm targets. + +### 22. `cache_try_set` returns `CacheSetError` instead of `Box` + +Detection: grep for `cache_try_set` / `try_set` call sites that bind the error, and custom `impl Cached` blocks that override `cache_try_set`. + +Action: the error type is now the concrete `cached::CacheSetError` (a `#[non_exhaustive]` enum, variant `TimeBounds`). Match on it instead of a boxed error; for a custom `Cached` impl, change the override's return type to `Result, CacheSetError>`. +```rust +// Before +fn cache_try_set(&mut self, k: K, v: V) -> Result, Box> { ... } +// After +fn cache_try_set(&mut self, k: K, v: V) -> Result, cached::CacheSetError> { ... } +``` + +### 23. `DiskCache*` aliases removed + +Detection: grep for `DiskCache`, `DiskCacheBuilder`, `DiskCacheError`, `DiskCacheBuildError`. + +Action: rename to `RedbCache` / `RedbCacheBuilder` / `RedbCacheError` / `RedbCacheBuildError`. The backend is unchanged; only the old alias names are gone. + +### 24. `store()` accessors removed from in-memory stores + +The `store()` getters on `UnboundCache`, `TtlCache`, `LruTtlCache`, and `ExpiringLruCache` are removed (they exposed the internal backing map and leaked the internal `TimedEntry` wrapper). + +Detection: grep for `.store()` on one of those caches. + +Action: use the public `Cached` API instead - `cache_get`/`get` for lookups, `cache_size`/`len` for counts, and the `CachedIter` helpers where available. + +### 25. `ShardHasher` now requires `Clone` + +Detection: a custom `impl ShardHasher for MyHasher` where `MyHasher` is not `Clone`. + +Action: derive or implement `Clone` for your custom shard hasher. `DefaultShardHasher` already is `Clone`, so the default path is unaffected. The `deep_clone` / `copy_from` methods already required `H: Clone`, so this only formalizes an existing constraint. + +### 26. Store builders: `::builder()` takes no arguments (C1) + +`RedbCache::builder(name)`, `RedisCache::builder(prefix, ttl)`, and `AsyncRedisCache::builder(prefix, ttl)` previously accepted required fields as constructor arguments. They now all take no arguments; required fields are set via setters and validated in `build()`. + +Detection: `grep -rn 'RedbCache::builder(\|RedisCache::builder(\|AsyncRedisCache::builder('` — any call with arguments is a hit. + +Action: +```rust +// Before +let builder = RedbCache::::builder("my-cache"); +// After +let builder = RedbCache::::builder().name("my-cache"); + +// Before +let builder = RedisCache::::builder("my_prefix", Duration::from_secs(60)); +// After +let builder = RedisCache::::builder() + .prefix("my_prefix") + .ttl(Duration::from_secs(60)); + +// Before +let builder = AsyncRedisCache::::builder("my_prefix", Duration::from_secs(60)); +// After +let builder = AsyncRedisCache::::builder() + .prefix("my_prefix") + .ttl(Duration::from_secs(60)); +``` + +If a required field is missing, `build()` now returns `Err(BuildError::MissingRequired(field_name))` rather than panicking or failing at construction time. + +### 27. `CachedAsync` trait method renames (I1) + +The four `async_get_or_set_with*` methods on the `CachedAsync` trait are renamed to include the `cache_` namespace infix, and the four shorthand methods `get_async` / `set_async` / `remove_async` / `clear_async` are renamed to the `async_cache_*` form. Every `CachedAsync` method now uses the `async_cache_*` namespace. `ConcurrentCachedAsync` is unchanged. + +Detection: `grep -rn 'async_get_or_set_with\|async_try_get_or_set_with\|get_async\|set_async\|remove_async\|clear_async'` (exclude hits that already start with `async_cache_`). + +Action: add `cache_` after the `async_` prefix. +```rust +// Before +cache.async_get_or_set_with(key, || async { value }).await +// After +cache.async_cache_get_or_set_with(key, || async { value }).await + +// Before +cache.async_get_or_set_with_mut(key, || async { value }).await +// After +cache.async_cache_get_or_set_with_mut(key, || async { value }).await + +// Before +cache.async_try_get_or_set_with(key, || async { Ok(value) }).await +// After +cache.async_cache_try_get_or_set_with(key, || async { Ok(value) }).await + +// Before +cache.async_try_get_or_set_with_mut(key, || async { Ok(value) }).await +// After +cache.async_cache_try_get_or_set_with_mut(key, || async { Ok(value) }).await + +// Before +cache.get_async(&key).await; cache.set_async(key, value).await; +// After +cache.async_cache_get(&key).await; cache.async_cache_set(key, value).await; +``` + +Custom `impl CachedAsync` blocks must rename the method definitions to match. + +### 28. Error vocabulary: `BuildError::InvalidTtl` removed; `*BuildError::InvalidTtl` renamed (I4+I5) + +`BuildError::InvalidTtl { ttl }` is removed. A zero TTL at build time now yields `BuildError::InvalidValue { field: "ttl", reason: "must be greater than zero" }`. + +`RedisCacheBuildError::InvalidTtl` and `RedbCacheBuildError::InvalidTtl` are renamed to `RedisCacheBuildError::Build(BuildError)` and `RedbCacheBuildError::Build(BuildError)`. + +Detection: `grep -rn 'BuildError::InvalidTtl\|RedisCacheBuildError::InvalidTtl\|RedbCacheBuildError::InvalidTtl'` + +Action: +```rust +// Before +Err(BuildError::InvalidTtl { ttl }) => { ... } +// After +Err(BuildError::InvalidValue { field, reason }) if field == "ttl" => { ... } + +// Before +Err(RedisCacheBuildError::InvalidTtl(_)) => { ... } +// After +Err(RedisCacheBuildError::Build(BuildError::InvalidValue { .. })) => { ... } + +// Before +Err(RedbCacheBuildError::InvalidTtl(_)) => { ... } +// After +Err(RedbCacheBuildError::Build(BuildError::InvalidValue { .. })) => { ... } +``` + +### 29. `set_ttl(Duration::ZERO)` disables expiry for future inserts only (I2) + +A zero `Duration` passed to `set_ttl` now means "expiry disabled" -- exactly equivalent to `unset_ttl()`, with future-inserted entries never expiring. This is uniform across every `set_ttl` surface: the sharded `ShardedTtlCache` / `ShardedLruTtlCache`, the single-owner `TtlCache` / `LruTtlCache` / `TtlSortedCache`, and `RedisCache` / `AsyncRedisCache`. + +- Previously the sharded stores **panicked** on `Duration::ZERO`; they no longer do. +- Previously a zero TTL meant "expire immediately" on the single-owner and Redis stores; it now means "never expire". +- For the Redis stores, a disabled TTL writes keys **without any expiry** (a plain `SET` instead of `SETEX`), and the refresh-on-hit path issues no `EXPIRE`. Re-arm expiry with `set_ttl(nonzero)`. +- `build()` still rejects a zero TTL (`BuildError::InvalidValue` / `MissingRequired`), and `CacheTtl::try_set_ttl(0)` still returns `Err(SetTtlError::ZeroTtl)` -- these are the strict "give me a real ttl" paths. +- `TtlSortedCache` now matches the other TTL stores: a zero TTL disables expiry for future inserts (previously it meant immediate expiry). Its per-entry expiry is now `Option` (`None` = never expires), ordered so never-expiring entries are evicted last under a size cap. +- Because TTL stores now track per-entry expiry (#32 below), `set_ttl` applies to future inserts only. Entries already in the store keep their computed expiry; `set_ttl(0)` / `unset_ttl()` does not retroactively strip expiry from existing entries. + +Detection: `grep -rn 'set_ttl'` for call sites that pass a `Duration` that could be zero and relied on either the panic (sharded) or the immediate-expiry behavior (single-owner / Redis). + +Action: if you relied on a zero TTL expiring entries immediately, that behavior is gone -- pass a small non-zero `Duration` instead. If you want a zero TTL rejected as a caller error, use `CacheTtl::try_set_ttl`, which returns `Err(SetTtlError::ZeroTtl)`. To disable expiry, call `set_ttl(0)` or `unset_ttl()`. No change is required for call sites that already pass a non-zero duration. + +### 30. `#[cached(refresh = true)]` requires a TTL (I7) + +Using `refresh = true` on `#[cached]` without a TTL (`ttl_secs`, `ttl_millis`, or `ttl`) is now a compile error. Previously it was silently ignored. + +Detection: `grep -rn 'refresh\s*=\s*true'` inside `#[cached(...)]` annotations; then check whether a TTL attribute is also present. + +Action: add a TTL attribute alongside `refresh = true`, or remove `refresh = true` if it was unintentional. +```rust +// Before (silently ignored — refresh had no effect) +#[cached(refresh = true)] +fn fetch(id: u32) -> String { ... } + +// After: either add a TTL +#[cached(ttl_secs = 60, refresh = true)] +fn fetch(id: u32) -> String { ... } + +// or remove refresh if it was not intentional +#[cached] +fn fetch(id: u32) -> String { ... } +``` + +### 31. `refresh_on_hit` / `set_refresh_on_hit` now required on `CacheTtl` / `ConcurrentCacheTtl` + +`refresh_on_hit(&self) -> bool` and `set_refresh_on_hit(refresh: bool) -> bool` are now required methods on both traits; their trait-default bodies (which returned `false`) were removed. This also fixes a latent bug: the concurrent stores (`ShardedTtlCache`, `ShardedLruTtlCache`, `RedisCache`, `AsyncRedisCache`, `RedbCache`) overrode only `set_refresh_on_hit`, so `ConcurrentCacheTtl::refresh_on_hit` returned the default `false` through trait dispatch even after `set_refresh_on_hit(true)`. The getter now reads the same flag the setter writes, so it reflects the configured value. + +Detection: a custom `impl CacheTtl for X` or `impl ConcurrentCacheTtl for X` that omitted these two methods now fails to compile with `not all trait items implemented, missing: refresh_on_hit, set_refresh_on_hit`. + +Action: implement both methods. A store that does not refresh on hit can return `false` and treat the setter as a no-op: +```rust +fn refresh_on_hit(&self) -> bool { + false +} +fn set_refresh_on_hit(&mut self, _refresh: bool) -> bool { + false +} +``` +A store that tracks the flag should read it back in the getter (e.g. `self.refresh.load(Ordering::Relaxed)` for an `AtomicBool`, matching what `set_refresh_on_hit` writes). + +### 32. TTL stores track per-entry expiry; `set_ttl` affects future inserts only + +`TtlCache`, `LruTtlCache`, `ShardedTtlCache`, and `ShardedLruTtlCache` now store the computed +expiry timestamp alongside each entry rather than recomputing it from the global TTL on every +lookup. `set_ttl` (and `unset_ttl`) changes the TTL used for future insertions; entries already +in the store keep their original expiry. + +Previously, changing the global TTL retroactively affected all entries because the expiry was +computed at lookup time from the current TTL. That behavior is gone. + +`refresh_on_hit` still works as before: on a hit it recomputes the entry's expiry from the +current TTL as of the access time. + +Detection: `grep -rn 'set_ttl\|unset_ttl'` -- call sites that relied on `set_ttl` retroactively +changing when existing entries expire. + +Action: to change the expiry of entries already in the store, re-insert them. Call sites that +only use `set_ttl` to configure the TTL before insertions are unaffected. + +### 33. `Cached` trait: `type Error` associated type required; `cache_try_set` / `try_set` return `Result, Self::Error>` + +`Cached` gained an associated `type Error`. Every `impl Cached` must declare it. The return type +of `cache_try_set` (and its `try_set` alias) changed from `Result, CacheSetError>` to +`Result, Self::Error>`. + +Built-in mappings: + +| Store | `type Error` | +|---|---| +| `UnboundCache`, `LruCache`, `ExpiringCache`, `ExpiringLruCache`, sharded stores | `std::convert::Infallible` | +| `TtlCache`, `LruTtlCache`, `TtlSortedCache` | `cached::CacheSetError` | + +Detection: +- A custom `impl Cached` without `type Error` fails with `E0046`. +- A call site that bound the error as `CacheSetError` for an infallible store fails with a type + mismatch. + +Action: +```rust +// Custom impl -- add the associated type +impl Cached for MyStore { + type Error = std::convert::Infallible; // or your error type + // ... +} + +// Call site using an infallible store -- remove the CacheSetError annotation +// Before +let _: Result, CacheSetError> = cache.try_set(k, v); +// After +let _: Result, std::convert::Infallible> = cache.try_set(k, v); +// or just let inference work: +let _ = cache.try_set(k, v); +``` + +### 34. Sharded stores expose inherent `get`/`set`/`remove`/`remove_entry`/`delete`/`reset` returning unwrapped values + +The six concrete sharded types (`ShardedUnboundCache`, `ShardedLruCache`, `ShardedTtlCache`, +`ShardedLruTtlCache`, `ShardedExpiringCache`, `ShardedExpiringLruCache`) now have inherent +methods `get`, `set`, `remove`, `remove_entry`, `delete`, and `reset` that return unwrapped +values (`Option`, `()`, `bool`) instead of `Result, Infallible>`. + +These inherent methods take priority over the same-named `ConcurrentCached` trait aliases when +called through a concrete type, so `store.get(&k)` now returns `Option` directly. + +Detection: a type mismatch where code previously matched on `Result` from a sharded store's +`get`/`set`/`remove`/`delete`/`reset`. + +Action: +```rust +// Before -- used Result because the trait alias returned Result<_, Infallible> +let v: Result, _> = store.get(&k); +// After -- inherent method returns Option directly +let v: Option = store.get(&k); +``` + +To use the `Result`-returning trait methods, call through the `ConcurrentCached` trait or use +the `cache_`-prefixed trait methods: +```rust +use cached::ConcurrentCached; +let v = ConcurrentCached::cache_get(&store, &k); // Result, Infallible> +let v = store.cache_get(&k); // also Result, Infallible> +``` + +### 35. `TimedEntry` is no longer public + +`cached::TimedEntry` is now `pub(crate)`. Any `use cached::TimedEntry;` import fails. + +Detection: `grep -rn 'TimedEntry'` in your codebase. + +Action: remove the import. `TimedEntry` was only accessible after the `store()` accessors were +removed (breaking change #24); if you have no remaining `store()` call sites, there is nothing +further to do. + +### 36. `async_tokio_rt_multi_thread` cargo feature removed + +The `async_tokio_rt_multi_thread` feature is removed from `cached`. It added +`tokio/rt-multi-thread` to the build, which `#[tokio::test]` requires for multi-threaded tests. + +Detection: a Cargo feature-not-found error for `async_tokio_rt_multi_thread` in your +`Cargo.toml` features list. + +Action: add `tokio` with `rt-multi-thread` directly to your own `[dev-dependencies]`: +```toml +[dev-dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +``` +Remove `async_tokio_rt_multi_thread` from your `cached` features list. + +### 37. `cached`'s `async` feature no longer pulls `tokio`; `async_sync` re-exports changed + +The `async` feature previously implied `tokio` as a dependency. It now only pulls `async-lock` +and `blocking` (runtime-agnostic). smol/async-std async users no longer compile tokio. + +`cached::async_sync::{Mutex, RwLock, OnceCell}` now re-export from `async-lock` instead of +`tokio::sync`. The API surface is similar but not identical: `OnceCell` from `async-lock` has +no `const_new()` method. + +Detection: direct use of `cached::async_sync::OnceCell::const_new()` fails. + +Action: replace `const_new()` with `OnceCell::new()` (or construct lazily with +`OnceCell::get_or_init`). If you need tokio-specific sync primitives, depend on `tokio` directly. + +### 38. `RedbCacheError::BackgroundTaskFailed` variant removed + +The `RedbCacheError::BackgroundTaskFailed` variant is removed. The async `RedbCache` now runs +blocking redb work on the `blocking` crate's thread pool instead of tokio's `spawn_blocking`, +so there is no background-task failure path. + +Detection: an exhaustive `match` on `RedbCacheError` with a `BackgroundTaskFailed` arm fails +with an unknown-variant error. + +Action: remove the `BackgroundTaskFailed` arm. The `#[non_exhaustive]` annotation on +`RedbCacheError` requires a wildcard arm regardless. + +### 39. `sync_writes` default on `#[cached]` changed to `"by_key"` + +A bare `#[cached]` (no `sync_writes` attribute) now uses `sync_writes = "by_key"`: concurrent +first calls for the same key are deduplicated through bucketed per-key locks. Previously the +default was no synchronization, matching Python's `functools.lru_cache` behavior. + +This affects only the first-call window for a given key. Once a value is cached, subsequent +calls still hit the cache without taking the per-key lock. + +`#[once]` and `#[concurrent_cached]` defaults are unchanged. + +`result_fallback` with no explicit `sync_writes` implicitly uses `Disabled` (not `"by_key"`), +since the fallback logic requires its own coordination that is incompatible with per-key +deduplication. + +Detection: no compile error. Behavior change: code that relied on concurrent first calls each +computing and storing independently will now serialize those calls per key. + +Action: to restore the old no-synchronization behavior, add `sync_writes = false` explicitly: +```rust +// Before (implicit no-sync) +#[cached] +fn compute(x: u64) -> u64 { expensive(x) } + +// After: restore old behavior +#[cached(sync_writes = false)] +fn compute(x: u64) -> u64 { expensive(x) } + +// Or keep the new default (recommended -- avoids redundant parallel computation): +#[cached] +fn compute(x: u64) -> u64 { expensive(x) } +``` + +### 40. Macro attributes `convert`, `create`, `force_refresh`, `map_error`, `cache_prefix_block` accept unquoted Rust + +These attributes previously required the value to be a quoted string: +`convert = r#"{ format!("{a}") }"#`. They now also accept unquoted Rust directly: +`convert = { format!("{a}") }`. The quoted-string form still works. + +`ty` and `key` remain quoted strings (they hold type names and key expressions that the macro +parses as tokens, not inline Rust blocks). + +`force_refresh` now also accepts a plain `bool`: `force_refresh = true` is valid (equivalent to +`force_refresh = { true }`). + +Detection: no compile error for existing quoted-string forms. This is purely additive. + +Action: no change required. Optionally update to the shorter unquoted form: +```rust +// Before (still valid) +#[cached(convert = r#"{ format!("{a}") }"#, map_error = "|e| MyErr(e)")] +// After (also valid -- prefer for readability) +#[cached(convert = { format!("{a}") }, map_error = |e| MyErr(e))] +``` + +### 41. `map_error` optional on `#[concurrent_cached(disk = true)]` and Redis stores + +When `map_error` is omitted from a `#[concurrent_cached]` annotation that uses a disk or Redis +store, the generated code uses `.map_err(Into::into)?`. The function's error type must implement +`From` (disk) or `From` (Redis) for this to compile. + +Detection: no compile error if the function's error type already implements the required `From`. +If `From` is not implemented, the error is a type mismatch at `.map_err(Into::into)?`. + +Action: either supply `map_error` as before, or implement `From` / +`From` for your error type and omit `map_error`: +```rust +// map_error still accepted (no change needed) +#[concurrent_cached(disk = true, map_error = |e| MyErr::Disk(e))] +fn compute(n: u64) -> Result { ... } + +// New: omit map_error when From is implemented +impl From for MyErr { ... } + +#[concurrent_cached(disk = true)] +fn compute(n: u64) -> Result { ... } +``` + +### 42. `companions_vis` macro attribute sets visibility of generated companion functions + +`#[cached]`, `#[once]`, and `#[concurrent_cached]` now accept a `companions_vis` attribute to +control the visibility of the generated `{fn}_no_cache` and `{fn}_prime_cache` companions +independently of the cached function's own visibility. + +```rust +// Make companions pub(crate) even though the cached fn is pub +#[cached(companions_vis = "pub(crate)")] +pub fn compute(x: u64) -> u64 { x * 2 } + +// Make companions private +#[cached(companions_vis = "")] +pub fn compute(x: u64) -> u64 { x * 2 } +``` + +The default is to inherit the cached function's visibility (unchanged behavior). This is +additive -- no action required. + +### 43. `TtlSortedCacheError` removed; `TtlSortedCache` uses `CacheSetError` + +`TtlSortedCacheError` is removed. `TtlSortedCache` now uses the shared `cached::CacheSetError` +(variant `TimeBounds`), the same error type as `TtlCache` and `LruTtlCache`, so all three TTL +stores report one error type. + +Detection: `cannot find type TtlSortedCacheError` at an import or a type annotation. + +Action: replace `TtlSortedCacheError` with `CacheSetError` (the variant name `TimeBounds` is unchanged). +```rust +// Before +use cached::TtlSortedCacheError; +let _: Result, TtlSortedCacheError> = cache.try_set(k, v); +// After +use cached::CacheSetError; +let _: Result, CacheSetError> = cache.try_set(k, v); +``` + +### 44. `disk_store` cargo feature renamed to `redb_store` + +The `disk_store` feature is renamed to `redb_store`, naming the backend (`redb`) explicitly, +parallel to the `redis_*` features. No backwards-compatible alias. + +Detection: `the package 'cached' does not contain this feature: disk_store`. + +Action: rename the feature in your `Cargo.toml`. +```toml +# Before +cached = { version = "", features = ["disk_store"] } +# After +cached = { version = "", features = ["redb_store"] } +``` + +### 45. `redis_ahash` cargo feature removed + +The `redis_ahash` feature (which enabled the `redis` crate's optional `ahash` feature) is removed. +It gated no `cached` code. + +Detection: `the package 'cached' does not contain this feature: redis_ahash`. + +Action: drop `redis_ahash` from your feature list. To use `ahash` with `redis`, enable it on your +own `redis` dependency. + +### 46. Redis values are serialized with MessagePack instead of JSON + +`RedisCache` / `AsyncRedisCache` now serialize cached values with MessagePack (`rmp-serde`), +storing binary values, matching the redb store. They previously used `serde_json` and stored +UTF-8 strings. The `redis_store` feature now pulls `rmp-serde` instead of `serde_json`. + +Detection: a downstream that relied on `cached` transitively enabling `serde_json` no longer +gets it. + +Action: old (2.x) JSON-format entries are read transparently. On a get, the store tries +MessagePack first; if that fails and the raw bytes look like a JSON object with a `version` key +(the 2.x format), it falls back to `serde_json` deserialization and serves the value. No +recompute happens; there is no cache stampede. Old entries are rewritten as MessagePack on their +next write (natural TTL expiry or an explicit set). Redis is often a shared cache, so this +backward-read fallback means a rolling upgrade of your application does not invalidate existing +entries mid-deploy. + +Two additional caveats: the on-the-wire value format for new writes is MessagePack, so any +external reader of those keys must decode MessagePack; and `RedisCacheError`'s +serialize/deserialize variants now carry `rmp_serde` error types rather than `serde_json::Error` +(see #7). If you depended on `cached` to pull `serde_json` into your tree, add it to your own +`Cargo.toml`. + +### 47. `connection_string()` returns a `ConnectionString` newtype; redacts by default + +`RedisCache::connection_string()` and `AsyncRedisCache::connection_string()` now return a +`ConnectionString` newtype instead of a plain `String`. Both `Display` and `Debug` on +`ConnectionString` redact credentials (passwords masked). Call `.reveal()` on the returned value +to get the raw URL string. + +Detection: code that used the `String` return value directly (passed it to a function expecting +`String`, or compared it against a raw URL string) will fail to compile. + +Action: call `.reveal()` when you need the raw URL (for example to open a second connection). +For logging, use the value directly (redacted `Display`/`Debug`). + +### 48. Redis TTL uses millisecond precision + +`RedisCache` / `AsyncRedisCache` now set expiry with the millisecond commands `PSETEX` / `PEXPIRE` +and convert the configured TTL to milliseconds. Previously sub-second TTLs were rounded up to the +next whole second via `SETEX` / `EXPIRE`. + +Detection: a sub-second TTL (`ttl_millis = N` below 1000, or a `Duration` with a fractional +second) on a Redis-backed cache now expires at the requested millisecond instead of at the next +whole second. + +Action: none unless you relied on the round-up. Whole-second TTLs are unchanged. Requires Redis +2.6 or newer (the millisecond commands; 2.6 shipped in 2012). + +### 49. `cache_reset` no longer preserves preallocated capacity + +`Cached::cache_reset` (and `ConcurrentCached::cache_reset` / `ConcurrentCachedAsync::async_cache_reset`) +now calls `clear()` followed by `shrink_to(initial_capacity)`. The allocator may shrink the +backing allocation toward zero rather than retaining the initial capacity, so a reset cache may +reallocate on subsequent inserts up to that capacity. + +Detection: code that called `cache_reset` expecting the backing allocation to remain at the +originally configured size (and thus to avoid reallocation after reset) will see that the +behavior has changed. + +Action: if retaining the preallocated capacity matters (for example to avoid allocation jitter +in a hot path), recreate the cache with the same builder settings instead of calling +`cache_reset`. + +## New APIs (additive -- no action required) + +- `companions_vis = ""` macro attribute on `#[cached]` / `#[once]` / `#[concurrent_cached]` -- sets the visibility of the generated `{fn}_no_cache` and `{fn}_prime_cache` companions; defaults to the cached function's own visibility (see #42). +- Inherent `get`/`set`/`remove`/`remove_entry`/`delete`/`reset` on the six sharded types -- return unwrapped values (`Option`, `()`, `bool`) instead of `Result<_, Infallible>`, so `store.get(&k)` is `Option` directly (see #34). +- `map_error` is now optional on `#[concurrent_cached(disk = true)]` and Redis-backed stores; when omitted the generated code uses `.map_err(Into::into)?` (see #41). - `cached::prelude` — `use cached::prelude::*;` to import the common traits at once. -- `ConcurrentCached::cache_clear` / `cache_reset` / `cache_reset_metrics` — `&self` clear/reset for a concurrent store. Sharded stores implement all three; `DiskCache` overrides `cache_clear` / `cache_reset` (clearing its redb table) but keeps the no-op `cache_reset_metrics` default (it tracks no in-memory metrics); `RedisCache` / `AsyncRedisCache` keep the no-op defaults. `ConcurrentCachedAsync` has the async counterparts `async_cache_clear` / `async_cache_reset` / `async_cache_reset_metrics`. If you maintain a custom `ConcurrentCached` / `ConcurrentCachedAsync` impl, the new methods have no-op defaults (no change required), but you may override them if your store supports a real clear/reset. +- `ConcurrentCached::cache_reset_metrics` / `ConcurrentCachedAsync::async_cache_reset_metrics` — `&self`, no-op default. Sharded stores override it to zero their per-shard counters; `RedbCache` / `RedisCache` / `AsyncRedisCache` keep the no-op default (they track no in-memory metrics). A custom impl gets the no-op for free. (`cache_clear` / `cache_reset` are now *required* methods, not defaults - see breaking change #15.) - `ConcurrentCacheEvict` — `&self` eviction for sharded TTL/expiring stores. +- `CacheTtl::try_set_ttl(&mut self, Duration) -> Result, SetTtlError>` — the strict variant of `set_ttl` that returns `SetTtlError::ZeroTtl` when passed a zero TTL instead of interpreting it as "disable expiry" (which `set_ttl(0)` / `unset_ttl()` do). Provided default; existing `CacheTtl` impls get it for free. +- `ConcurrentCacheBase::len` / `is_empty` — ergonomic aliases over `cache_size` (return `Result>` / `Result>`), mirroring the sync `Cached` trait. Available on every concurrent store through the `ConcurrentCacheBase` supertrait of both `ConcurrentCached` and `ConcurrentCachedAsync`. +- `ConcurrentCacheTtl::try_set_ttl(&self, Duration) -> Result, SetTtlError>` — the `&self` strict variant of the concurrent `set_ttl` that returns `SetTtlError::ZeroTtl` for a zero TTL instead of disabling expiry. Provided default on `ConcurrentCacheTtl`; the TTL-capable concurrent stores get it for free. +- `Debug` for `RedisCache`, `AsyncRedisCache`, and `RedbCache` (prints only non-secret config: namespace/prefix/path/ttl/refresh). +- `PartialEq` / `Eq` for `ExpiringCache` and `ExpiringLruCache` (equal when their stored entries are equal). The TTL stores (`TtlCache`, `LruTtlCache`, `TtlSortedCache`) intentionally do not implement these, since their entries carry per-entry `Instant` expiry timestamps that are not meaningfully comparable across two caches. +- `ConcurrentCached::cache_get_or_set_with` / `get_or_set_with` and `ConcurrentCachedAsync::async_cache_get_or_set_with` - return the cached value for a key or compute, store, and return it. Defaulted (get-then-set, non-atomic: a concurrent miss may run the factory more than once). +- `ConcurrentCacheTtl::refresh_on_hit()` getter and `set_refresh_on_hit` are now *required* methods (see breaking change #31). +- `Clone` for `RedisCache` and `AsyncRedisCache`. +- `#[must_use]` on the pure-query trait methods and on `cache_remove`/`cache_remove_entry`: under `-D warnings`, discarding their result now needs `let _ = ...`. The short `remove`/`remove_entry` aliases are intentionally not annotated, so for-effect removal stays clean. +- `#[concurrent_cached]`'s `refresh` attribute is now a plain `bool` (was internally `Option`): `refresh = false` is the default and no longer conflicts with `expires` or a `create` block. No action unless you relied on `refresh = false` being rejected next to those. - `RedbCache::flush` / `RedbCache::async_flush` — force a durable (fsync) commit. Useful with `durable(false)`: keep cheap `Durability::None` writes and call `flush()` periodically or before shutdown to persist them. - `RedbCache::disk_path()` — returns the on-disk path of the redb database file backing the cache (useful for backup, cleanup, or inspection). - On the `#[concurrent_cached(disk = true)]` macro path the generated cache static is a `RedbCache`, so these are inherent methods on it (e.g. `MY_FN_CACHE.flush()`, `MY_FN_CACHE.remove_expired_entries()`); there are no separate macro attributes for flush/sweep. +- `SerializeCached` / `SerializeCachedAsync` — `cache_set_ref(&self, &K, &V) -> Result, Self::Error>` / `async_cache_set_ref(&self, &K, &V) -> Result, Self::Error>` for serialize-backed stores (`RedisCache`, `AsyncRedisCache`, `RedbCache`), setting an entry without taking ownership of the key/value. Imported via the prelude. `#[concurrent_cached]` automatically prefers this borrowed setter for any store that implements the trait (built-in or a custom `ty`/`create` store), so a custom serialize-backed store no longer pays a value clone on each set. +- `ttl_millis = N` macro attribute (`#[cached]` / `#[concurrent_cached]` / `#[once]`) — TTL in milliseconds for sub-second expiry; mutually exclusive with `ttl` and `ttl_secs`. Sub-second precision is honored by the in-memory, disk (redb), and Redis stores; Redis uses the millisecond commands `PSETEX` / `PEXPIRE` (see #48). +- `ttl_secs = N` macro attribute (`#[cached]` / `#[concurrent_cached]` / `#[once]`) — TTL in whole seconds; replaces the removed `ttl = N` integer form. Mutually exclusive with `ttl` and `ttl_millis`. +- `Type::new()` / `Type::new(required_field)` constructors on all in-memory and sharded stores — returns a ready-to-use cache using default configuration. Prefer `builder()` when you need non-default settings. +- Builder `.ttl_secs(n)` / `.ttl_millis(n)` convenience methods on all TTL-bearing builders (`TtlCacheBuilder`, `LruTtlCacheBuilder`, `TtlSortedCacheBuilder`, the four sharded TTL builders, `RedisCacheBuilder`, `AsyncRedisCacheBuilder`, `RedbCacheBuilder`) — shorthand for `.ttl(Duration::from_secs(n))` / `.ttl(Duration::from_millis(n))`. All three builder methods (`ttl`, `ttl_secs`, `ttl_millis`) target the same underlying field; whichever is called last wins. +- `force_refresh = "{ }"` macro attribute (`#[cached]` / `#[concurrent_cached]` / `#[once]`) - a curly-brace expression block over the function's arguments (like `convert`); when it evaluates true, bypass the cached value and recompute. For a dedicated flag argument on `#[cached]` / `#[concurrent_cached]` you must exclude it from the key via `key` / `convert` (otherwise the `flag = true` call recomputes into a different key slot than the `flag = false` calls read, so the refresh is never seen). On `#[once]` there is no per-call key, so the forced recompute simply overwrites the single shared value. Note: when `force_refresh` and `result_fallback` are both set, a force-refreshed call that returns `Err` will serve the previously cached `Ok` value - `result_fallback` takes precedence over the bypass (this is intended precedence, not a bug). +- `ConcurrentCloneCached::cache_peek_with_expiry_status` (and the `CloneCached` equivalent) — a read that returns the cached value along with its expiry status without renewing the TTL, updating LRU recency, or incrementing hit/miss counters. This is now a *required* method (see breaking change #16); the built-in sharded and in-memory expiry stores implement it with a genuine side-effect-free read. +- `in_impl = true` macro attribute — cache a method inside an `impl` block (the cache static is emitted in the method body); a `self` receiver is allowed and excluded from the key. The `_prime_cache` companion is not generated for `in_impl` methods. +- `LruCache::set_max_size` / `try_set_max_size`, `LruTtlCache::set_max_size` / `try_set_max_size`, `ExpiringLruCache::set_max_size` / `try_set_max_size` — resize a live LRU-backed cache (evicts LRU entries when shrinking). All three `try_set_max_size` methods return `Result` (variant `ZeroSize` on a zero argument). +- `RedisCache` / `AsyncRedisCache` now implement `cache_clear` / `async_cache_clear` (namespace-scoped `SCAN` + `DEL`); `cache_reset` / `async_cache_reset` delegate to them (redis tracks no in-memory metrics). +- Reference arguments (`&T`, `Option<&T>`) now form the default macro key without a `convert`. +- `Expires::expires_at(&self) -> Option` -- default trait method returning the value's expiry instant when tracked (`None` by default / when unknown). Advisory/observability only; `is_expired()` remains the authoritative liveness check. Existing `impl Expires` blocks (which provide only `is_expired`) get the default for free. + +- Custom hasher on the non-sharded in-memory stores: `UnboundCache`, `LruCache`, `TtlCache`, `LruTtlCache`, `TtlSortedCache`, `ExpiringCache`, and `ExpiringLruCache` gain a hasher type parameter defaulted to `DefaultHashBuilder` (`UnboundCache`, etc.) and a `.hasher(s)` builder method, mirroring the sharded stores. `DefaultHashBuilder` (ahash under the `ahash` feature, else std `RandomState`) is re-exported from the crate root. Existing code that names a store as `UnboundCache` is unchanged; only explicit turbofish or stored-type annotations that need the third parameter are affected. +- Concurrent metrics through a trait: `ConcurrentCacheBase` gains `cache_hits` / `cache_misses` / `cache_capacity` / `cache_evictions` and a default `metrics() -> CacheMetrics`, so a `ConcurrentCached` / `ConcurrentCachedAsync` bound can read a sharded store's metrics generically (the inherent `metrics()` is retained). This mirrors the metric accessors already on `Cached`. +- `#[cached]` / `#[once]` now reject the concurrent-store-only attributes `disk`, `redis`, and `map_error` with a clear error pointing to `#[concurrent_cached]`, instead of darling's generic "Unknown field" message. No change to which attributes are accepted. +- `len` / `cache_size` / `iter` / `evict` contract on lazy-eviction stores is now documented in one place: `len` / `cache_size` returns the stored count without scanning for expiry (so it may include expired entries), `iter` omits expired entries from the view without removing them, and `evict()` physically reclaims expired entries (and is how to obtain an accurate live count). Behavior is unchanged; see the `CachedIter` and `CacheEvict` docs. ## Required Cargo.toml change @@ -131,7 +1019,40 @@ If pinning the proc-macro crate directly, bump `cached_proc_macro` to the same m - `no method named cache_get ... ConcurrentCachedAsync` → use `async_cache_get` (#2). - `no method named refresh` on a builder → `refresh_on_hit` (#5). - `no field size on CacheMetrics` → `entry_count` (#4). - - `the trait CacheTtl/CacheEvict is not implemented for ` → use `ConcurrentCached::set_ttl` / `ConcurrentCacheEvict::evict` (#3). + - `the trait CacheTtl/CacheEvict is not implemented for ` → use `ConcurrentCacheTtl::set_ttl` / `ConcurrentCacheEvict::evict` (#3). + - `multiple applicable items in scope` on `set_ttl`/`cache_size`/`len`/`is_empty`/`unset_ttl` with both concurrent traits imported → those helpers moved to `ConcurrentCacheBase` / `ConcurrentCacheTtl`; import the new trait(s) or use `cached::prelude::*` (#2b). - `` `size` was renamed to `max_size`; use `max_size = ...` `` in a macro → rename to `max_size` (#1). - `Unknown field: \`connection_config\`` in a macro → remove it (#6). -- `cargo test` — no behavior changes beyond the above; disk caches will be empty on first run after upgrade (format change). + - `cannot borrow ... as mutable` / type mismatch on a `get_or_set_with` result → use the `*_mut` variant (#10). + - a `rediss://` TLS connection failure or missing TLS connector → add `redis_tokio_native_tls` / `redis_tokio_rustls` (or the smol equivalents) (#8). + - `no function or associated item named 'new' found for struct RedbCache` / `RedisCache` / `AsyncRedisCache` → replace `::new(` with `::builder(` (#11). + - an error like "`ttl` now takes a Duration expression ... for whole seconds use `ttl_secs = 60`" in a macro → rename `ttl = N` to `ttl_secs = N` (#12). + - `cannot find type ShardedCache` / `ShardedCacheBase` / `ShardedCacheBuilder` → rename to `ShardedUnbound*` (#17). + - `cannot find type TtlSortedCacheError` or `ttl_sorted::Error` → both names are removed; `TtlSortedCache` now uses `CacheSetError` (#18 / #43). + - `E0046` "not all trait items implemented: cache_clear, cache_reset" on a custom concurrent store → implement them (#15); same for `cache_peek_with_expiry_status` on a custom `CloneCached`/`ConcurrentCloneCached` (#16). + - `the unbound attribute has been removed` in a macro → drop `unbound` (#19). + - `no method named refresh_on_hit`/`set_refresh_on_hit` on a `TtlCache`/`LruTtlCache` value → `use cached::CacheTtl;` (#20). + - an unknown-feature error for `wasm` → remove it from your features (#21). + - a type-mismatch on a `cache_try_set`/`try_set` error binding, or `E0053` on a custom `cache_try_set` override → switch to `cached::CacheSetError` (#22). + - `cannot find type DiskCache*` → rename to `RedbCache*` (#23). + - `no method named store` on an in-memory cache → use the public `Cached` API (#24). + - `the trait bound MyHasher: Clone is not satisfied ... required by ... ShardHasher` → derive `Clone` on your custom shard hasher (#25). + - `unused ... that must be used` on a `cache_size`/`cache_remove`/`metrics`/... call under `-D warnings` → bind with `let _ = ...` or use the value (#12 / must_use). + - `E0046` "not all trait items implemented, missing: Error" on a custom `Cached` impl → add `type Error = std::convert::Infallible;` (or your error type) to the impl (#33). + - `TtlSortedCache`'s `cache_try_set`/`try_set` error type is `CacheSetError`, unified with `TtlCache`/`LruTtlCache`; a previous binding to `TtlSortedCacheError` or `ttl_sorted::Error` must change to `CacheSetError` (#18 / #43). + - type mismatch `Result, Infallible>` where `Option` is now expected from a sharded store's `get`/`set`/`remove`/`delete`/`reset` → remove the `Result` wrapper; call through the trait or use `cache_` prefix if `Result` is needed (#34). + - `use of undeclared type TimedEntry` or `cannot find type TimedEntry in module cached` → remove the import; `TimedEntry` is no longer public (#35). + - unknown feature `async_tokio_rt_multi_thread` → remove from `cached` features, add `tokio = { ..., features = ["rt-multi-thread"] }` to your own dev-dependencies (#36). + - `no method named const_new on ... OnceCell` → replace with `OnceCell::new()` (#37). + - unknown variant `BackgroundTaskFailed` in match on `RedbCacheError` → remove that arm (#38). + - type mismatch `expected Result, Infallible>, found Option` on a sharded store method → see #34. + - `cannot find feature async_tokio_rt_multi_thread` → see #36. + - `the type ... does not implement From` at a `#[concurrent_cached(disk = true)]` without `map_error` → either add `map_error` or implement `From` for your error type (#41). + - `no method named get_async`/`set_async`/`remove_async`/`clear_async` on a `CachedAsync` store → use `async_cache_get`/`async_cache_set`/`async_cache_remove`/`async_cache_clear` (#27). + - `the package 'cached' does not contain this feature: disk_store` → renamed to `redb_store` (#44). + - `the package 'cached' does not contain this feature: redis_ahash` → removed; enable `ahash` on your own `redis` dependency if needed (#45). + - `no method named get`/`set`/`remove`/`len`/`is_empty`/... on a cache value → the short aliases moved to `CachedExt` / `ConcurrentCachedExt`; add `use cached::prelude::*;` or the specific extension trait (#13). + - `no variant named Connection` / `cannot find ... Connection` in a match on `RedbCacheBuildError` → renamed to `Storage` (#7). + - `expected struct variant` / pattern mismatch on `RedbCacheError` / `RedbCacheBuildError` (e.g. `CacheSerialization(e)`) → these are now struct variants; match `CacheSerialization { source }` / `CacheDeserialization { source, cached_value }` / `Storage { source }` (#7). + - a type mismatch binding a `RedisCacheError` serialize/deserialize `source` as `serde_json::Error` → the variants now carry `rmp_serde::encode::Error` / `rmp_serde::decode::Error` (#7 / #46). +- `cargo test` — no behavior changes beyond the above; disk caches will be empty on first run after upgrade (format change). `#[cached]` functions that previously ran concurrent first-calls in parallel per key now serialize them; tests asserting that behavior must add `sync_writes = false` (#39). diff --git a/examples/async_std.rs b/examples/async_std.rs index 23026019..b738bd20 100644 --- a/examples/async_std.rs +++ b/examples/async_std.rs @@ -1,6 +1,6 @@ /* Async memoization on the async-std runtime: `#[cached]` / `#[once]` on -`async fn`s (including `ttl` + `result`). Demonstrates the proc macros are +`async fn`s (including `ttl_secs` + `result`). Demonstrates the proc macros are runtime-agnostic (the `async` feature; async-std runs `main`). Run: @@ -24,7 +24,7 @@ async fn cached_sleep_secs(secs: u64) { /// should only cache the result for a second, and only when /// the result is `Ok` -#[cached(ttl = 1, key = "bool", convert = r#"{ true }"#)] +#[cached(ttl_secs = 1, key = "bool", convert = r#"{ true }"#)] async fn only_cached_a_second( s: String, ) -> std::result::Result, &'static dyn std::error::Error> { @@ -41,7 +41,7 @@ async fn only_cached_result_once(s: String, error: bool) -> std::result::Result< /// should only cache the _first_ `Ok` returned for 1 second. /// all arguments are ignored for subsequent calls until the /// cache expires after a second. -#[once(ttl = 1)] +#[once(ttl_secs = 1)] async fn only_cached_result_once_per_second( s: String, error: bool, @@ -59,7 +59,7 @@ async fn only_cached_option_once(s: String, none: bool) -> Option> { /// should only cache the _first_ `Some` returned for 1 second. /// all arguments are ignored for subsequent calls until the /// cache expires after a second. -#[once(ttl = 1)] +#[once(ttl_secs = 1)] async fn only_cached_option_once_per_second(s: String, none: bool) -> Option> { if none { None } else { Some(vec![s]) } } @@ -67,7 +67,7 @@ async fn only_cached_option_once_per_second(s: String, none: bool) -> Option Vec { vec![s] } @@ -79,7 +79,7 @@ async fn only_cached_once_per_second(s: String) -> Vec { /// _one_ call will be "executed" and all others will be synchronized /// to return the cached result of the one call instead of all /// concurrently un-cached tasks executing and writing concurrently. -#[once(ttl = 2, sync_writes = true)] +#[once(ttl_secs = 2, sync_writes = true)] async fn only_cached_once_per_second_sync_writes(s: String) -> Vec { vec![s] } diff --git a/examples/basic.rs b/examples/basic.rs index be55df14..d2ddcd6c 100644 --- a/examples/basic.rs +++ b/examples/basic.rs @@ -1,7 +1,7 @@ /* -The basics: `#[cached(max_size = N)]` (LRU memoization) and `#[once(ttl = N)]` +The basics: `#[cached(max_size = N)]` (LRU memoization) and `#[once(ttl_secs = N)]` (a single cached value that expires), plus reading the generated cache static -through the `Cached` trait. +through the `Cached` trait, and manual cache invalidation via `remove`. Run: cargo run --example basic --features "time_stores,proc_macro" @@ -21,7 +21,16 @@ fn slow_fn(n: u32) -> String { slow_fn(n - 1) } -#[once(ttl = 1)] +/// Remove a specific entry from the `slow_fn` cache. +/// `CachedExt` must be in scope to call `remove`. +fn invalidate_slow_fn(n: u32) { + use cached::CachedExt; + // `.0` accesses the inner lock of the (lock, key-buckets) tuple produced by + // the default `sync_writes = "by_key"` mode. + SLOW_FN.0.write().remove(&n); +} + +#[once(ttl_secs = 1)] fn once_slow_fn(n: u32) -> String { sleep(Duration::new(1, 0)); format!("{n}") @@ -40,20 +49,29 @@ pub fn main() { // Inspect the cache { - use cached::Cached; // must be in scope to access cache + use cached::CachedExt; // must be in scope to access short aliases println!("[cached] ** Cache info **"); - let cache = SLOW_FN.read(); - assert_eq!(cache.cache_hits().unwrap(), 1); - println!("[cached] hits=1 -> {:?}", cache.cache_hits().unwrap() == 1); - assert_eq!(cache.cache_misses().unwrap(), 11); - println!( - "[cached] misses=11 -> {:?}", - cache.cache_misses().unwrap() == 11 - ); + let cache = SLOW_FN.0.read(); + assert_eq!(cache.hits().unwrap(), 1); + println!("[cached] hits=1 -> {:?}", cache.hits().unwrap() == 1); + assert_eq!(cache.misses().unwrap(), 11); + println!("[cached] misses=11 -> {:?}", cache.misses().unwrap() == 11); // make sure the cache-lock is dropped } + // Invalidate the entry for n=10, then show the next call is a cache miss. + println!("[cached] Invalidating entry for n=10..."); + invalidate_slow_fn(10); + { + use cached::CachedExt; + let mut cache = SLOW_FN.0.write(); + // Entry for 10 is gone; the recursive sub-entries (0 through 9) are still present. + let present = cache.get(&10).is_some(); + println!("[cached] cache contains n=10 after invalidation -> {present}"); + assert!(!present, "entry should have been removed"); + } + println!("[once] Initial run..."); let now = Instant::now(); let _ = once_slow_fn(10); diff --git a/examples/disk.rs b/examples/disk.rs index a1ea8d32..08f62fa4 100644 --- a/examples/disk.rs +++ b/examples/disk.rs @@ -3,9 +3,10 @@ Synchronous on-disk cache: `#[concurrent_cached(disk = true)]` backed by `redb`. Default cache files live under $system_cache_dir/_cached_disk_cache/. Run: - cargo run --example disk --features "disk_store,proc_macro" + cargo run --example disk --features "redb_store,proc_macro" */ +use cached::ConcurrentCachedExt; use cached::macros::concurrent_cached; use cached::time::Duration; use std::io; @@ -18,19 +19,37 @@ enum ExampleError { DiskError(String), } -// When the macro constructs your RedbCache instance (the default disk engine; -// `DiskCache` is a kept type alias), the default cache files will be stored +// When the macro constructs your RedbCache instance (the default disk engine), +// the default cache files will be stored // under $system_cache_dir/_cached_disk_cache/ +// +// `map_error` is an unquoted closure here; the legacy quoted-string form +// (`map_error = r##"|e| ..."##`) is still accepted. #[concurrent_cached( disk = true, - ttl = 30, - map_error = r##"|e| ExampleError::DiskError(format!("{:?}", e))"## + ttl_secs = 30, + map_error = |e| ExampleError::DiskError(format!("{e:?}")) )] fn cached_sleep_secs(secs: u64) -> Result<(), ExampleError> { std::thread::sleep(Duration::from_secs(secs)); Ok(()) } +// `map_error` is now optional: when the function's error type implements +// `From`, the macro converts store errors automatically via +// `Into::into`, so no `map_error` closure is needed. +impl From for ExampleError { + fn from(e: cached::RedbCacheError) -> Self { + ExampleError::DiskError(format!("{e:?}")) + } +} + +#[concurrent_cached(disk = true, ttl_secs = 30)] +fn cached_sleep_secs_from(secs: u64) -> Result<(), ExampleError> { + std::thread::sleep(Duration::from_secs(secs)); + Ok(()) +} + fn main() { print!("1. first sync call with a 2 seconds sleep..."); io::stdout().flush().unwrap(); @@ -41,8 +60,7 @@ fn main() { cached_sleep_secs(2).unwrap(); println!("done"); - use cached::ConcurrentCached; - CACHED_SLEEP_SECS.cache_remove(&2).unwrap(); + CACHED_SLEEP_SECS.remove(&2).unwrap(); print!("third sync call with a 2 seconds sleep (slow, after cache-remove)..."); io::stdout().flush().unwrap(); cached_sleep_secs(2).unwrap(); @@ -54,13 +72,20 @@ fn main() { // for write throughput; call `flush()` at a chosen point (periodically or // before shutdown) to force a single durable commit that persists them all. use cached::RedbCache; - let cache: RedbCache = RedbCache::new("disk-example-flush") + let cache: RedbCache = RedbCache::builder() + .name("disk-example-flush") .durable(false) .build() .unwrap(); for i in 0..3 { - cache.cache_set(i, i * 10).unwrap(); + cache.set(i, i * 10).unwrap(); } cache.flush().unwrap(); // one durable commit persisting the cheap writes above println!("flushed 3 cheap writes to disk in a single durable commit"); + + // No `map_error` needed: ExampleError: From handles conversion. + print!("call without map_error (From) ..."); + io::stdout().flush().unwrap(); + cached_sleep_secs_from(1).unwrap(); + println!("done"); } diff --git a/examples/disk_async.rs b/examples/disk_async.rs index 347764de..64f78a8a 100644 --- a/examples/disk_async.rs +++ b/examples/disk_async.rs @@ -1,10 +1,10 @@ /* Async disk cache. `redb` has no async API, so `#[concurrent_cached(disk = true)]` -on an `async fn` runs the blocking I/O on tokio's blocking pool via -`spawn_blocking` — it never stalls the async runtime. +on an `async fn` runs the blocking I/O on a background thread via the `blocking` +crate -- it never stalls the async runtime and works with any executor. Run: - cargo run --example disk_async --features "disk_store,async_tokio_rt_multi_thread,proc_macro" + cargo run --example disk_async --features "redb_store,async,proc_macro" */ use cached::macros::concurrent_cached; @@ -24,7 +24,7 @@ enum ExampleError { // $system_cache_dir/_cached_disk_cache/). #[concurrent_cached( disk = true, - ttl = 30, + ttl_secs = 30, name = "ASYNC_DISK_SLEEP_SECS", map_error = r##"|e| ExampleError::DiskError(format!("{:?}", e))"## )] diff --git a/examples/expires_per_key.rs b/examples/expires_per_key.rs index 114b72e0..34a7d2b3 100644 --- a/examples/expires_per_key.rs +++ b/examples/expires_per_key.rs @@ -22,7 +22,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use cached::macros::{cached, once}; use cached::stores::ExpiringCache; use cached::time::{Duration, Instant}; -use cached::{Cached, Expires, ExpiringLruCache}; +use cached::{CachedExt, Expires, ExpiringLruCache}; static CALL_N: AtomicU64 = AtomicU64::new(0); @@ -41,14 +41,14 @@ impl Expires for MyValue { } } -// A keyed cache using the #[cached] macro — each user_id key independently expires +// A keyed cache using the #[cached] macro - each user_id key independently expires // when its stored value reports `is_expired() == true`. // // `expires = true` alone gives an unbounded ExpiringCache. // Add `max_size = N` to switch to an LRU-bounded ExpiringLruCache. // `key`/`convert` narrow the cache key to just user_id so expiry_offset_ms only // influences the token's lifetime, not which cache slot it occupies. -#[cached(expires = true, key = "u64", convert = "{ user_id }")] +#[cached(expires = true, key = "u64", convert = { user_id })] fn fetch_token(user_id: u64, expiry_offset_ms: u64) -> MyValue { println!(" -> [fetch_token] generating new token for user {user_id}..."); let n = CALL_N.fetch_add(1, Ordering::Relaxed); @@ -72,7 +72,7 @@ fn get_session_token(expiry_offset_ms: u64) -> MyValue { // `expires = true` composes with a `Result` return: only the `Ok(MyValue)` is // cached (and expires per-value via `Expires`); an `Err` is never cached, so a // failing call always re-executes. -#[cached(expires = true, key = "u64", convert = "{ user_id }")] +#[cached(expires = true, key = "u64", convert = { user_id })] fn fetch_token_result(user_id: u64, expiry_offset_ms: u64, fail: bool) -> Result { println!(" -> [fetch_token_result] generating token for user {user_id} (fail={fail})..."); if fail { @@ -88,7 +88,7 @@ fn fetch_token_result(user_id: u64, expiry_offset_ms: u64, fail: bool) -> Result // `expires = true` composes with an `Option` return: only `Some(MyValue)` is // cached (and expires per-value); a `None` is never cached, so a miss keeps // re-executing until a `Some` is produced. -#[cached(expires = true, key = "u64", convert = "{ user_id }")] +#[cached(expires = true, key = "u64", convert = { user_id })] fn fetch_token_option(user_id: u64, expiry_offset_ms: u64, found: bool) -> Option { println!(" -> [fetch_token_option] generating token for user {user_id} (found={found})..."); if !found { @@ -114,19 +114,19 @@ fn main() { data: "Short-lived LRU response".to_string(), expires_at: now + Duration::from_millis(500), }; - lru_cache.cache_set("short", quick_expiry); + lru_cache.set("short", quick_expiry); let long_expiry = MyValue { data: "Long-lived LRU response".to_string(), expires_at: now + Duration::from_secs(10), }; - lru_cache.cache_set("long", long_expiry); + lru_cache.set("long", long_expiry); println!("Immediately after insertion into ExpiringLruCache:"); - if let Some(val) = lru_cache.cache_get(&"short") { + if let Some(val) = lru_cache.get(&"short") { println!(" 'short' exists: '{}'", val.data); } - if let Some(val) = lru_cache.cache_get(&"long") { + if let Some(val) = lru_cache.get(&"long") { println!(" 'long' exists: '{}'", val.data); } @@ -134,11 +134,11 @@ fn main() { std::thread::sleep(Duration::from_secs(1)); println!("After waiting 1 second:"); - match lru_cache.cache_get(&"short") { + match lru_cache.get(&"short") { Some(val) => println!(" 'short' exists: '{}'", val.data), None => println!(" 'short' has expired and was removed!"), } - match lru_cache.cache_get(&"long") { + match lru_cache.get(&"long") { Some(val) => println!(" 'long' exists: '{}' (still active)", val.data), None => println!(" 'long' expired!"), } @@ -153,19 +153,19 @@ fn main() { data: "Short-lived response".to_string(), expires_at: Instant::now() + Duration::from_millis(500), }; - expiring_cache.cache_set("short", quick_expiry); + expiring_cache.set("short", quick_expiry); let long_expiry = MyValue { data: "Long-lived response".to_string(), expires_at: Instant::now() + Duration::from_secs(10), }; - expiring_cache.cache_set("long", long_expiry); + expiring_cache.set("long", long_expiry); println!("Immediately after insertion into ExpiringCache:"); - if let Some(val) = expiring_cache.cache_get(&"short") { + if let Some(val) = expiring_cache.get(&"short") { println!(" 'short' exists: '{}'", val.data); } - if let Some(val) = expiring_cache.cache_get(&"long") { + if let Some(val) = expiring_cache.get(&"long") { println!(" 'long' exists: '{}'", val.data); } @@ -173,11 +173,11 @@ fn main() { std::thread::sleep(Duration::from_secs(1)); println!("After waiting 1 second:"); - match expiring_cache.cache_get(&"short") { + match expiring_cache.get(&"short") { Some(val) => println!(" 'short' exists: '{}'", val.data), None => println!(" 'short' has expired and was removed!"), } - match expiring_cache.cache_get(&"long") { + match expiring_cache.get(&"long") { Some(val) => println!(" 'long' exists: '{}' (still active)", val.data), None => println!(" 'long' expired!"), } @@ -188,7 +188,7 @@ fn main() { println!("\n--- 3. #[cached(expires = true)] Macro (keyed) ---"); // Each cache key (user_id) has its own independent expiry. - // First calls for each user — both are cache misses. + // First calls for each user - both are cache misses. println!("First call for user 1 (expires in 500ms):"); let u1_t1 = fetch_token(1, 500); println!(" Returned: '{}'", u1_t1.data); @@ -197,7 +197,7 @@ fn main() { let u2_t1 = fetch_token(2, 10_000); println!(" Returned: '{}'", u2_t1.data); - // Same arguments → cache hits, function not re-executed. + // Same arguments -> cache hits, function not re-executed. println!("\nSecond call for user 1 (cache hit):"); let u1_t2 = fetch_token(1, 500); println!(" Returned: '{}' (same token)", u1_t2.data); @@ -205,7 +205,7 @@ fn main() { println!("\nWaiting 1 second..."); std::thread::sleep(Duration::from_secs(1)); - // User 1's token has expired → re-evaluated. User 2's is still live. + // User 1's token has expired -> re-evaluated. User 2's is still live. println!("\nAfter 1 second:"); let u1_t3 = fetch_token(1, 500); println!(" user 1: '{}' (re-evaluated — was expired)", u1_t3.data); diff --git a/examples/expiring_sized_cache.rs b/examples/expiring_sized_cache.rs index abf63b61..066f5281 100644 --- a/examples/expiring_sized_cache.rs +++ b/examples/expiring_sized_cache.rs @@ -3,7 +3,7 @@ limit, exercised by concurrent readers/writers on the tokio runtime. Run: - cargo run --example expiring_sized_cache --features "async_tokio_rt_multi_thread,time_stores" + cargo run --example expiring_sized_cache --features "time_stores" */ use cached::CachedRead; // shared-borrow reads via `cache_get_read` (RwLockReadGuard) diff --git a/examples/kitchen_sink.rs b/examples/kitchen_sink.rs index 4f90cef5..f1c6fc5b 100644 --- a/examples/kitchen_sink.rs +++ b/examples/kitchen_sink.rs @@ -8,7 +8,7 @@ Run: */ use cached::macros::cached; -use cached::{Cached, LruCache, UnboundCache}; +use cached::{Cached, CachedExt, LruCache, UnboundCache}; use std::cmp::Eq; use std::collections::HashMap; use std::hash::Hash; @@ -29,7 +29,7 @@ fn fib(n: u32) -> u32 { // Note that the cache key type is a tuple of function argument types. #[cached( ty = "UnboundCache", - create = "{ UnboundCache::builder().capacity(50).build().unwrap() }" + create = UnboundCache::builder().capacity(50).build().unwrap() )] fn fib_specific(n: u32) -> u32 { if n == 0 || n == 1 { @@ -50,7 +50,7 @@ fn slow(a: u32, b: u32) -> u32 { // Note that the cache key type is a `String` created from the borrow arguments #[cached( ty = "LruCache", - create = "{ LruCache::builder().max_size(100).build().unwrap() }", + create = LruCache::builder().max_size(100).build().unwrap(), convert = r#"{ format!("{a}{b}") }"# )] fn keyed(a: &str, b: &str) -> usize { @@ -73,6 +73,8 @@ impl MyCache { } } impl Cached for MyCache { + type Error = std::convert::Infallible; + fn cache_get(&mut self, k: &Q) -> Option<&V> where K: std::borrow::Borrow, @@ -87,10 +89,10 @@ impl Cached for MyCache { { self.store.get_mut(k) } - fn cache_get_or_set_with V>(&mut self, k: K, f: F) -> &mut V { + fn cache_get_or_set_with_mut V>(&mut self, k: K, f: F) -> &mut V { self.store.entry(k).or_insert_with(f) } - fn cache_try_get_or_set_with Result, E>( + fn cache_try_get_or_set_with_mut Result, E>( &mut self, k: K, f: F, @@ -135,7 +137,7 @@ impl Cached for MyCache { #[cached( name = "CUSTOM", ty = "MyCache", - create = "{ MyCache::with_capacity(50) }" + create = MyCache::with_capacity(50) )] fn custom(n: u32) { if n == 0 { @@ -144,22 +146,22 @@ fn custom(n: u32) { custom(n - 1); } -#[cached(ttl = 1)] +#[cached(ttl_secs = 1)] fn expires(a: i32) -> i32 { a } -#[cached(ttl = 1)] +#[cached(ttl_secs = 1)] fn expires_result(a: i32) -> Result { Ok(a) } -#[cached(ttl = 1)] +#[cached(ttl_secs = 1)] fn expires_option(a: i32) -> Option { Some(a) } -#[cached(ttl = 1, name = "EXPIRES_FOR_PRIMING")] +#[cached(ttl_secs = 1, name = "EXPIRES_FOR_PRIMING")] fn expires_for_priming(a: i32) -> i32 { a } @@ -169,9 +171,11 @@ pub fn main() { fib(3); fib(3); { - let cache = FIB.read(); - println!("hits: {:?}", cache.cache_hits()); - println!("misses: {:?}", cache.cache_misses()); + // `.0` accesses the inner lock of the (lock, key-buckets) tuple generated + // by the default `sync_writes = "by_key"` mode. + let cache = FIB.0.read(); + println!("hits: {:?}", cache.hits()); + println!("misses: {:?}", cache.misses()); // make sure lock is dropped } fib(10); @@ -181,9 +185,9 @@ pub fn main() { fib_specific(20); fib_specific(20); { - let cache = FIB_SPECIFIC.read(); - println!("hits: {:?}", cache.cache_hits()); - println!("misses: {:?}", cache.cache_misses()); + let cache = FIB_SPECIFIC.0.read(); + println!("hits: {:?}", cache.hits()); + println!("misses: {:?}", cache.misses()); // make sure lock is dropped } fib_specific(20); @@ -192,9 +196,9 @@ pub fn main() { println!("\n ** custom cache **"); custom(25); { - let cache = CUSTOM.read(); - println!("hits: {:?}", cache.cache_hits()); - println!("misses: {:?}", cache.cache_misses()); + let cache = CUSTOM.0.read(); + println!("hits: {:?}", cache.hits()); + println!("misses: {:?}", cache.misses()); // make sure lock is dropped } @@ -204,9 +208,9 @@ pub fn main() { println!(" - second run `slow(10)`"); slow(10, 10); { - let cache = SLOW.read(); - println!("hits: {:?}", cache.cache_hits()); - println!("misses: {:?}", cache.cache_misses()); + let cache = SLOW.0.read(); + println!("hits: {:?}", cache.hits()); + println!("misses: {:?}", cache.misses()); // make sure the cache-lock is dropped } @@ -217,9 +221,9 @@ pub fn main() { sleep(Duration::new(2, 0)); expires(1); { - let cache = EXPIRES.read(); - println!("hits: {:?}", cache.cache_hits()); - println!("misses: {:?}", cache.cache_misses()); + let cache = EXPIRES.0.read(); + println!("hits: {:?}", cache.hits()); + println!("misses: {:?}", cache.misses()); } println!("\n ** expires_result **"); @@ -229,9 +233,9 @@ pub fn main() { sleep(Duration::new(2, 0)); let _ = expires_result(1); { - let cache = EXPIRES_RESULT.read(); - println!("hits: {:?}", cache.cache_hits()); - println!("misses: {:?}", cache.cache_misses()); + let cache = EXPIRES_RESULT.0.read(); + println!("hits: {:?}", cache.hits()); + println!("misses: {:?}", cache.misses()); } println!("\n ** expires_option **"); @@ -241,9 +245,9 @@ pub fn main() { sleep(Duration::new(2, 0)); expires_option(1); { - let cache = EXPIRES_OPTION.read(); - println!("hits: {:?}", cache.cache_hits()); - println!("misses: {:?}", cache.cache_misses()); + let cache = EXPIRES_OPTION.0.read(); + println!("hits: {:?}", cache.hits()); + println!("misses: {:?}", cache.misses()); } println!("\n ** expires_for_priming **"); @@ -251,25 +255,25 @@ pub fn main() { expires_for_priming(1); // Second call: cache hit (key 1 is still within its TTL) expires_for_priming(1); - // Prime key 1 and key 2 — refreshes the cache directly without affecting hit/miss counters + // Prime key 1 and key 2 - refreshes the cache directly without affecting hit/miss counters expires_for_priming_prime_cache(1); expires_for_priming_prime_cache(2); { - let c = EXPIRES_FOR_PRIMING.read(); + let c = EXPIRES_FOR_PRIMING.0.read(); // Only the two explicit function calls above count toward metrics - assert_eq!(c.cache_hits(), Some(1)); // second call was a hit - assert_eq!(c.cache_misses(), Some(1)); // first call was a miss + assert_eq!(c.hits(), Some(1)); // second call was a hit + assert_eq!(c.misses(), Some(1)); // first call was a miss } // Sleep longer than the 1-second TTL so the cached values expire sleep(Duration::new(2, 0)); // Re-prime key 1 so it's fresh in the cache again expires_for_priming_prime_cache(1); - // Now calling the function finds the freshly-primed value — it's a hit + // Now calling the function finds the freshly-primed value - it's a hit assert_eq!(expires_for_priming(1), 1); { - let c = EXPIRES_FOR_PRIMING.read(); - assert_eq!(c.cache_hits(), Some(2)); // this last call was also a hit - assert_eq!(c.cache_misses(), Some(1)); // still only 1 miss + let c = EXPIRES_FOR_PRIMING.0.read(); + assert_eq!(c.hits(), Some(2)); // this last call was also a hit + assert_eq!(c.misses(), Some(1)); // still only 1 miss } println!("done!"); diff --git a/examples/redis-async-async-std.rs b/examples/redis-async-async-std.rs index 4c34595a..4652cfec 100644 --- a/examples/redis-async-async-std.rs +++ b/examples/redis-async-async-std.rs @@ -1,15 +1,15 @@ /* Async Redis cache (`AsyncRedisCache`, async-std runtime): `#[concurrent_cached]` -with a custom `create` block and cache priming. Uses `redis_smol` for the Redis +with a custom `create` block and cache priming. Uses `redis_smol_native_tls` for the Redis connection (smol is the async executor that async-std is built on). The connection string is read from `CACHED_REDIS_CONNECTION_STRING`. See also `redis-async-tokio` for the same example on the Tokio runtime. -Note: `redis_smol` enables TLS via native-tls. If you need plain-text connections +Note: `redis_smol_native_tls` enables TLS via native-tls. If you need plain-text connections only, you can enable `redis/smol-comp` directly instead. -Note: `redis_smol` transitively enables `tokio` (through cached's `async` +Note: `redis_smol_native_tls` transitively enables `tokio` (through cached's `async` feature) so that cached's internal sync primitives compile, but the Tokio runtime is never started — all async execution is driven by async-std. @@ -17,7 +17,7 @@ Start redis if you don't already have one: docker run --rm --name async-cached-redis-example -p 6379:6379 -d redis Run: CACHED_REDIS_CONNECTION_STRING=redis://127.0.0.1:6379 \ - cargo run --example redis-async-async-std --features "redis_smol,proc_macro" + cargo run --example redis-async-async-std --features "redis_smol_native_tls,proc_macro" Cleanup: docker rm -f async-cached-redis-example */ @@ -41,7 +41,7 @@ enum ExampleError { // will be pulled from the env var: `CACHED_REDIS_CONNECTION_STRING`. #[concurrent_cached( redis = true, - ttl = 30, + ttl_secs = 30, cache_prefix_block = r##"{ "cache-redis-async-std-example-1" }"##, map_error = r##"|e| ExampleError::RedisError(format!("{:?}", e))"## )] @@ -54,7 +54,9 @@ async fn cached_sleep_secs(secs: u64) -> Result<(), ExampleError> { map_error = r##"|e| ExampleError::RedisError(format!("{:?}", e))"##, ty = "cached::AsyncRedisCache", create = r##" { - AsyncRedisCache::new("cache_redis_async_std_example_cached_sleep_secs", Duration::from_secs(1)) + AsyncRedisCache::builder() + .prefix("cache_redis_async_std_example_cached_sleep_secs") + .ttl(Duration::from_secs(1)) .refresh_on_hit(true) .build() .await @@ -83,7 +85,9 @@ static CONFIG: LazyLock = LazyLock::new(Config::load); map_error = r##"|e| ExampleError::RedisError(format!("{:?}", e))"##, ty = "cached::AsyncRedisCache", create = r##" { - AsyncRedisCache::new("cache_redis_async_std_example_cached_sleep_secs_config", Duration::from_secs(1)) + AsyncRedisCache::builder() + .prefix("cache_redis_async_std_example_cached_sleep_secs_config") + .ttl(Duration::from_secs(1)) .refresh_on_hit(true) .connection_string(&CONFIG.conn_str) .build() diff --git a/examples/redis-async-tokio.rs b/examples/redis-async-tokio.rs index 13d95ee0..fde9164a 100644 --- a/examples/redis-async-tokio.rs +++ b/examples/redis-async-tokio.rs @@ -9,7 +9,7 @@ Start redis if you don't already have one: docker run --rm --name async-cached-redis-example -p 6379:6379 -d redis Run: CACHED_REDIS_CONNECTION_STRING=redis://127.0.0.1:6379 \ - cargo run --example redis-async-tokio --features "redis_tokio,async_tokio_rt_multi_thread,proc_macro" + cargo run --example redis-async-tokio --features "redis_tokio_native_tls,proc_macro" Cleanup: docker rm -f async-cached-redis-example */ @@ -32,7 +32,7 @@ enum ExampleError { // will be pulled from the env var: `CACHED_REDIS_CONNECTION_STRING`; #[concurrent_cached( redis = true, - ttl = 30, + ttl_secs = 30, cache_prefix_block = r##"{ "cache-redis-example-1" }"##, map_error = r##"|e| ExampleError::RedisError(format!("{:?}", e))"## )] @@ -45,7 +45,9 @@ async fn cached_sleep_secs(secs: u64) -> Result<(), ExampleError> { map_error = r##"|e| ExampleError::RedisError(format!("{:?}", e))"##, ty = "cached::AsyncRedisCache", create = r##" { - AsyncRedisCache::new("cache_redis_example_cached_sleep_secs", Duration::from_secs(1)) + AsyncRedisCache::builder() + .prefix("cache_redis_example_cached_sleep_secs") + .ttl(Duration::from_secs(1)) .refresh_on_hit(true) .build() .await @@ -74,7 +76,9 @@ static CONFIG: LazyLock = LazyLock::new(Config::load); map_error = r##"|e| ExampleError::RedisError(format!("{:?}", e))"##, ty = "cached::AsyncRedisCache", create = r##" { - AsyncRedisCache::new("cache_redis_example_cached_sleep_secs_config", Duration::from_secs(1)) + AsyncRedisCache::builder() + .prefix("cache_redis_example_cached_sleep_secs_config") + .ttl(Duration::from_secs(1)) .refresh_on_hit(true) .connection_string(&CONFIG.conn_str) .build() diff --git a/examples/redis-client-side-cache-tokio.rs b/examples/redis-client-side-cache-tokio.rs index 46c570ee..98fe4449 100644 --- a/examples/redis-client-side-cache-tokio.rs +++ b/examples/redis-client-side-cache-tokio.rs @@ -5,13 +5,15 @@ invalidation-tracked copy of fetched keys, cutting round-trips for hot keys while staying consistent via server push invalidation. Note: client-side caching requires RESP3, which is only supported over Tokio -(`redis_async_cache` implies `redis_tokio`). There is no async-std equivalent. +(`redis_async_cache` enables the Tokio RESP3 client-side-caching path and is +TLS-agnostic). There is no async-std equivalent. Add `redis_tokio_native_tls` +or `redis_tokio_rustls` separately if TLS connectivity is required. Start a RESP3-capable redis (redis 6+, e.g. the `redis` image) if not already running: docker run --rm --name cached-csc-example -p 6379:6379 -d redis Run: CACHED_REDIS_CONNECTION_STRING=redis://127.0.0.1:6379 \ - cargo run --example redis-client-side-cache-tokio --features "redis_async_cache,async_tokio_rt_multi_thread,proc_macro" + cargo run --example redis-client-side-cache-tokio --features "redis_async_cache,redis_tokio_native_tls,proc_macro" Cleanup: docker rm -f cached-csc-example */ @@ -36,7 +38,9 @@ enum ExampleError { map_error = r##"|e| ExampleError::RedisError(format!("{:?}", e))"##, ty = "cached::AsyncRedisCache", create = r##" { - AsyncRedisCache::new("cached-csc-example", Duration::from_secs(30)) + AsyncRedisCache::builder() + .prefix("cached-csc-example") + .ttl(Duration::from_secs(30)) .client_side_caching(true) .build() .await diff --git a/examples/redis.rs b/examples/redis.rs index 007a016b..9bd93f41 100644 --- a/examples/redis.rs +++ b/examples/redis.rs @@ -1,14 +1,14 @@ /* Synchronous Redis cache: `#[concurrent_cached(redis = true)]` and an explicit `ty = "RedisCache<..>"` + `create` store. The connection string is read from -`CACHED_REDIS_CONNECTION_STRING`. (`main` is `#[tokio::main]`, hence the -`async_tokio_rt_multi_thread` feature even though the cache itself is sync.) +`CACHED_REDIS_CONNECTION_STRING`. (`main` is `#[tokio::main]`; tokio is a +dev-dependency so it is always available for examples.) Start redis if you don't already have one: docker run --rm --name cached-redis-example -p 6379:6379 -d redis Run: CACHED_REDIS_CONNECTION_STRING=redis://127.0.0.1:6379 \ - cargo run --example redis --features "redis_store,async_tokio_rt_multi_thread,proc_macro" + cargo run --example redis --features "redis_store,proc_macro" Cleanup: docker rm -f cached-redis-example */ @@ -31,7 +31,7 @@ enum ExampleError { // will be pulled from the env var: `CACHED_REDIS_CONNECTION_STRING`; #[concurrent_cached( redis = true, - ttl = 30, + ttl_secs = 30, cache_prefix_block = r##"{ "cache-redis-example-1" }"##, map_error = r##"|e| ExampleError::RedisError(format!("{:?}", e))"## )] @@ -44,7 +44,7 @@ fn cached_sleep_secs(secs: u64) -> Result<(), ExampleError> { // is used to create a prefix for cache keys used by this function #[concurrent_cached( redis = true, - ttl = 30, + ttl_secs = 30, map_error = r##"|e| ExampleError::RedisError(format!("{:?}", e))"## )] fn cached_sleep_secs_example_2(secs: u64) -> Result<(), ExampleError> { @@ -69,7 +69,9 @@ static CONFIG: LazyLock = LazyLock::new(Config::load); map_error = r##"|e| ExampleError::RedisError(format!("{:?}", e))"##, ty = "cached::RedisCache", create = r##" { - RedisCache::new("cache_redis_example_cached_sleep_secs_config", Duration::from_secs(1)) + RedisCache::builder() + .prefix("cache_redis_example_cached_sleep_secs_config") + .ttl(Duration::from_secs(1)) .refresh_on_hit(true) .connection_string(&CONFIG.conn_str) .build() @@ -92,8 +94,8 @@ async fn main() { cached_sleep_secs(2).unwrap(); println!("done"); - use cached::ConcurrentCached; - CACHED_SLEEP_SECS.cache_remove(&2).unwrap(); + use cached::ConcurrentCachedExt; + CACHED_SLEEP_SECS.remove(&2).unwrap(); print!("third sync call with a 2 seconds sleep (slow, after cache-remove)..."); io::stdout().flush().unwrap(); cached_sleep_secs(2).unwrap(); diff --git a/examples/sharded.rs b/examples/sharded.rs index 403307a9..8b61bbad 100644 --- a/examples/sharded.rs +++ b/examples/sharded.rs @@ -1,14 +1,14 @@ /* In-memory concurrent memoization with zero boilerplate. -`#[concurrent_cached]` defaults to a sharded in-memory store — no Redis, +`#[concurrent_cached]` defaults to a sharded in-memory store - no Redis, no disk, no `map_error`, no `ty`/`create`. The right variant is selected -automatically based on `max_size` and `ttl` attributes: +automatically based on `max_size` and `ttl_secs` attributes: - (no attrs) → ShardedCache (unbounded, no TTL) - max_size = N → ShardedLruCache (LRU, no TTL) - ttl = T → ShardedTtlCache (unbounded, with TTL) - max_size = N, ttl = T → ShardedLruTtlCache (LRU, with TTL) + (no attrs) -> ShardedUnboundCache (unbounded, no TTL) + max_size = N -> ShardedLruCache (LRU, no TTL) + ttl_secs = T -> ShardedTtlCache (unbounded, with TTL) + max_size = N, ttl_secs = T -> ShardedLruTtlCache (LRU, with TTL) For per-value expiry (`expires = true`), see `examples/sharded_expiring.rs`. @@ -27,17 +27,17 @@ Run: */ use cached::macros::concurrent_cached; -use cached::{ConcurrentCached, ShardedCache, ShardedLruCache}; +use cached::{ShardedLruCache, ShardedUnboundCache}; use std::thread; -// Bare default: ShardedCache (unbounded, no TTL) +// Bare default: ShardedUnboundCache (unbounded, no TTL) #[concurrent_cached] fn compute(x: u64) -> u64 { x * x } // LRU: ShardedLruCache (max_size = 128 requested; actual capacity is ≥ 128 because each shard -// gets ceiling(max_size/shards) slots with a minimum of 16 — so max_size=128 with 8 shards is +// gets ceiling(max_size/shards) slots with a minimum of 16 - so max_size=128 with 8 shards is // exactly 128, but max_size=10 with 8 shards would yield 128 slots (8 × 16 minimum). // See the `max_size` attribute docs for details.) #[concurrent_cached(max_size = 128)] @@ -46,13 +46,13 @@ fn compute_lru(x: u64) -> u64 { } // TTL: ShardedTtlCache (expires after 60 s) -#[concurrent_cached(ttl = 60)] +#[concurrent_cached(ttl_secs = 60)] fn compute_ttl(x: u64) -> u64 { x * x } // LRU + TTL: ShardedLruTtlCache -#[concurrent_cached(max_size = 64, ttl = 30)] +#[concurrent_cached(max_size = 64, ttl_secs = 30)] fn compute_lru_ttl(x: u64) -> u64 { x * x } @@ -63,14 +63,14 @@ fn compute_shards(x: u64) -> u64 { x * x } -// Only cache successful lookups — Err is returned but not stored, so the +// Only cache successful lookups - Err is returned but not stored, so the // function is retried on the next call. #[concurrent_cached] fn load_record(id: u64) -> Result { Ok(format!("record_{id}")) } -// Cache Option — only Some values are stored; None is returned without being +// Cache Option - only Some values are stored; None is returned without being // cached, so find_record(0) will re-execute on every call. #[concurrent_cached] fn find_record(id: u64) -> Option { @@ -92,8 +92,8 @@ fn main() { // Result: only Ok is cached let r1 = load_record(42); let r2 = load_record(42); - assert_eq!(r1.as_deref().expect("infallible"), "record_42"); - assert_eq!(r2.as_deref().expect("infallible"), "record_42"); + assert_eq!(r1.as_deref().expect("load_record returns Ok"), "record_42"); + assert_eq!(r2.as_deref().expect("load_record returns Ok"), "record_42"); println!("load_record(42) = {:?} (cached)", r1); // Option: None is NOT cached by default; the function re-executes each time @@ -133,36 +133,33 @@ fn main() { h.join().expect("thread panicked"); } - // Inspect the cache directly. The `cache_get`/`cache_set`/... methods come from the - // `ConcurrentCached` trait (it must be in scope). The async trait's operations are - // `async_`-prefixed: `COMPUTE.async_cache_get(&7).await`. + // Inspect the cache directly via the inherent `get` method — returns `Option` directly, + // no `.expect("infallible")` needed. The async trait's operations are `async_`-prefixed: + // `COMPUTE.async_cache_get(&7).await` - the async trait provides no short alias, + // so `async_cache_get` is the only spelling available there. { - let val = COMPUTE.cache_get(&7).expect("infallible"); + let val = COMPUTE.get(&7); assert_eq!(val, Some(49)); - println!("cache_get(7) = {val:?}"); + println!("get(7) = {val:?}"); } - // Build a ShardedCache manually and use it without a macro - let cache: ShardedCache = ShardedCache::builder().build().unwrap(); - cache.cache_set(1, "hello".to_string()).expect("infallible"); - cache.cache_set(2, "world".to_string()).expect("infallible"); - assert_eq!( - cache.cache_get(&1).expect("infallible").as_deref(), - Some("hello") - ); - println!( - "manual ShardedCache: {:?}", - cache.cache_get(&1).expect("infallible") - ); + // Build a ShardedUnboundCache manually and use it without a macro. + // The inherent `get`/`set`/`remove` methods return unwrapped values directly. + let cache: ShardedUnboundCache = ShardedUnboundCache::builder().build().unwrap(); + cache.set(1, "hello".to_string()); + cache.set(2, "world".to_string()); + assert_eq!(cache.get(&1).as_deref(), Some("hello")); + println!("manual ShardedUnboundCache: {:?}", cache.get(&1)); - // ShardedLruCache with explicit shard count + // ShardedLruCache with explicit shard count. + // Inherent `set`/`get` return unwrapped values. let lru: ShardedLruCache = ShardedLruCache::builder() .max_size(256) .shards(8) .build() .expect("valid config"); for i in 0..256u32 { - lru.cache_set(i, i * 2).expect("infallible"); + lru.set(i, i * 2); } println!("ShardedLruCache len = {}", lru.len()); println!("ShardedLruCache shard_sizes = {:?}", lru.shard_sizes()); diff --git a/examples/sharded_expiring.rs b/examples/sharded_expiring.rs index e80deb24..e2180992 100644 --- a/examples/sharded_expiring.rs +++ b/examples/sharded_expiring.rs @@ -101,7 +101,8 @@ fn main() { h.join().expect("thread panicked"); } - // 4. Direct Manual construction and usage (without macro) + // 4. Direct Manual construction and usage (without macro). + // The inherent `set`/`get` methods return unwrapped values directly. println!("\n--- Manual Store Construction ---"); let cache: ShardedExpiringCache = ShardedExpiringCache::builder().build().unwrap(); @@ -109,9 +110,9 @@ fn main() { user_id: 100, expired: already_expired.clone(), // starts expired }; - cached::ConcurrentCached::cache_set(&cache, 100, s_manual).expect("infallible"); + cache.set(100, s_manual); - let val = cached::ConcurrentCached::cache_get(&cache, &100).expect("infallible"); + let val = cache.get(&100); assert!( val.is_none(), "Expired manual entry should be filtered out on get" @@ -132,8 +133,8 @@ fn main() { expired: Arc::new(AtomicBool::new(false)), }; println!("Caching session for user_id {}", live_manual.user_id); - cached::ConcurrentCached::cache_set(&lru, 200, live_manual).expect("infallible"); - let val_lru = cached::ConcurrentCached::cache_get(&lru, &200).expect("infallible"); + lru.set(200, live_manual); + let val_lru = lru.get(&200); assert!(val_lru.is_some(), "Live manual entry should be present"); println!( "Manual ShardedExpiringLruCache lookup for live entry: {:?}", diff --git a/examples/struct_method.rs b/examples/struct_method.rs new file mode 100644 index 00000000..07d3f976 --- /dev/null +++ b/examples/struct_method.rs @@ -0,0 +1,233 @@ +/* +Caching results of struct methods. + +Two approaches are shown: + +Part 1 - `in_impl = true` (headline feature): cache a method directly inside an `impl` +block. The cache static is emitted inside the method body. `self` is excluded from the +default key, so all instances share one cache. To differentiate entries per instance, +fold identifying fields into the key with a `convert` expression. + +Part 2 - Free-function wrapper (portable pattern): extract the computation into a free +top-level `#[cached]` function and call it from the method, passing only the fields it +needs. Works on any Rust version and keeps the cache independent of the type. + +Part 3 - Caching calls dispatched through a `dyn Trait` reference, keyed on a stable +numeric id. The trait object itself is not `Hash + Eq + Clone`, so the id serves as the +cache key and the actual computation is forwarded to a free `#[cached]` function. + +Run: + cargo run --example struct_method --features "proc_macro" +*/ + +use cached::macros::cached; + +// --------------------------------------------------------------------------- +// Part 1 - in_impl = true with convert to fold self's id into the key +// --------------------------------------------------------------------------- +// +// The cache is process-global, not per-instance. Without `convert`, two +// `Worker` instances with the same `n` argument would share one cache entry: +// `a.compute(5)` and `b.compute(5)` would return the same value even if +// `a.factor` != `b.factor`. +// +// The fix: include `self.id` in the cache key via `convert`. Each instance +// then occupies its own key space in the shared cache. +// +// Note: because the cache static lives inside the method body, there is no +// module-level static to lock. The cache cannot be inspected or invalidated +// from outside the method (unlike the free-function pattern in basic.rs, +// where `SLOW_FN.write().remove(...)` reaches the module-level static). + +struct Worker { + /// Stable identity that distinguishes this instance in the cache. + id: u64, + factor: u32, +} + +impl Worker { + fn new(id: u64, factor: u32) -> Self { + Self { id, factor } + } + + /// Cached method. The cache static lives inside this fn body. + /// `convert` folds `self.id` into the key so different instances + /// do not share cache entries. + /// + /// A `compute_no_cache` sibling is also generated for calling the raw + /// computation without touching the cache. Its visibility defaults to the + /// method's own; `companions_vis = "pub(crate)"` overrides it independently. + /// The `_prime_cache` companion is NOT generated for `in_impl` methods. + /// + /// `convert` is an unquoted block here (`{ (self.id, n) }`); the legacy + /// quoted-string form (`convert = "{ (self.id, n) }"`) is still accepted. + #[cached( + in_impl = true, + key = "(u64, u32)", + convert = { (self.id, n) }, + companions_vis = "pub(crate)" + )] + fn compute(&self, n: u32) -> u32 { + println!(" [miss] Worker(id={}) compute(n={n})", self.id); + self.factor * n + } +} + +// --------------------------------------------------------------------------- +// Part 2 - Free function wrapping a method computation +// --------------------------------------------------------------------------- + +struct Config { + multiplier: u32, + base: u32, +} + +impl Config { + fn new(multiplier: u32, base: u32) -> Self { + Self { multiplier, base } + } + + /// Calls a free cached function, forwarding only the fields it needs. + /// The struct itself does not need to be `Hash + Eq + Clone`. + fn compute(&self, n: u32) -> u32 { + config_compute(self.multiplier, self.base, n) + } +} + +/// The actual cached computation lives here, as a free function. +/// Keys are derived from the plain arguments; no struct reference is stored. +#[cached] +fn config_compute(multiplier: u32, base: u32, n: u32) -> u32 { + println!(" [miss] config_compute({multiplier}, {base}, {n})"); + multiplier * base + n +} + +// --------------------------------------------------------------------------- +// Part 3 - Caching dyn-Trait calls keyed on a stable numeric id +// --------------------------------------------------------------------------- +// +// When the receiver is a `dyn Trait`, the trait object is not `Hash + Eq`. +// The workaround is the same: extract the computation into a free `#[cached]` +// function whose arguments are all `Hash + Eq + Clone` types. A stable numeric +// id serves as the per-object cache discriminant. +// +// Here the trait exposes a factory-method-style approach: the default `process` +// implementation on the trait calls `processor_compute(self.id(), self.factor(), input)`. +// Each concrete type provides its own `factor()` - the free function handles +// the actual work and caching. +// +// Caution: `id` must be a true identity. Two objects sharing an `id` but +// differing in `factor` (or any other state that affects the result) collide +// on one cache entry, so the second object silently receives the first's +// cached value. The same caution applies to the `(self.id, n)` key in Part 1. + +trait Processor { + /// A stable id that uniquely identifies this instance across calls. + fn id(&self) -> u64; + + /// A value derived from internal state that influences the result. + fn factor(&self) -> u32; + + /// Public entry point - delegates to a free cached function. + /// The cache key is (id, input); `factor` is looked up on a miss only. + fn process(&self, input: u32) -> u32 { + processor_compute(self.id(), self.factor(), input) + } +} + +/// Cached computation for any `Processor`-like object. +/// Key: `(id, input)`. `factor` is only used on a cache miss. +#[cached(key = "(u64, u32)", convert = { (id, input) })] +fn processor_compute(id: u64, factor: u32, input: u32) -> u32 { + println!(" [miss] processor_compute(id={id}, factor={factor}, input={input})"); + input * factor +} + +struct FastProcessor { + id: u64, + factor: u32, +} + +impl Processor for FastProcessor { + fn id(&self) -> u64 { + self.id + } + fn factor(&self) -> u32 { + self.factor + } +} + +// --------------------------------------------------------------------------- +// main +// --------------------------------------------------------------------------- + +pub fn main() { + // --- Part 1: in_impl --- + println!("=== Part 1: in_impl = true with per-instance key ==="); + let wa = Worker::new(1, 3); + let wb = Worker::new(2, 7); + + println!("wa.compute(5), first call (expect miss):"); + let r1 = wa.compute(5); + println!(" result = {r1}"); + assert_eq!(r1, 3 * 5); + + println!("wa.compute(5), second call (same id -> expect hit, no [miss]):"); + let r2 = wa.compute(5); + println!(" result = {r2}"); + assert_eq!(r1, r2); + + println!("wb.compute(5), first call (different id -> expect miss):"); + let r3 = wb.compute(5); + println!(" result = {r3}"); + assert_eq!(r3, 7 * 5); + + println!("wb.compute(5), second call (same id -> expect hit):"); + let r4 = wb.compute(5); + assert_eq!(r3, r4); + + // Demonstrate the generated _no_cache sibling: bypasses the cache entirely. + println!("wa.compute_no_cache(5) bypasses cache (always recomputes):"); + let r5 = wa.compute_no_cache(5); + println!(" result = {r5}"); + assert_eq!(r5, 3 * 5); + + // --- Part 2: free-function wrapper --- + println!("\n=== Part 2: free-function wrapper ==="); + let cfg = Config::new(3, 10); + + println!("First call (expect miss):"); + let v1 = cfg.compute(5); + println!(" result = {v1}"); + + println!("Second call with same args (expect hit, no [miss] line):"); + let v2 = cfg.compute(5); + println!(" result = {v2}"); + assert_eq!(v1, v2); + + println!("Call with different n=6 (expect miss):"); + let v3 = cfg.compute(6); + println!(" result = {v3}"); + assert_eq!(v3, 3 * 10 + 6); + + // --- Part 3: dyn Trait --- + println!("\n=== Part 3: dyn Trait keyed on stable id ==="); + let p1: &dyn Processor = &FastProcessor { id: 1, factor: 7 }; + let p2: &dyn Processor = &FastProcessor { id: 2, factor: 9 }; + + println!("p1, input=4, first call (expect miss):"); + let r1 = p1.process(4); + println!(" result = {r1}"); + + println!("p1, input=4, second call (same id -> expect hit):"); + let r2 = p1.process(4); + println!(" result = {r2}"); + assert_eq!(r1, r2); + + println!("p2, input=4, first call (different id -> expect miss):"); + let r3 = p2.process(4); + println!(" result = {r3}"); + assert_eq!(r3, 4 * 9); + + println!("\ndone!"); +} diff --git a/examples/tokio.rs b/examples/tokio.rs index 64a2db32..0cdccd88 100644 --- a/examples/tokio.rs +++ b/examples/tokio.rs @@ -3,7 +3,7 @@ Async memoization on the tokio runtime: `#[cached]` on `async fn`s, including `with_cached_flag = true` returning `cached::Return` for a fallible function. Run: - cargo run --example tokio --features "async_tokio_rt_multi_thread,proc_macro" + cargo run --example tokio --features "async,proc_macro" */ use cached::macros::cached; diff --git a/examples/wasm/Cargo.toml b/examples/wasm/Cargo.toml index d1bf0676..7ba0c6d0 100644 --- a/examples/wasm/Cargo.toml +++ b/examples/wasm/Cargo.toml @@ -9,7 +9,7 @@ publish = false [dependencies.cached] path = "../.." default-features = false -features = ["proc_macro", "wasm", "async", "time_stores"] +features = ["proc_macro", "async", "time_stores"] [dependencies.chrono] version = "0.4" diff --git a/specs/0001-non-sharded-custom-hasher.md b/specs/0001-non-sharded-custom-hasher.md new file mode 100644 index 00000000..57b535cb --- /dev/null +++ b/specs/0001-non-sharded-custom-hasher.md @@ -0,0 +1,33 @@ +# 0001 - Custom hasher on non-sharded stores + +Status: Implemented + +## Current state + +- Non-sharded stores hardcode the hash builder and expose no hasher type parameter and no + `hasher()` builder method: `UnboundCache` with `HashMap` + (`src/stores/unbound.rs:25`), and likewise `LruCache` (`src/stores/lru.rs:28`), `TtlCache` + (`src/stores/ttl.rs:29`), `LruTtlCache`, `TtlSortedCache`, `ExpiringCache`, `ExpiringLruCache`. +- The `RandomState` is selected at compile time by the `ahash` feature; a user cannot supply + `FxHasher`, a seeded/deterministic hasher for reproducible tests, or a per-instance hasher. +- Sharded stores parameterize only the shard-router hasher (`H: ShardHasher`, + `src/stores/sharded/mod.rs:143`); the per-shard inner map is still + `HashMap` (`src/stores/sharded/unbound.rs:25`). So no store currently lets + the caller choose the map hasher. + +## Desired work + +- Add a hasher type parameter with a default to each non-sharded store, e.g. + `UnboundCache`, and a `.hasher(s: S)` builder method that + switches the builder's `S` (mirrors the sharded `.hasher()` pattern). +- Keep the named types defaulting to today's `RandomState` so existing code is unaffected at the + type level. `S: BuildHasher` bounds appear on the constructor and `Cached` impls. +- Thread the chosen hasher into the LRU internals (`src/stores/lru.rs` already carries a + `hash_builder` field) and the other backing maps. + +## Notes + +- Also surface the sharded inner-map hasher as part of this work, so sharded stores can pick the + map hasher too (not just the shard router). Optional; can be a follow-on if it widens scope. +- Migration: low for ordinary users (defaulted param); turbofish call sites and stored concrete + types may need the extra parameter. Compiler-guided. diff --git a/specs/0002-size-iter-evict-semantics.md b/specs/0002-size-iter-evict-semantics.md new file mode 100644 index 00000000..d74066c6 --- /dev/null +++ b/specs/0002-size-iter-evict-semantics.md @@ -0,0 +1,39 @@ +# 0002 - `len`/`size` vs `iter` vs `evict` semantics + +Status: Implemented + +## Current state + +- `cache_size()` / `len()` return the raw stored entry count, including expired-but-not-swept + entries, on every lazy-eviction store: `TtlCache` (`src/stores/ttl.rs:516`), `TtlSortedCache` + (documented inaccurate), `ExpiringCache` (`src/stores/expiring.rs:405`), `ExpiringLruCache`. +- `CachedIter::iter()` is `&self` and filters expired entries out of the yielded items without + removing them (`src/stores/expiring.rs:434`, `src/stores/ttl.rs:531`). The trait doc already + notes this (`src/lib.rs:1088`). +- `evict()` is `&mut self` and physically removes expired entries, updating the count + (`src/stores/ttl.rs:250`, `src/stores/expiring.rs:204`). +- The inconsistency: `len()` counts expired while `iter().count()` does not, with no single + documented contract tying them together. + +## Desired contract + +- `len`/`size`: return the current known stored size without applying eviction logic (cheap, no + expiry scan). This is the existing behavior; keep it. +- `iter`: continues to omit expired entries from the view (it scans anyway). It does not free + them; `iter` stays `&self`. +- `evict`: the explicit way to reclaim memory and get an accurate live count. + +## Desired work + +- Audit every timed/expiring store (sharded and non-sharded) to confirm the trio behaves as + above, fixing any that deviate. +- Document the contract clearly and consistently: on `cache_size`/`len`, on `CachedIter`, and on + `evict`, plus a short note in the crate-level "Behavioral guarantees" section. State that + `len` may include expired entries and that `evict()` reclaims them. + +## Notes + +- Physical eviction during `iter` was considered and rejected: `iter` yields `(&K, &V)` borrows + under `&self`, so removing during iteration is not possible without changing the receiver to + `&mut self`, which would break the shared-iteration use case. If revisited, treat as a separate + research item. diff --git a/specs/0003-redis-millisecond-ttl.md b/specs/0003-redis-millisecond-ttl.md new file mode 100644 index 00000000..37cef2df --- /dev/null +++ b/specs/0003-redis-millisecond-ttl.md @@ -0,0 +1,28 @@ +# 0003 - Redis millisecond TTL + +Status: Implemented + +## Current state + +- The Redis store rounds any sub-second TTL up to one whole second and writes with `SETEX` / + `EXPIRE` (`src/stores/redis.rs:27`). Builders accept `.ttl_millis(...)` and `.ttl(Duration)` + but the millisecond resolution is discarded at write time, so `ttl_millis(250)` becomes a + 1000ms Redis TTL. +- `ttl_millis` is advertised as a feature in the macro and store docs, but it is inaccurate + below one second on a Redis-backed store. + +## Desired work + +- Convert all TTLs to milliseconds before issuing the Redis command and use the millisecond + commands `PSETEX` / `PEXPIRE` (available since Redis 2.6) so the stored TTL matches the + requested `Duration` to the millisecond. +- Drop the round-up-to-1s path. Update the internal `ttl_seconds`/`ttl_seconds_i64` helpers and + their tests (`src/stores/redis.rs:168`) to millisecond variants. Keep the saturating clamp at + `i64::MAX` in milliseconds. +- Update docs that describe whole-second Redis TTL granularity. + +## Notes + +- Behavior of existing whole-second TTLs is unchanged (`PSETEX` with `secs * 1000`). Only + sub-second TTL users see corrected timing. Targets only Redis older than 2.6 (circa 2012), + which is negligible. diff --git a/specs/0004-redis-connection-string-redaction.md b/specs/0004-redis-connection-string-redaction.md new file mode 100644 index 00000000..7daaabf0 --- /dev/null +++ b/specs/0004-redis-connection-string-redaction.md @@ -0,0 +1,24 @@ +# 0004 - Redact `connection_string()` getter + +Status: Implemented + +## Current state + +- The internal `ConnectionString` wrapper redacts credentials in `Debug`/`Display` + (`src/stores/redis.rs:319`). +- The public `connection_string()` getter on `RedisCache` and `AsyncRedisCache` returns the raw + URL including any password (`src/stores/redis.rs:682`, `src/stores/redis.rs:1345`), with only + a doc-comment warning. This re-exposes exactly what the wrapper hides, and leaks credentials + into logs via `println!("{}", cache.connection_string())`. + +## Desired work + +- Remove the credential-bearing getter, or change it to return the redacted form (or a + `&ConnectionString` whose `Display` is safe). +- If a raw accessor is still wanted, gate it behind an explicit name such as + `connection_string_unredacted()` so the footgun is obvious at the call site. + +## Notes + +- Migration: low. Few callers need the raw URL back out of the cache; those rename to the + explicit accessor. diff --git a/specs/0005-store-error-consistency.md b/specs/0005-store-error-consistency.md new file mode 100644 index 00000000..50924250 --- /dev/null +++ b/specs/0005-store-error-consistency.md @@ -0,0 +1,32 @@ +# 0005 - redb/redis error naming and variant shape + +Status: Implemented + +## Current state + +- `RedbCacheBuildError::Connection(redb::Error)` (`src/stores/redb.rs:61`) is misnamed: redb is + an embedded file database with no connection. Its message says "Storage connection error". + The runtime enum names the same family `RedbCacheError::Storage(redb::Error)` + (`src/stores/redb.rs:450`), so build-time and runtime disagree on the vocabulary. +- Variant shapes are inconsistent across the two backends. Redis uses struct variants with named + fields: `CacheDeserialization { cached_value: String, error: ... }` and + `CacheSerialization { error: ... }` (`src/stores/redis.rs:689`). redb uses tuple variants: + `CacheDeserialization(...)`, `CacheSerialization(...)`, `Storage(...)` + (`src/stores/redb.rs:450`). + +## Desired work + +- Rename `RedbCacheBuildError::Connection` to `Storage`, matching `RedbCacheError::Storage`, and + update its message to drop "connection". +- Convert the redb error enums to struct variants for consistency with redis (named fields, + `#[source]`/`#[from]` on the single error field where applicable). Keep both enums + `#[non_exhaustive]`. +- Pick one field naming convention for the serialize/deserialize variants and apply it to both + backends. + +## Notes + +- Interacts with [0011](0011-redis-serialization-codec.md): switching Redis to MessagePack + changes the concrete error types those variants carry. Land them together so redis.rs error + edits happen once. +- Migration: mechanical match-arm updates; enums are already `#[non_exhaustive]`. diff --git a/specs/0006-macro-quoted-attributes.md b/specs/0006-macro-quoted-attributes.md new file mode 100644 index 00000000..bf4d0f8c --- /dev/null +++ b/specs/0006-macro-quoted-attributes.md @@ -0,0 +1,23 @@ +# 0006 - Retire quoted-string macro attributes + +Status: Not implemented (declined) + +## Current state + +- The proc macros accept code attributes both quoted and unquoted. `convert`, `create`, + `map_error`, `force_refresh`, and `cache_prefix_block` accept unquoted tokens; the + quoted-string form still works. +- `ty` and `key` are still parsed as `Option` + (`cached_proc_macro/src/cached.rs:63,83`). +- So one annotation can mix `ty = "..."` (quoted) with `convert = { ... }` (unquoted). + +## Desired work + +- Make `ty`/`key` token streams and remove quoted-string acceptance for all code/type + attributes. +- Emit a friendly compile error guiding the quote-strip when a quoted form is used. + +## Notes + +- Declined. darling's attribute parsing makes fully removing the quoted form impractical here. +- Keep accepting strings. Revisit only if the macro arg parsing moves off darling. diff --git a/specs/0007-unbound-evictions-counter.md b/specs/0007-unbound-evictions-counter.md new file mode 100644 index 00000000..cab8f011 --- /dev/null +++ b/specs/0007-unbound-evictions-counter.md @@ -0,0 +1,20 @@ +# 0007 - ShardedUnboundCache evictions counter + +Status: Not implemented (declined) + +## Current state + +- `ShardedUnboundCache::metrics()` always returns `evictions: None` + (`src/stores/sharded/unbound.rs:242`) even though its `on_evict` callback fires on + `cache_remove`. +- Every other sharded store tracks an `AtomicU64` evictions counter. + +## Desired work + +- Add an evictions counter to the unbound inner so `metrics().evictions` is `Some(n)`. + +## Notes + +- Declined. An unbound cache has no eviction policy; explicit removes are not evictions in this + model. +- Leave `evictions: None`. The asymmetry is documented. diff --git a/specs/0008-method-name-deduplication.md b/specs/0008-method-name-deduplication.md new file mode 100644 index 00000000..d713e159 --- /dev/null +++ b/specs/0008-method-name-deduplication.md @@ -0,0 +1,28 @@ +# 0008 - Collapse dual method names + +Status: Implemented + +## Current state + +- Every operation on `Cached` exists under two non-deprecated names: a short alias and a + `cache_`-prefixed form. `cache_get`/`get`, `cache_set`/`set`, `cache_remove`/`remove`, + `cache_remove_entry`/`remove_entry`, `cache_clear`/`clear`, `cache_size`/`len`, + `cache_delete`/`delete`, `cache_try_set`/`try_set`, the four `*get_or_set_with*` pairs, + `cache_hits`/`hits`, `cache_misses`/`misses` (`src/lib.rs:740` onward; aliases from + `src/lib.rs:921`). `ConcurrentCached` repeats the pattern (`src/lib.rs:1701`/`1798`). +- `Cached` is roughly 40 public methods, over half pure delegations. This is the trait + implementors read and the prelude pulls in. + +## Desired work + +- Keep the `cache_`-prefixed methods as the required core trait surface and move the short names + (`get`/`set`/`remove`/`clear`/`len`/...) to a blanket extension trait (`CachedExt`, and a + concurrent counterpart) with default impls delegating to the core methods. +- This shrinks the implementor surface (custom stores implement only the core methods) without + removing any caller-facing name. Re-export the extension traits from the prelude. + +## Notes + +- Chosen over deleting one spelling outright (lowest-regret: no caller-facing name disappears). +- Migration: low. Existing call sites keep compiling once the extension trait is in scope (it is + in the prelude). Custom `impl Cached` blocks shrink. diff --git a/specs/0009-cached-get-shared-receiver.md b/specs/0009-cached-get-shared-receiver.md new file mode 100644 index 00000000..a1127773 --- /dev/null +++ b/specs/0009-cached-get-shared-receiver.md @@ -0,0 +1,23 @@ +# 0009 - Cached::get taking &self + +Status: Needs research + +## Current state + +- `Cached::cache_get`/`get` take `&mut self` (`src/lib.rs:740,921`), as do `cache_get_mut`, + `contains`, and `CloneCached::cache_get_with_expiry_status`. +- Justified by LRU recency updates, TTL refresh, and hit/miss metrics mutating on read. +- `CachedPeek::cache_peek` (&self) and `CachedRead::cache_get_read` (&self) exist as + shared-borrow escape hatches. + +## Desired work + +- Move hit/miss counters to Cell/atomics and LRU recency to interior mutability so the core + `get` could take `&self`, matching user intuition. +- If it lands, fold away CachedPeek/CachedRead. + +## Notes + +- Deferred as too invasive for now. Highest-impact ergonomic change but real engineering cost + and a possible borrow-panic surface for RefCell-based LRU recency. +- Revisit deliberately; do not bundle into the current release. Related: 0023. diff --git a/specs/0010-read-optimized-sharded-lru.md b/specs/0010-read-optimized-sharded-lru.md new file mode 100644 index 00000000..1b842dbc --- /dev/null +++ b/specs/0010-read-optimized-sharded-lru.md @@ -0,0 +1,24 @@ +# 0010 - Read-optimized sharded LRU variant + +Status: Needs research + +## Current state + +- `ShardedLruCache::cache_get` and `ShardedExpiringLruCache::cache_get` acquire an exclusive + write lock on every read hit to update recency (`src/stores/sharded/lru.rs:356`, + `src/stores/sharded/expiring_lru.rs:370`), serializing reads within a shard. +- The non-LRU sharded stores read under a shared lock. +- The crate docs already note this as a known limitation and point users to + ShardedUnboundCache. + +## Desired work + +- A future store type using sampled/clock or TinyLFU recency that reads under a shared lock and + only takes the write lock on insert/eviction or a sampled fraction of hits. + +## Notes + +- Deferred. Will ship as a separate distinct store type rather than changing the strict-LRU + stores' semantics. +- Tracked here so we come back to it. Document the limitation in the LRU store docs in the + meantime. diff --git a/specs/0011-redis-serialization-codec.md b/specs/0011-redis-serialization-codec.md new file mode 100644 index 00000000..6b57052c --- /dev/null +++ b/specs/0011-redis-serialization-codec.md @@ -0,0 +1,27 @@ +# 0011 - Redis serialization codec + +Status: MessagePack switch implemented; pluggable codec needs research + +## Current state + +- Redis serializes values with serde_json and stores a UTF-8 String + (`src/stores/redis.rs:824`). +- redb uses rmp-serde (MessagePack) on bytes (`src/stores/redb.rs:776`). +- The codec is a private per-store detail; the error enums bake in the concrete serde error + type. + +## Desired work + +- Target 3.0: switch the Redis store from serde_json-as-String to MessagePack (rmp-serde), + matching redb, storing bytes. This changes the wire format and the error types those variants + carry (see 0005). +- Needs research: a `Codec` abstraction wired into the builders (builder-set, not a generic + type param, to avoid signature leakage) defaulting to the per-store choice, so users can pick + bincode/cbor/json. + +## Notes + +- The MessagePack switch is a wire-format change; existing Redis entries are recomputed on miss, + same tradeoff already accepted for the sled->redb backend change. +- Land the Redis error-enum edits together with 0005. +- The pluggable-codec design is unresolved and tracked for later. diff --git a/specs/0012-concurrent-metrics-trait.md b/specs/0012-concurrent-metrics-trait.md new file mode 100644 index 00000000..bfb2b1d5 --- /dev/null +++ b/specs/0012-concurrent-metrics-trait.md @@ -0,0 +1,28 @@ +# 0012 - Expose sharded metrics through a trait + +Status: Implemented + +## Current state + +- Non-sharded stores expose metrics through the `Cached` trait: `cache_hits`, `cache_misses`, + `cache_capacity`, `cache_evictions`, and a default `metrics() -> CacheMetrics` + (`src/lib.rs:1059`-`1085`). `CacheMetrics` is shared (`src/lib.rs:1122`). +- Sharded stores return the same `CacheMetrics` struct but only via inherent methods + (`src/stores/sharded/unbound.rs:230`, `src/stores/sharded/lru.rs:223`), plus inherent + `shards()`, `shard_sizes()`, `clear()`, `cache_clear_with_on_evict()`. None of these is on a + trait, so generic code over `ConcurrentCached` cannot read a hit rate or shard distribution. + +## Desired work + +- Mirror the non-sharded design: expose the metric accessors (`cache_hits`/`cache_misses`/ + `cache_capacity`/`cache_evictions` and a default `metrics()`) on the concurrent trait family + (extend `ConcurrentCacheBase`, or add an introspection trait) so they are reachable through a + `ConcurrentCached` bound, consistent with `Cached`. +- Keep the inherent methods so `store.metrics()` still resolves without an import. +- Decide whether `shards()`/`shard_sizes()` and the callback-firing clear also belong on the + trait, or stay inherent-only as sharded-specific introspection. + +## Notes + +- Consistency target: the non-sharded metric surface lives on the base read trait, so the + concurrent metric surface should live on the concurrent base trait too. diff --git a/specs/0013-macro-store-attribute-placement.md b/specs/0013-macro-store-attribute-placement.md new file mode 100644 index 00000000..b8e4f32b --- /dev/null +++ b/specs/0013-macro-store-attribute-placement.md @@ -0,0 +1,27 @@ +# 0013 - Friendly rejection of store attrs on `#[cached]` + +Status: Implemented + +## Current state + +- `disk` and `redis` store selectors exist only on `#[concurrent_cached]`. The `#[cached]` and + `#[once]` argument structs use `#[derive(FromMeta)]` with no such fields + (`cached_proc_macro/src/cached.rs:29`), so `#[cached(disk = true)]` already fails, but with + darling's generic "Unknown field: `disk`" error. +- `#[concurrent_cached]` already has the reverse: `reject_cached_only_attrs` + (`cached_proc_macro/src/concurrent_cached.rs:151`) emits friendly messages for + `sync_writes`/`sync_lock`/`result`/etc., pointing the user the right way. + +## Desired work + +- Add the mirror-image check on `#[cached]` (and `#[once]`): detect the concurrent-store-only + attributes (`disk`, `redis`, and any others that only make sense on the concurrent path) and + emit a clear compile error directing the user to `#[concurrent_cached]`, instead of darling's + generic unknown-field message. +- Confirm `ty` + `create` remain valid on `#[cached]` (custom in-memory store), so the rejection + targets only the I/O-backed store selectors. + +## Notes + +- No functional change to which attributes are accepted; this is an error-message improvement so + users land on the correct macro. diff --git a/specs/0014-infallible-builders.md b/specs/0014-infallible-builders.md new file mode 100644 index 00000000..ef036bfc --- /dev/null +++ b/specs/0014-infallible-builders.md @@ -0,0 +1,25 @@ +# 0014 - Infallible builders return the cache directly + +Status: Needs research + +## Current state + +- `UnboundCacheBuilder::build` and `ExpiringCacheBuilder::build` can never fail but return + `Result<_, BuildError>` and call `.expect("infallible")` internally + (`src/stores/unbound.rs:120`, `src/stores/expiring.rs:159`). +- The fallible stores' `new(max_size)` constructors panic on a zero/oversized value + (`src/stores/lru.rs:186`), so the terse constructor panics while the verbose builder is the + safe one. + +## Desired work + +- Make genuinely-infallible builders return the cache directly + (`build(self) -> UnboundCache`), drop the Result. +- Keep `build() -> Result` for fallible stores. +- Consider a `try_new()` for the runtime-derived-size case. + +## Notes + +- Migration is mechanical (drop `?`/`.unwrap()` at call sites). +- Different `build()` return shapes across stores is honest about which can fail. Decide + deliberately for 3.0. diff --git a/specs/0015-sharded-base-alias-collapse.md b/specs/0015-sharded-base-alias-collapse.md new file mode 100644 index 00000000..7129c84c --- /dev/null +++ b/specs/0015-sharded-base-alias-collapse.md @@ -0,0 +1,22 @@ +# 0015 - Collapse *Base + alias into a defaulted type param + +Status: Needs research + +## Current state + +- Each sharded store ships three public names: `ShardedXBase`, + `ShardedX = ...Base`, and `ShardedXBuilder` (e.g. + `src/stores/sharded/unbound.rs:43,51`). +- The `*Base` name leaks into doc links and error messages. + +## Desired work + +- Collapse to one generic type per store with a defaulted hasher param, + `ShardedX`, like `std::collections::HashMap`, dropping the separate `*Base` alias. + +## Notes + +- Lower priority now that the turbofish-drops-hasher footgun is already fixed. +- Migration is a mechanical rename `ShardedXBase` -> `ShardedX`; a deprecated alias could ease + it. Touches every custom-hasher user and doc reference. diff --git a/specs/0016-async-core-internal-feature.md b/specs/0016-async-core-internal-feature.md new file mode 100644 index 00000000..268a7209 --- /dev/null +++ b/specs/0016-async-core-internal-feature.md @@ -0,0 +1,19 @@ +# 0016 - Make async_core internal + +Status: Needs research + +## Current state + +- `async_core` is a public, empty marker feature that gates the runtime-agnostic async traits; + `async` adds async-lock and blocking on top (`Cargo.toml:28`). +- There is no clear standalone use for enabling `async_core` alone. + +## Desired work + +- Make `async_core` internal (rename to `_async_core` or fold its gating into `async`) so the + public surface has a single `async` knob. + +## Notes + +- Verify no example or downstream relies on enabling `async_core` alone before hiding it. +- Migration: low (likely no users). diff --git a/specs/0017-redis-feature-axes.md b/specs/0017-redis-feature-axes.md new file mode 100644 index 00000000..b03a9b47 --- /dev/null +++ b/specs/0017-redis-feature-axes.md @@ -0,0 +1,27 @@ +# 0017 - Orthogonal redis runtime x TLS features + +Status: Needs research + +## Current state + +- Eight redis features encode a runtime x TLS cross-product by hand: redis_smol, + redis_smol_native_tls, redis_smol_rustls, redis_tokio, redis_tokio_native_tls, + redis_tokio_rustls, plus redis_async_cache and redis_connection_manager + (`Cargo.toml:30-46`). +- The AsyncRedisCache export is gated on 8-way `any(...)` cfg lists (`src/lib.rs:595`, + `src/stores/mod.rs:281`). + +## Desired work + +- Make the axes orthogonal: keep redis_tokio/redis_smol as runtime selectors and replace the + four fused TLS combos with backend-only redis_native_tls/redis_rustls, so a user composes + "tokio + rustls". +- At minimum, introduce one internal aggregator feature so the 8-way `any()` lists collapse. + +## Notes + +- Cargo features are additive; an orthogonal TLS feature with no runtime needs a compile_error + guard or is a no-op. +- If Cargo cannot route one TLS feature to two runtimes cleanly, fall back to the internal + aggregator. +- Migration: 1:1 table in the guide. diff --git a/specs/0018-redis-key-escaping.md b/specs/0018-redis-key-escaping.md new file mode 100644 index 00000000..4dd3da3b --- /dev/null +++ b/specs/0018-redis-key-escaping.md @@ -0,0 +1,20 @@ +# 0018 - Escape redis key segments + +Status: Needs research + +## Current state + +- `generate_redis_key` joins namespace:prefix:key without escaping interior colons + (`src/stores/redis.rs:59`), so namespace="a:b" collides with namespace="a", prefix="b". +- The code documents this and a test asserts the collision. + +## Desired work + +- Length-prefix or percent-escape the segment joins so distinct (namespace, prefix, key) tuples + always map to distinct Redis keys. + +## Notes + +- Wire-format (key layout) change; existing keys are recomputed on miss after upgrade. +- Could escape only when a colon is present to keep keys readable in redis-cli. +- Lower priority since it is already documented. diff --git a/specs/0019-ahash-default-feature.md b/specs/0019-ahash-default-feature.md new file mode 100644 index 00000000..41131d78 --- /dev/null +++ b/specs/0019-ahash-default-feature.md @@ -0,0 +1,19 @@ +# 0019 - Drop ahash from default features + +Status: Needs research + +## Current state + +- `default = ["proc_macro", "ahash", "time_stores"]` (`Cargo.toml:25`). +- `ahash` pulls dep:ahash + hashbrown/default and is enabled for everyone, including users who + only need #[once] or an UnboundCache. + +## Desired work + +- Keep proc_macro and time_stores in default, move ahash out so users opt in with + features = ["ahash"]. + +## Notes + +- Genuinely debatable for a cache crate where hashing is hot; decide consciously. +- Migration: add "ahash" to features to keep old behavior; document the rationale either way. diff --git a/specs/0020-argument-error-unification.md b/specs/0020-argument-error-unification.md new file mode 100644 index 00000000..408e4173 --- /dev/null +++ b/specs/0020-argument-error-unification.md @@ -0,0 +1,20 @@ +# 0020 - Unify single-variant argument errors + +Status: Needs research + +## Current state + +- Three single-variant error enums for bad setter arguments: `SetMaxSizeError::ZeroSize`, + `SetTtlError::ZeroTtl`, `CacheSetError::TimeBounds` (`src/stores/mod.rs`). +- All are "you passed a bad argument at a setter". + +## Desired work + +- Merge into one small enum (e.g. `ValueError` with `ZeroSize | ZeroTtl | TimeBounds`), reducing + the number of public error types. + +## Notes + +- Counter-argument: the current per-operation single-variant typing is precise (a + try_set_max_size can only fail one way). Lean toward keeping them split. +- Tracked as a conscious 3.0 decision. All are already #[non_exhaustive]. diff --git a/specs/0021-redb-refresh-on-hit-cost.md b/specs/0021-redb-refresh-on-hit-cost.md new file mode 100644 index 00000000..da514a3a --- /dev/null +++ b/specs/0021-redb-refresh-on-hit-cost.md @@ -0,0 +1,22 @@ +# 0021 - Amortize redb refresh-on-hit write txns + +Status: Needs research + +## Current state + +- redb `cache_get` with refresh_on_hit opens a write transaction on every hit to rewrite + `created_at` (`src/stores/redb.rs:560`), turning reads into serialized writes. +- Each async op runs one redb transaction on the global blocking pool + (`src/stores/redb.rs:852`). + +## Desired work + +- Store an absolute expiry timestamp and only rewrite when the remaining TTL crosses a + threshold, amortizing the refresh. +- Bump DISK_FILE_VERSION if the on-disk representation changes (old files recomputed). + +## Notes + +- Amortized refresh weakens the "every hit resets the clock" guarantee slightly; document the + contract. +- The blocking-pool-saturation concern is doc-only for now. diff --git a/specs/0022-serialize-cached-set-ref-return.md b/specs/0022-serialize-cached-set-ref-return.md new file mode 100644 index 00000000..986e8ba9 --- /dev/null +++ b/specs/0022-serialize-cached-set-ref-return.md @@ -0,0 +1,21 @@ +# 0022 - cache_set_ref returning previous value + +Status: Needs research + +## Current state + +- `SerializeCached::cache_set_ref` and `SerializeCachedAsync::async_cache_set_ref` return the + previous value `Result, _>` (`src/lib.rs:2070,2094`). +- For serialize-backed stores that means a read+deserialize of the old entry on every set, but + the trait's only caller (the #[concurrent_cached] fast path) discards the return. + +## Desired work + +- Change the borrowed setter to return `Result<(), _>`, or split off an explicit swap method + that returns the previous value. + +## Notes + +- Performance: avoids a previous-value decode nobody on the fast path consumes (extra round trip + on Redis, extra table read on redb). +- Migration: custom impls drop the previous-value computation; macro users unaffected. diff --git a/specs/0023-peek-read-trait-merge.md b/specs/0023-peek-read-trait-merge.md new file mode 100644 index 00000000..16e3901d --- /dev/null +++ b/specs/0023-peek-read-trait-merge.md @@ -0,0 +1,24 @@ +# 0023 - Merge CachedPeek/CachedRead; trait fragmentation + +Status: Needs research + +## Current state + +- The public trait surface is ~16 traits. +- `CachedRead` (`src/lib.rs:1169`) adds exactly one method that defaults to calling + `CachedPeek::cache_peek` (`src/lib.rs:1156`). +- Basic operations on a TTL store can require importing several traits (Cached + CacheTtl + + CacheEvict + CloneCached). The prelude re-exports 14 traits. + +## Desired work + +- Merge CachedPeek + CachedRead into one trait. +- Consider folding CacheEvict into CacheTtl and ConcurrentCacheEvict into ConcurrentCacheTtl, + since every store that has one has the other. Shrinks the prelude and the per-store import + count. + +## Notes + +- The Peek/Read merge is nearly free (one delegates to the other). +- Folding Evict into Ttl couples a TTL knob with a sweep method; given the actual store set that + coupling is fine. Related: 0009. diff --git a/specs/0024-generated-companion-naming.md b/specs/0024-generated-companion-naming.md new file mode 100644 index 00000000..1670fede --- /dev/null +++ b/specs/0024-generated-companion-naming.md @@ -0,0 +1,23 @@ +# 0024 - Rename or namespace generated companion fns + +Status: Needs research + +## Current state + +- All three macros emit free functions `{fn}_no_cache` and `{fn}_prime_cache` into the parent + module (`cached_proc_macro/src/cached.rs:824,1126`; `once.rs:565,769`), which can collide with + user functions. +- `_prime_cache` is tagged `#[allow(dead_code)]`, an admission it is often unused. +- No way to suppress generation. + +## Desired work + +- Adopt one naming scheme across all three macros (e.g. `{fn}_uncached`/`{fn}_prime`, or a + generated `{fn}_cache` module namespacing both). +- Add a switch (e.g. `companions = false`) to suppress generation. + +## Notes + +- A module namespace is the cleaner end state but a bigger break than a rename. +- Migration: rename call sites. Pick one scheme and apply identically to + #[cached]/#[once]/#[concurrent_cached]. diff --git a/specs/0025-redb-disk-path-introspection.md b/specs/0025-redb-disk-path-introspection.md new file mode 100644 index 00000000..5335bd0d --- /dev/null +++ b/specs/0025-redb-disk-path-introspection.md @@ -0,0 +1,24 @@ +# 0025 - redb resolved-path introspection and temp fallback + +Status: Needs research + +## Current state + +- redb's `name` is validated only at build() and the file is `_v.redb` + under a default dir derived from the exe name (`src/stores/redb.rs:193,275`). +- The default-dir logic silently falls back from the system cache dir to the temp dir on + PermissionDenied (`src/stores/redb.rs:214`), so a cache can land in /tmp without the caller + knowing. + +## Desired work + +- Expose the resolved disk path before build (a builder `resolved_disk_path()` mirroring redis's + resolve_connection_string). +- Make the temp-dir fallback explicit (an opt-in builder flag) or return an error instead of + silently using a volatile location. + +## Notes + +- A durable store silently relocating to /tmp is a correctness surprise. Middle ground: an + explicit `allow_temp_fallback(bool)`. +- Migration: low; most users pass disk_directory explicitly. diff --git a/specs/0026-serde-feature.md b/specs/0026-serde-feature.md new file mode 100644 index 00000000..950311cd --- /dev/null +++ b/specs/0026-serde-feature.md @@ -0,0 +1,20 @@ +# 0026 - Explicit serde feature for custom serialize stores + +Status: Needs research + +## Current state + +- serde/serde_json are pulled only via redis_store; rmp-serde only via redb_store + (`Cargo.toml:30,46`). +- There is no top-level serde feature, so a custom serialize-backed store author (using the + SerializeCached trait) cannot enable serde support independent of choosing redis or redb. + +## Desired work + +- Add an explicit `serde` feature that the store features depend on, so serde is enableable on + its own. + +## Notes + +- Additive (does not require a major), but pairs with the serialize-store extension point. +- Skip if keeping the feature count down is preferred and the audience is niche. diff --git a/specs/README.md b/specs/README.md new file mode 100644 index 00000000..ab92fc09 --- /dev/null +++ b/specs/README.md @@ -0,0 +1,44 @@ +# Specs + +Tracked design items for `cached`, mostly breaking changes scoped to the 3.0 release. Each +file documents one item: current state in the code, the desired work, and a status. + +This directory is a working record, not user-facing docs. Once an item ships, its substance +moves to the changelog and migration guide; the spec stays here for history. + +## Status legend + +- **Implemented** - landed on the 3.0 branch. +- **Not implemented** - agreed direction, not yet built (or a conscious decision not to build). +- **Needs research** - direction is plausible but unresolved; do not build until scoped. + +## Index + +| Spec | Item | Status | +|---|---|---| +| [0001](0001-non-sharded-custom-hasher.md) | Custom hasher on non-sharded stores | Implemented | +| [0002](0002-size-iter-evict-semantics.md) | `len`/`size` vs `iter` vs `evict` semantics + docs | Implemented | +| [0003](0003-redis-millisecond-ttl.md) | Redis millisecond TTL (`PSETEX`/`PEXPIRE`) | Implemented | +| [0004](0004-redis-connection-string-redaction.md) | Redact `connection_string()` getter | Implemented | +| [0005](0005-store-error-consistency.md) | redb/redis error naming + struct variants | Implemented | +| [0006](0006-macro-quoted-attributes.md) | Retire quoted-string macro attrs | Not implemented (declined) | +| [0007](0007-unbound-evictions-counter.md) | `ShardedUnboundCache` evictions counter | Not implemented (declined) | +| [0008](0008-method-name-deduplication.md) | Collapse dual method names via extension trait | Implemented | +| [0009](0009-cached-get-shared-receiver.md) | `Cached::get` taking `&self` | Needs research | +| [0010](0010-read-optimized-sharded-lru.md) | Read-optimized sharded LRU variant | Needs research | +| [0011](0011-redis-serialization-codec.md) | Redis -> MessagePack; pluggable codec | MessagePack implemented; codec needs research | +| [0012](0012-concurrent-metrics-trait.md) | Expose sharded metrics through a trait | Implemented | +| [0013](0013-macro-store-attribute-placement.md) | Friendly rejection of store attrs on `#[cached]` | Implemented | +| [0014](0014-infallible-builders.md) | Infallible builders return the cache directly | Needs research | +| [0015](0015-sharded-base-alias-collapse.md) | Collapse `*Base` + alias into a defaulted type param | Needs research | +| [0016](0016-async-core-internal-feature.md) | Make `async_core` internal | Needs research | +| [0017](0017-redis-feature-axes.md) | Orthogonal redis runtime x TLS features | Needs research | +| [0018](0018-redis-key-escaping.md) | Escape redis namespace/prefix/key segments | Needs research | +| [0019](0019-ahash-default-feature.md) | Drop `ahash` from default features | Needs research | +| [0020](0020-argument-error-unification.md) | Unify single-variant argument errors | Needs research | +| [0021](0021-redb-refresh-on-hit-cost.md) | Amortize redb refresh-on-hit write txns | Needs research | +| [0022](0022-serialize-cached-set-ref-return.md) | `cache_set_ref` returning previous value | Needs research | +| [0023](0023-peek-read-trait-merge.md) | Merge `CachedPeek`/`CachedRead`; trait fragmentation | Needs research | +| [0024](0024-generated-companion-naming.md) | Rename/namespace generated companion fns | Needs research | +| [0025](0025-redb-disk-path-introspection.md) | redb resolved-path introspection + temp fallback | Needs research | +| [0026](0026-serde-feature.md) | Explicit `serde` feature for custom serialize stores | Needs research | diff --git a/src/lib.rs b/src/lib.rs index 265a6181..541be709 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,16 +12,22 @@ Memoized functions defined using `#[cached]`/`#[once]` macros are thread-safe wi function-cache wrapped in a mutex/rwlock. `#[concurrent_cached]` functions are thread-safe via the store's own internal synchronization: sharded stores use per-shard `parking_lot::RwLock`; Redis and disk stores rely on their respective server/file-system concurrency. -By default, the function-cache is **not** locked for the duration of the function's execution, so initial (on an empty cache) -concurrent calls of long-running functions with the same arguments will each execute fully and each overwrite -the memoized value as they complete. This mirrors the behavior of Python's `functools.lru_cache`. To synchronize the execution and caching -of un-cached arguments, specify `#[cached(sync_writes = true)]` / `#[once(sync_writes = true)]`; for -`#[cached]`, use `sync_writes = "by_key"` to synchronize duplicate keys through bucketed per-key locks -(not supported by `#[once]` or `#[concurrent_cached]`). +By default, `#[cached]` uses `sync_writes = "by_key"`: concurrent first calls for the same key are +deduplicated through bucketed per-key locks, so the function body runs at most once per key per miss +window. To allow concurrent misses to each compute independently (the pre-3.0 default, which mirrored +Python's `functools.lru_cache`), set `sync_writes = false`. To hold the whole-cache lock for the +duration of each miss, use `sync_writes = true` (or `"default"`). `#[once]` defaults to no +synchronization (add `sync_writes = true` to serialize concurrent first-calls); `#[concurrent_cached]` +does not support `sync_writes`. For `#[cached]`, the number of per-key lock buckets for `"by_key"` is +tunable with `sync_writes_buckets = N` (default 64). - See [`cached::stores` docs](https://docs.rs/cached/latest/cached/stores/index.html) cache stores available. - See [`macros` docs](https://docs.rs/cached/latest/cached/macros/index.html) for more macro examples. +> **Upgrading from 2.x?** See the +> [migration guide](https://github.com/jaemk/cached/blob/master/docs/migrations/2.0-to-unreleased.md) +> for all breaking changes and a step-by-step walkthrough. +> > **Upgrading from 1.x?** 2.0 contains breaking changes (new `cache_remove_entry` required method, > `Result`/`Option` caching behavior flipped to smart-by-default, `result`/`option` attributes > removed, and more). See the @@ -36,28 +42,55 @@ of un-cached arguments, specify `#[cached(sync_writes = true)]` / `#[once(sync_w > [agent-oriented guide](https://github.com/jaemk/cached/blob/master/docs/migrations/0.x-to-1.0.md) > for automated migration tooling. +**Method naming** + +Every synchronous cache operation has a short alias (`get`/`set`/`remove`/`clear`/`len`/...) and a +`cache_`-prefixed form (`cache_get`/`cache_set`/`cache_remove`/`cache_clear`/`cache_size`/...). +The short aliases are the preferred spelling. Use the `cache_`-prefixed names when a short alias +would collide with another in-scope trait's method of the same name (for example, your type also +implements a trait with its own `get`). + +The `get`/`set`/`remove` short aliases for `Cached` stores live on `CachedExt`; those for +`ConcurrentCached` stores live on `ConcurrentCachedExt`. Both extension traits have blanket +implementations, so the short names are always available when the extension trait is in scope. +The simplest way to get them is `use cached::prelude::*;`, which re-exports both extension traits. +Alternatively, import them directly: `use cached::{Cached, CachedExt};`. Custom store +implementations only need to implement the `cache_`-prefixed required methods on the core trait; +the short aliases come for free via the blanket extension trait impl. + +For `Cached` stores, `len`/`is_empty` are also on `CachedExt`. For `ConcurrentCached` stores, +`len`/`is_empty` are defined on `ConcurrentCacheBase` (the shared base trait), not on +`ConcurrentCachedExt` — bring `ConcurrentCacheBase` into scope to call them on a generic bound. + +Both async traits use the `async_cache_*` spelling. `ConcurrentCachedAsync` has +`async_cache_get`, `async_cache_set`, `async_cache_remove`, ...; `CachedAsync` has +`async_cache_get`, `async_cache_set`, `async_cache_remove`, `async_cache_clear`, plus the +`async_cache_get_or_set_with` family (`async_cache_get_or_set_with`, +`async_cache_try_get_or_set_with`, and their `_mut` variants). Neither trait has a short alias; +the `async_` prefix already prevents collisions with the sync methods. + **Features** - `default`: Include `proc_macro`, `ahash`, and `time_stores` features - `proc_macro`: Include proc macros - `ahash`: Enable the optional `ahash` hasher as default hashing algorithm. - `async_core`: Include runtime-agnostic async traits used by async cache stores -- `async`: Include support for async functions and async cache stores using Tokio synchronization -- `async_tokio_rt_multi_thread`: Enable `tokio`'s optional `rt-multi-thread` feature. +- `async`: Include support for async functions and async cache stores (runtime-agnostic; no tokio dependency; uses `async-lock` and `blocking`) - `redis_store`: Include Redis cache store -- `redis_smol`: Include async Redis support using `smol` and `smol` tls support, implies `redis_store` and `async` -- `redis_tokio`: Include async Redis support using `tokio` and `tokio` tls support, implies `redis_store` and `async` +- `redis_smol`: Include async Redis support using `smol` (no TLS); implies `redis_store` and `async` +- `redis_smol_native_tls`: `redis_smol` + TLS via `native-tls` (system TLS library) +- `redis_smol_rustls`: `redis_smol` + TLS via `rustls` (pure-Rust TLS) +- `redis_tokio`: Include async Redis support using `tokio` (no TLS); implies `redis_store` and `async` +- `redis_tokio_native_tls`: `redis_tokio` + TLS via `native-tls` (system TLS library) +- `redis_tokio_rustls`: `redis_tokio` + TLS via `rustls` (pure-Rust TLS) - `redis_connection_manager`: Enable the optional `connection-manager` feature of `redis`. Any async redis caches created will use a connection manager instead of a `MultiplexedConnection`. Implies `async` (Tokio runtime) and `redis_store`, - but does **not** enable TLS. Add `redis_tokio` alongside if TLS is required. + but does **not** enable TLS. Add `redis_tokio_native_tls` or `redis_tokio_rustls` alongside if TLS is required. - `redis_async_cache`: Enable Redis client-side caching over RESP3 for async Redis caches. - When enabled standalone, this feature defaults to the Tokio async Redis path. -- `redis_ahash`: Enable the optional `ahash` feature of `redis` -- `disk_store`: Include disk cache store -- `wasm`: Enable WASM support. Note that this feature is incompatible with `tokio`'s multi-thread - runtime (`async_tokio_rt_multi_thread`) and all Redis features (`redis_store`, `redis_smol`, `redis_tokio`, `redis_ahash`) + Implies `redis_tokio`, `async`, and `redis_store`, but does not enable TLS. Add `redis_tokio_native_tls` or `redis_tokio_rustls` alongside if TLS is required. +- `redb_store`: Include disk cache store - `time_stores`: Include time-based cache stores ([`TtlCache`](https://docs.rs/cached/latest/cached/struct.TtlCache.html), [`LruTtlCache`](https://docs.rs/cached/latest/cached/struct.LruTtlCache.html), [`TtlSortedCache`](https://docs.rs/cached/latest/cached/struct.TtlSortedCache.html), [`ShardedTtlCache`](https://docs.rs/cached/latest/cached/type.ShardedTtlCache.html), and [`ShardedLruTtlCache`](https://docs.rs/cached/latest/cached/type.ShardedLruTtlCache.html)). - Also required when using `#[concurrent_cached(ttl = …)]` on the default in-memory path. + Also required when using `#[cached(ttl_secs = ...)]`, `#[cached(ttl = ...)]`, `#[cached(ttl_millis = ...)]`, `#[concurrent_cached(ttl_secs = ...)]`, `#[concurrent_cached(ttl = ...)]`, `#[concurrent_cached(ttl_millis = ...)]`, `#[once(ttl_secs = ...)]`, `#[once(ttl = ...)]`, or `#[once(ttl_millis = ...)]` on the default in-memory path. Disable this feature when targeting environments without system time support (e.g. `wasm32-unknown-unknown` without WASI or JS). The procedural macros (`#[cached]`, `#[once]`, `#[concurrent_cached]`) offer a number of features, including async support. @@ -74,43 +107,59 @@ Any custom cache that implements `cached::ConcurrentCached`/`cached::ConcurrentC | Use case | Annotated signature | |---|---| | **`#[cached]`** | | -| Unbounded memoize (default) | `#[cached] fn fib(n: u64) -> u64` | +| Unbounded memoize (default; deduplicates concurrent misses per key) | `#[cached] fn fib(n: u64) -> u64` | +| Unbounded memoize, allow concurrent misses per key (old default) | `#[cached(sync_writes = false)] fn fib(n: u64) -> u64` | | LRU-bounded — evict past N entries | `#[cached(max_size = 1_000)] fn lookup(id: u32) -> Row` | -| TTL — expire results after N seconds | `#[cached(ttl = 60)] fn config() -> Config` | -| LRU + TTL | `#[cached(max_size = 500, ttl = 300)] fn search(q: String) -> Vec` | +| TTL — expire results after N whole seconds | `#[cached(ttl_secs = 60)] fn config() -> Config` | +| TTL as a Duration expression (inlined verbatim, so `Duration` must be in scope; see note below) | `#[cached(ttl = "Duration::from_secs(60)")] fn config() -> Config` | +| TTL in milliseconds (sub-second capable; Redis honors millisecond TTL via PSETEX/PEXPIRE) | `#[cached(ttl_millis = 500)] fn poll(id: u64) -> Status` | +| LRU + TTL | `#[cached(max_size = 500, ttl_secs = 300)] fn search(q: String) -> Vec` | | Don't cache `None` returns (implicit for `Option`) | `#[cached] fn find(id: u64) -> Option` | | Don't cache `Err` returns (implicit for `Result`) | `#[cached] fn load(id: u64) -> Result` | | Force-cache `None` returns | `#[cached(cache_none = true)] fn find(id: u64) -> Option` | | Force-cache `Err` returns | `#[cached(cache_err = true)] fn load(id: u64) -> Result` | -| Serve stale value when function returns `Err` | `#[cached(result_fallback = true, ttl = 60)] fn fetch(id: u64) -> Result` | +| Serve stale value when function returns `Err` | `#[cached(result_fallback = true, ttl_secs = 60)] fn fetch(id: u64) -> Result` | | Per-value / dynamic per-entry TTL (value carries its own expiry) | `#[cached(expires = true)] fn token(scope: String) -> Token` | -| Deduplicate concurrent first calls for same key | `#[cached(ttl = 30, sync_writes = "by_key")] fn expensive(id: u64) -> Payload` | +| Deduplicate concurrent first calls for same key (explicit; same as bare `#[cached]`) | `#[cached(ttl_secs = 30, sync_writes = "by_key")] fn expensive(id: u64) -> Payload` | +| Recompute when an expression over the args is true | `#[cached(force_refresh = { id == 0 })] fn fetch(id: u64) -> Data` | +| Force-refresh via a dedicated flag (exclude it from the key) | `#[cached(key = "u64", convert = { id }, force_refresh = { refresh })] fn fetch(id: u64, refresh: bool) -> Data { let _ = refresh; … }` — the generated guard reads `refresh` to decide whether to bypass the cache; the function body still receives `refresh` as a normal parameter, so if your body does not otherwise use it, add `let _ = refresh;` (or `#[allow(unused_variables)]`) to silence the unused-variable warning | +| Cache a method inside an `impl` block (one cache shared across all instances) | `#[cached(in_impl = true)] fn load(&self, id: u64) -> Data` | +| Control visibility of generated `_no_cache` / `_prime_cache` companions | `#[cached(companions_vis = "pub(crate)")] pub fn compute(x: u64) -> u64` | | Async | `#[cached(max_size = 100)] async fn remote(id: u64) -> Data` | | **`#[once]`** | | | Compute and cache a global value forever | `#[once] fn app_config() -> Config` | -| Refresh a global value periodically | `#[once(ttl = 300, sync_writes = true)] fn pubkey() -> Key` | +| Refresh a global value periodically | `#[once(ttl_secs = 300, sync_writes = true)] fn pubkey() -> Key` | +| TTL in milliseconds (sub-second capable) | `#[once(ttl_millis = 500)] fn pubkey() -> Key` | | Optional global — skip caching if `None` (implicit) | `#[once] fn feature_flag() -> Option` | +| Recompute when an expression is true | `#[once(force_refresh = { flag })] fn config(flag: bool) -> Config` | +| Cache a method inside an `impl` block (one value shared across all instances) | `#[once(in_impl = true)] fn config(&self) -> Config` | | **`#[concurrent_cached]`** | | | Thread-safe sharded memoize (no global lock per call) | `#[concurrent_cached] fn compute(x: u64) -> u64` | | Sharded with LRU | `#[concurrent_cached(max_size = 1_000)] fn lookup(id: u64) -> Row` | -| Sharded with TTL | `#[concurrent_cached(ttl = 60)] fn fetch(url: String) -> Body` | -| Sharded LRU + TTL with custom shard count | `#[concurrent_cached(max_size = 1_000, ttl = 60, shards = 32)] fn query(id: u64) -> Row` | +| Sharded with TTL | `#[concurrent_cached(ttl_secs = 60)] fn fetch(url: String) -> Body` | +| Sharded LRU + TTL with custom shard count | `#[concurrent_cached(max_size = 1_000, ttl_secs = 60, shards = 32)] fn query(id: u64) -> Row` | +| TTL in milliseconds (sub-second; Redis honors millisecond TTL via PSETEX/PEXPIRE) | `#[concurrent_cached(ttl_millis = 500)] fn poll(id: u64) -> Status` | | Per-value expiry, thread-safe | `#[concurrent_cached(expires = true)] fn session(id: u32) -> Token` | | Per-value expiry with LRU bound | `#[concurrent_cached(expires = true, max_size = 1_000)] fn session(id: u32) -> Token` | | Cache only successful results (implicit for `Result`) | `#[concurrent_cached] fn load(id: u64) -> Result` | | Don't cache `None` returns (implicit for `Option`) | `#[concurrent_cached] fn find(id: u64) -> Option` | -| Serve stale value when function returns `Err` | `#[concurrent_cached(result_fallback = true, ttl = 60)] fn fetch(id: u64) -> Result` | -| Persist results to disk | `#[concurrent_cached(disk = true, map_error = \|e\| MyErr(e))] fn crunch(n: u64) -> Result` | -| Redis-backed async cache | `#[concurrent_cached(ty = "AsyncRedisCache", create = r#"{ ... }"#, map_error = \|e\| MyErr(e))] async fn api(id: u64) -> Result` | +| Serve stale value when function returns `Err` | `#[concurrent_cached(result_fallback = true, ttl_secs = 60)] fn fetch(id: u64) -> Result` | +| Recompute when an expression over the args is true | `#[concurrent_cached(force_refresh = { id == 0 })] fn fetch(id: u64) -> Data` | +| Force-refresh via a dedicated flag (exclude it from the key) | `#[concurrent_cached(key = "u64", convert = { id }, force_refresh = { refresh })] fn fetch(id: u64, refresh: bool) -> Data { let _ = refresh; … }` — the generated guard reads `refresh` to decide whether to bypass the cache; the body still receives it as a normal parameter, so add `let _ = refresh;` (or `#[allow(unused_variables)]`) if your body does not otherwise use it | +| Cache a method inside an `impl` block (one cache shared across all instances) | `#[concurrent_cached(in_impl = true)] fn load(&self, id: u64) -> Data` | +| Persist results to disk (with `map_error`; or omit when `E: From`) | `#[concurrent_cached(disk = true, map_error = \|e\| MyErr(e))] fn crunch(n: u64) -> Result` | +| Redis-backed async cache (quoted or unquoted `create`/`map_error`) | `#[concurrent_cached(ty = "AsyncRedisCache", create = { ... }, map_error = \|e\| MyErr(e))] async fn api(id: u64) -> Result` | On `#[cached]` and `#[concurrent_cached]`, the LRU bound is set with `max_size = N` (mirroring the `max_size` builder/constructor methods on the stores). The `size = N` spelling — a deprecated alias in 2.x — has been removed; only `max_size = N` is accepted. +The `ttl` attribute accepts a Duration expression as a quoted string: `ttl = "Duration::from_secs(60)"`. The expression is inlined verbatim, so `Duration` must be in scope at the call site (e.g. `use cached::time::Duration;`); the `ttl_secs` / `ttl_millis` forms need no import. For whole seconds, the shorter `ttl_secs = N` form is preferred. `ttl_millis = N` sets a TTL in milliseconds. The three attributes `ttl`, `ttl_secs`, and `ttl_millis` are mutually exclusive; using more than one is a compile error. All three are mutually exclusive with `expires`. Sub-second precision for `ttl_millis` is honored by the in-memory, disk (redb), and Redis stores; Redis applies the TTL with millisecond precision via PSETEX/PEXPIRE. + For the default in-memory sharded stores, `#[concurrent_cached]` accepts any return type — plain values, `Option`, or `Result`. Plain values are always cached as-is. `Option` returns skip caching `None` by default; use `cache_none = true` to also cache `None` values. `Result` only caches `Ok` values; `Err` is returned without being stored. Use `cache_err = true` to also cache `Err` values. The macro detects `Result` by matching the exact identifier `Result` (including fully-qualified paths such as `std::result::Result`). Type aliases are not resolved at macro-expansion time, so any alias — even one whose name ends with `Result` (e.g. `type MyResult = Result`) — is treated as a plain value and its `Err` variant is cached. Use `Result` directly when you need Ok-only caching behavior. The same applies to `Option` detection: a type alias such as `type MaybeRow = Option` is treated as a plain value and its `None` variant is cached. Use `Option` directly when you need `None`-skipping behavior. -On the default in-memory path, do **not** specify `map_error` — the sharded stores are infallible and supplying it is a compile error. -For `disk` and `redis` stores, `Result` is required and `map_error` must convert the store's error into your `E`. +On the default in-memory path, do not specify `map_error` -- the sharded stores are infallible and supplying it is a compile error. +For `disk` and `redis` stores, `Result` is required. `map_error` is optional: when supplied it converts the store error into your `E`; when omitted the generated code uses `.map_err(Into::into)?`, so `E` must implement `From` (disk) or `From` (Redis). Both quoted-string and unquoted forms are accepted: `map_error = |e| MyErr(e)` and `map_error = "|e| MyErr(e)"` are equivalent. **Store comparison** @@ -123,7 +172,7 @@ For `disk` and `redis` stores, `Result` is required and `map_error` must c | [`TtlSortedCache`](https://docs.rs/cached/latest/cached/struct.TtlSortedCache.html) | TTL (expiry-ordered) | Optional | Global | No | Yes | No | Yes | | [`ExpiringLruCache`](https://docs.rs/cached/latest/cached/struct.ExpiringLruCache.html) | LRU + value-defined | Yes | Per-value | N/A | Yes | No | Yes | | [`ExpiringCache`](https://docs.rs/cached/latest/cached/struct.ExpiringCache.html) | Value-defined | No | Per-value | N/A | Yes | No | Yes | -| [`ShardedCache`](https://docs.rs/cached/latest/cached/type.ShardedCache.html) | None (unbounded) | No | No | N/A | On explicit remove | Yes (`Arc`) | Yes | +| [`ShardedUnboundCache`](https://docs.rs/cached/latest/cached/type.ShardedUnboundCache.html) | None (unbounded) | No | No | N/A | On explicit remove | Yes (`Arc`) | Yes | | [`ShardedLruCache`](https://docs.rs/cached/latest/cached/type.ShardedLruCache.html) | LRU | Yes | No | N/A | Yes | Yes (`Arc`) | Yes | | [`ShardedTtlCache`](https://docs.rs/cached/latest/cached/type.ShardedTtlCache.html) | TTL (insert time) | No | Global | Optional | Yes | Yes (`Arc`) | Yes | | [`ShardedLruTtlCache`](https://docs.rs/cached/latest/cached/type.ShardedLruTtlCache.html) | LRU + TTL | Yes | Global | Optional | Yes (†) | Yes (`Arc`) | Yes | @@ -135,11 +184,11 @@ For `disk` and `redis` stores, `Result` is required and `map_error` must c `TtlCache`/`LruTtlCache`/`TtlSortedCache`/`ShardedTtlCache`/`ShardedLruTtlCache` require the `time_stores` feature. -`ShardedCache` and its variants are partitioned across power-of-two shards (default: `available_parallelism() × 4`, clamped to 8–1024; the 8–1024 clamp applies only to this computed default — an explicit `shards = N` is rounded up to a power of two but never clamped) each protected by a `parking_lot::RwLock`. Shard structs are padded to 128-byte alignment (covering Intel adjacent-line prefetch and Apple Silicon 128-byte L1 lines) to eliminate false sharing; on a 64-shard deployment this amounts to ~8 KB of padding overhead per cache array. The outer type is an `Arc` — cloning is a reference share, not a deep copy (use `deep_clone()` for an independent copy; note that `deep_clone()` is an inherent method on each concrete sharded type, not part of any trait). They implement `ConcurrentCached`/`ConcurrentCachedAsync` and are the default store selected by `#[concurrent_cached]`. +`ShardedUnboundCache` and its variants are partitioned across power-of-two shards (default: `available_parallelism() × 4`, clamped to 8–1024; the 8–1024 clamp applies only to this computed default — an explicit `shards = N` is rounded up to a power of two but never clamped) each protected by a `parking_lot::RwLock`. Shard structs are padded to 128-byte alignment (covering Intel adjacent-line prefetch and Apple Silicon 128-byte L1 lines) to eliminate false sharing; on a 64-shard deployment this amounts to ~8 KB of padding overhead per cache array. The outer type is an `Arc` — cloning is a reference share, not a deep copy (use `deep_clone()` for an independent copy; note that `deep_clone()` is an inherent method on each concrete sharded type, not part of any trait). They implement `ConcurrentCached`/`ConcurrentCachedAsync` and are the default store selected by `#[concurrent_cached]`. For sharded LRU variants, eviction is enforced independently per shard. `max_size = N` is divided across shards with ceiling division. Use the builder's `per_shard_max_size` method for an exact per-shard cap (builder-only; `#[concurrent_cached]` does not expose a `per_shard_max_size` attribute — use `shards` to control parallelism and `max_size` for total capacity). **Capacity Fragmentation Warning**: To protect against premature evictions due to hash collisions in extremely small caches (where a shard capacity could drop to 1-2 entries), when sharding is active (`shards > 1`) we enforce a minimum capacity of `16` entries **per shard** (e.g., minimum total capacity of `128` on a single-core machine with 8 shards, or `256` on a 4-core machine with 16 shards). If you require smaller, strict limits under low capacities, configure `shards = 1` or specify `per_shard_max_size` directly (builder-only; not available via `#[concurrent_cached]`). -Because LRU caches require updating access recency, `ShardedLruCache`, `ShardedLruTtlCache`, and `ShardedExpiringLruCache` must acquire an exclusive **write lock** on accessed shards during read hits, which can lead to contention under highly concurrent read-heavy workloads. Unbounded `ShardedCache`, time-only `ShardedTtlCache` (when `refresh_on_hit` is disabled — enabling it promotes read hits to exclusive write locks), and expiring `ShardedExpiringCache` require only a **shared read lock** on read hits, avoiding this contention. To mitigate contention on LRU variants, consider increasing the number of `shards` to distribute writes. +Because LRU caches require updating access recency, `ShardedLruCache`, `ShardedLruTtlCache`, and `ShardedExpiringLruCache` must acquire an exclusive **write lock** on accessed shards during read hits, which can lead to contention under highly concurrent read-heavy workloads. Unbounded `ShardedUnboundCache`, time-only `ShardedTtlCache` (when `refresh_on_hit` is disabled -- enabling it promotes read hits to exclusive write locks), and expiring `ShardedExpiringCache` require only a **shared read lock** on read hits, avoiding this contention. To mitigate contention on LRU variants, consider increasing the number of `shards` to distribute writes. Note: this write-lock-on-read behavior is a known limitation of the strict-LRU sharded stores. A future read-optimized variant that relaxes strict recency ordering will ship as a separate store type; the existing stores will not change semantics. -> **`*Base` types:** Each sharded store has a corresponding `*Base` generic (`ShardedCacheBase`, `ShardedLruCacheBase`, etc.) parameterized on a custom [`ShardHasher`]. The named aliases (`ShardedCache`, `ShardedLruCache`, …) use the default hasher and are what most users should reach for. Use the `*Base` types only when implementing a custom `ShardHasher` for non-standard shard routing. +> **`*Base` types:** Each sharded store has a corresponding `*Base` generic (`ShardedUnboundCacheBase`, `ShardedLruCacheBase`, etc.) parameterized on a custom [`ShardHasher`]. The named aliases (`ShardedUnboundCache`, `ShardedLruCache`, …) use the default hasher and are what most users should reach for. Use the `*Base` types only when implementing a custom `ShardHasher` for non-standard shard routing. Construct a custom-hasher cache through the alias builder and its `hasher` method: `ShardedLruCache::builder().hasher(my_hasher)` switches the builder's hasher type and `build` yields a `*Base` over `my_hasher`. `new`/`builder` are defined only on the default-hasher alias, so a custom hasher is always introduced through `hasher`, never a `*Base::<_, _, H>` turbofish (which would otherwise silently drop the hasher). **Behavioral guarantees** @@ -148,17 +197,28 @@ Because LRU caches require updating access recency, `ShardedLruCache`, `ShardedL managing these stores directly must add their own synchronization when sharing across threads. `Sharded*` stores are internally synchronized (per-shard `parking_lot::RwLock`) and implement `ConcurrentCached`/`ConcurrentCachedAsync` — no external lock is needed. - The synchronous `cache_get` / `cache_set` / `cache_remove` operations come from the - `ConcurrentCached` trait (it must be in scope — `use cached::ConcurrentCached;` or - `use cached::prelude::*;`), not from inherent methods. The async trait operations are - `async_`-prefixed, so they never collide (e.g., `STORE.async_cache_get(&key).await.expect("ShardedCache is infallible")`). -- `Cached::get` (and its legacy alias `cache_get`) requires mutable access because some - stores update recency, expiration timestamps, or metrics during reads. + The synchronous `get` / `set` / `remove` short aliases come from the `ConcurrentCachedExt` + extension trait (bring it into scope with `use cached::prelude::*;` or + `use cached::{ConcurrentCached, ConcurrentCachedExt};`); the `cache_get` / `cache_set` / + `cache_remove` spellings come from `ConcurrentCached` directly. For sharded stores, inherent + methods with the same names take priority at the call site. The async trait operations are + `async_`-prefixed, so they never collide (e.g., `STORE.async_cache_get(&key).await.expect("ShardedUnboundCache is infallible")`). +- `CachedExt::get` (and the `Cached::cache_get` required method it wraps) requires mutable access + because some stores update recency, expiration timestamps, or metrics during reads. +- **`len` / `size` vs `iter` vs `evict` contract for timed and expiring stores:** + `len()` (and `cache_size()`, `is_empty()`) return the raw stored entry count without + scanning for expiry. On lazy-eviction stores (`TtlCache`, `LruTtlCache`, + `TtlSortedCache`, `ExpiringCache`, `ExpiringLruCache`, and their sharded equivalents) + this count may include entries that have expired but not yet been swept, so + `len()` can be greater than `iter().count()`. `iter()` (from [`CachedIter`]) omits + expired entries from the yielded view but does not remove them from the store - it + stays `&self`. Call `evict()` (via [`CacheEvict`] for single-owner stores or + [`ConcurrentCacheEvict`] for sharded stores) to physically remove expired entries, + reclaim memory, and obtain an accurate live count. - Expired values can remain allocated until a mutating operation, `evict`, or - store-specific cleanup removes them. Methods such as `len` may include expired values - unless a store documents otherwise. + store-specific cleanup removes them. - `cache_remove` fires the `on_evict` callback (if set) and counts as an eviction for - every successful removal, across all stores that track evictions. `ShardedCache` is the + every successful removal, across all stores that track evictions. `ShardedUnboundCache` is the exception: it has no evictions counter and always returns `None` from `metrics().evictions`, though its `on_evict` callback still fires. The `on_evict` column above marks the unbounded stores where explicit removal is the *only* eviction trigger. For stores with @@ -191,7 +251,9 @@ Because LRU caches require updating access recency, `ShardedLruCache`, `ShardedL `CachedIter` or uses `.iter()` / `cache_peek` must use non-sharded stores instead. The four expiry-capable sharded stores ([`ShardedTtlCache`], [`ShardedLruTtlCache`], [`ShardedExpiringCache`], [`ShardedExpiringLruCache`]) implement [`ConcurrentCloneCached`], - which provides `cache_get_with_expiry_status` for reading stale entries without evicting them. + which provides `cache_get_with_expiry_status` for reading stale entries without evicting them, and + `cache_peek_with_expiry_status` as a side-effect-free counterpart (the built-in sharded stores + override the default, which delegates to the renewing read). **Per-Value Expiry via the `Expires` Trait** @@ -203,9 +265,9 @@ It is also the idiomatic way to give entries a **dynamic, per-entry TTL** — a When using the `#[cached]` or `#[once]` proc macros, add `expires = true` to opt into per-value expiry automatically. For `#[cached]`, this selects `ExpiringCache` (unbounded) by default or `ExpiringLruCache` when `max_size` is also specified. For `#[once]`, this stores a single value whose expiry is polled on each call. -The macro form below derives each entry's TTL from a function argument — `key`/`convert` keep the TTL out of the cache key so it influences only the entry's lifetime, not which slot it occupies: +The macro form below derives each entry's TTL from a function argument — `key`/`convert` keep the TTL out of the cache key so it influences only the entry's lifetime, not which slot it occupies (`ignore`d as a doctest because it requires the default `proc_macro` feature; the same code runs in the [`expires_per_key`](https://github.com/jaemk/cached/blob/master/examples/expires_per_key.rs) example): -```rust +```rust,ignore use cached::macros::cached; use cached::Expires; use cached::time::{Duration, Instant}; @@ -242,7 +304,7 @@ For concurrent (multi-thread, no external lock) use, the sharded equivalents [`S > `max_size` bound. ```rust -use cached::{Cached, Expires, ExpiringCache, ExpiringLruCache}; +use cached::{CachedExt, Expires, ExpiringCache, ExpiringLruCache}; use cached::time::{Duration, Instant}; #[derive(Clone)] @@ -261,18 +323,18 @@ let now = Instant::now(); // ExpiringCache — unbounded, default for `#[cached(expires = true)]` let mut cache = ExpiringCache::builder().build().unwrap(); -cache.cache_set("key1", Response { +cache.set("key1", Response { payload: "a".to_string(), expires_at: now + Duration::from_secs(1), }); -cache.cache_set("key2", Response { +cache.set("key2", Response { payload: "b".to_string(), expires_at: now + Duration::from_secs(3600), }); // ExpiringLruCache — LRU-bounded, used with `#[cached(expires = true, max_size = N)]` let mut lru = ExpiringLruCache::builder().max_size(10).build().unwrap(); -lru.cache_set("key1", Response { +lru.set("key1", Response { payload: "a".to_string(), expires_at: now + Duration::from_secs(1), }); @@ -325,12 +387,12 @@ use cached::macros::once; /// Only cache the initial function call. /// Function will be re-executed after the cache -/// expires (according to `ttl` seconds). +/// expires (according to `ttl_secs`). /// When no (or expired) cache, concurrent calls /// will synchronize (`sync_writes`) so the function /// is only executed once. # #[cfg(feature = "time_stores")] -#[once(ttl =10, sync_writes = true)] +#[once(ttl_secs=10, sync_writes = true)] fn keyed(a: String) -> Option { if a == "a" { Some(a.len()) @@ -348,7 +410,7 @@ use cached::macros::cached; /// Cannot use sync_writes and result_fallback together #[cached( - ttl = 1, + ttl_secs = 1, sync_writes = "default", result_fallback = true )] @@ -358,6 +420,18 @@ fn doesnt_compile() -> Result { ``` ---- +`cache_get_or_set_with` returns a shared reference (`&V`); binding it as `&mut V` +no longer compiles. Use [`cache_get_or_set_with_mut`](crate::Cached::cache_get_or_set_with_mut) +when you need a mutable reference. + +```compile_fail +use cached::{Cached, UnboundCache}; + +let mut cache: UnboundCache = UnboundCache::builder().build().unwrap(); +let _: &mut u32 = cache.cache_get_or_set_with(1, || 2); +``` +---- + ```rust,no_run,ignore use cached::macros::concurrent_cached; use cached::AsyncRedisCache; @@ -370,15 +444,19 @@ enum ExampleError { RedisError(String), } -/// Cache the results of an async function in redis. Cache -/// keys will be prefixed with `cache_redis_prefix`. +/// Cache the results of an async function in redis. Redis keys are laid out as +/// `{namespace}:{prefix}:{key}`, where `namespace` defaults to `cached-redis-store:` +/// and `prefix` is required (here `cached_redis_prefix`). The prefix is what scopes +/// `cache_clear` to this logical cache, so give each cache a distinct prefix. /// Redis and disk stores require `Result`; supply a `map_error` closure /// to convert store errors into your error type. #[concurrent_cached( map_error = r##"|e| ExampleError::RedisError(format!("{:?}", e))"##, ty = "AsyncRedisCache", create = r##" { - AsyncRedisCache::builder("cached_redis_prefix", Duration::from_secs(1)) + AsyncRedisCache::builder() + .prefix("cached_redis_prefix") + .ttl(Duration::from_secs(1)) .refresh_on_hit(true) .build() .await @@ -431,7 +509,7 @@ use cached::macros::concurrent_cached; /// `#[concurrent_cached]` does **not** support `sync_writes`. /// For `Option` returns, `None` is skipped by default (use `cache_none = true` to cache it). /// For `Result` returns, only `Ok` values are cached by default (use `cache_err = true` -/// to also cache `Err`). `result_fallback = true` is supported (requires `ttl`): on an `Err` +/// to also cache `Err`). `result_fallback = true` is supported (requires `ttl_secs`, `ttl_millis`, or `ttl = ""`): on an `Err` /// return, the last cached `Ok` value for the same key is returned instead. The stale value /// is held in the primary cache slot and re-cached with a fresh TTL window on `Err`; no /// secondary store is created. @@ -485,12 +563,35 @@ Due to the requirements of storing arguments and return values in a global cache - For I/O-backed stores used by `#[concurrent_cached]` (Redis and disk), must either be owned and implement `Display + Clone`, or a `convert` expression must be used to produce a key of a `Display + Clone` type. `Clone` is needed so removal APIs can return the stored key. + - Floats (`f32` / `f64`), and any type containing them (e.g. a struct with float fields), do not + implement `Hash` / `Eq`, so they are the canonical case that requires a `convert` expression to + produce a hashable key. For example `key = "String", convert = r#"{ format!("{:.6}", x) }"#`, or + wrap the value with a crate such as `ordered-float`. - Arguments and return values will be `cloned` in the process of insertion and retrieval. For Redis and disk stores, keys are additionally formatted into `String`s and values are de/serialized. - Macro-defined functions should not be used to produce side-effectual results! -- Macro-defined functions cannot live directly under `impl` blocks since macros expand to a - static initialization and one or more function definitions. -- Macro-defined functions cannot accept `Self` types as a parameter. +- Macro-defined functions live at module scope by default (the macro expands to a static plus + one or more functions). To cache a method inside an `impl` block, set `in_impl = true`, which + emits the cache static inside the generated method body instead. A `{fn}_no_cache` sibling + method is generated at the same visibility, calling the original body directly and bypassing + the cache. The `_prime_cache` companion is not generated for `in_impl` methods (a + function-local static cannot be shared between two sibling methods, so priming would silently + do nothing; calling a non-existent prime function is a clear compile error instead). +- Macro-defined methods may take a `self` receiver only when `in_impl = true`; `self` is excluded + from the default cache key. Otherwise `self`-receiver methods are rejected with a compile error + (a `convert` block alone does not make them valid: off the `in_impl` path the cache static is + emitted at `impl` scope, where a `static` is not a legal item). + **Footgun:** because `self` is excluded, two instances with different internal state but identical + arguments share one cache entry, so `a.load(5)` and `b.load(5)` return the same cached value even + when `a` and `b` differ. The cache is process-global, not per-instance. If a method's result + depends on `self`'s fields, fold them into the key with a `convert` expression (e.g. + `convert = r#"{ format!("{}:{}", self.id, id) }"#`), or keep the logic in a free function keyed on + those fields. +- Macro-defined functions can be generic over type parameters only when a `key` + `convert` is + supplied to produce a concrete key type. On the default-key path (no `convert`), `#[cached]` / + `#[concurrent_cached]` reject generic functions, since each monomorphization would need its own + static cache: write a concrete monomorphic wrapper per type instead. (`#[once]` caches a single + concrete value and is unaffected.) */ @@ -505,22 +606,43 @@ pub use macros::{Return, cached, concurrent_cached, once}; #[cfg(feature = "async_core")] #[cfg_attr(docsrs, doc(cfg(feature = "async_core")))] use std::future::Future; -#[cfg(any(feature = "redis_smol", feature = "redis_tokio"))] -#[cfg_attr(docsrs, doc(cfg(any(feature = "redis_smol", feature = "redis_tokio"))))] +#[cfg(any( + feature = "redis_smol", + feature = "redis_smol_native_tls", + feature = "redis_smol_rustls", + feature = "redis_tokio", + feature = "redis_tokio_native_tls", + feature = "redis_tokio_rustls", + feature = "redis_async_cache", + feature = "redis_connection_manager" +))] +#[cfg_attr( + docsrs, + doc(cfg(any( + feature = "redis_smol", + feature = "redis_smol_native_tls", + feature = "redis_smol_rustls", + feature = "redis_tokio", + feature = "redis_tokio_native_tls", + feature = "redis_tokio_rustls", + feature = "redis_async_cache", + feature = "redis_connection_manager" + ))) +)] pub use stores::{AsyncRedisCache, AsyncRedisCacheBuilder}; pub use stores::{ - BuildError, CacheEvict, ConcurrentCacheEvict, DefaultShardHasher, Expires, ExpiringCache, - ExpiringCacheBuilder, ExpiringLruCache, ExpiringLruCacheBuilder, LruCache, LruCacheBuilder, - ShardHasher, ShardedCache, ShardedCacheBase, ShardedCacheBuilder, ShardedExpiringCache, - ShardedExpiringCacheBase, ShardedExpiringCacheBuilder, ShardedExpiringLruCache, - ShardedExpiringLruCacheBase, ShardedExpiringLruCacheBuilder, ShardedLruCache, - ShardedLruCacheBase, ShardedLruCacheBuilder, UnboundCache, UnboundCacheBuilder, + BuildError, CacheEvict, CacheSetError, ConcurrentCacheEvict, DefaultHashBuilder, + DefaultShardHasher, Expires, ExpiringCache, ExpiringCacheBuilder, ExpiringLruCache, + ExpiringLruCacheBuilder, LruCache, LruCacheBuilder, SetMaxSizeError, SetTtlError, ShardHasher, + ShardedExpiringCache, ShardedExpiringCacheBase, ShardedExpiringCacheBuilder, + ShardedExpiringLruCache, ShardedExpiringLruCacheBase, ShardedExpiringLruCacheBuilder, + ShardedLruCache, ShardedLruCacheBase, ShardedLruCacheBuilder, ShardedUnboundCache, + ShardedUnboundCacheBase, ShardedUnboundCacheBuilder, UnboundCache, UnboundCacheBuilder, }; -#[cfg(feature = "disk_store")] -#[cfg_attr(docsrs, doc(cfg(feature = "disk_store")))] +#[cfg(feature = "redis_store")] +#[cfg_attr(docsrs, doc(cfg(feature = "redis_store")))] pub use stores::{ - DiskCache, DiskCacheBuildError, DiskCacheBuilder, DiskCacheError, RedbCache, - RedbCacheBuildError, RedbCacheBuilder, RedbCacheError, + ConnectionString, RedisCache, RedisCacheBuildError, RedisCacheBuilder, RedisCacheError, }; #[cfg(feature = "time_stores")] #[doc(hidden)] @@ -530,11 +652,11 @@ pub use stores::{HasEvict, NoEvict}; pub use stores::{ LruTtlCache, LruTtlCacheBuilder, ShardedLruTtlCache, ShardedLruTtlCacheBase, ShardedLruTtlCacheBuilder, ShardedTtlCache, ShardedTtlCacheBase, ShardedTtlCacheBuilder, - TtlCache, TtlCacheBuilder, TtlSortedCache, TtlSortedCacheBuilder, TtlSortedCacheError, + TtlCache, TtlCacheBuilder, TtlSortedCache, TtlSortedCacheBuilder, }; -#[cfg(feature = "redis_store")] -#[cfg_attr(docsrs, doc(cfg(feature = "redis_store")))] -pub use stores::{RedisCache, RedisCacheBuildError, RedisCacheBuilder, RedisCacheError}; +#[cfg(feature = "redb_store")] +#[cfg_attr(docsrs, doc(cfg(feature = "redb_store")))] +pub use stores::{RedbCache, RedbCacheBuildError, RedbCacheBuilder, RedbCacheError}; mod lru_list; #[cfg(feature = "proc_macro")] @@ -544,15 +666,13 @@ pub mod stores; /// Re-export of the [`web_time`](https://docs.rs/web_time) crate, /// which provides time types compatible with both native and WebAssembly targets. pub use web_time as time; -#[doc(hidden)] -pub use web_time; #[cfg(feature = "async")] #[doc(hidden)] pub mod async_sync { - pub use tokio::sync::Mutex; - pub use tokio::sync::OnceCell; - pub use tokio::sync::RwLock; + pub use async_lock::Mutex; + pub use async_lock::OnceCell; + pub use async_lock::RwLock; } #[doc(hidden)] @@ -569,11 +689,12 @@ pub mod sync_sync { /// `cache_set`, …) are trait methods and require the trait to be in scope to call. /// /// Only traits are re-exported here; concrete store types are intentionally omitted to -/// avoid name clashes. Import those directly (e.g. `use cached::ShardedCache;`). +/// avoid name clashes. Import those directly (e.g. `use cached::ShardedUnboundCache;`). pub mod prelude { pub use crate::{ - CacheEvict, Cached, CachedIter, CachedPeek, CachedRead, CloneCached, ConcurrentCacheEvict, - ConcurrentCached, ConcurrentCloneCached, Expires, + CacheEvict, Cached, CachedExt, CachedIter, CachedPeek, CachedRead, CloneCached, + ConcurrentCacheBase, ConcurrentCacheEvict, ConcurrentCacheTtl, ConcurrentCached, + ConcurrentCachedExt, ConcurrentCloneCached, Expires, SerializeCached, }; #[cfg(feature = "time_stores")] @@ -582,22 +703,52 @@ pub mod prelude { #[cfg(feature = "async_core")] #[cfg_attr(docsrs, doc(cfg(feature = "async_core")))] - pub use crate::{CachedAsync, ConcurrentCachedAsync}; + pub use crate::{CachedAsync, ConcurrentCachedAsync, SerializeCachedAsync}; } -/// Cache operations +/// Core cache operations for single-owner (non-concurrent) stores. +/// +/// The `cache_`-prefixed methods are the required core: stores implement only these. +/// Short aliases (`get`/`set`/`remove`/`clear`/`len`/...) are provided by the blanket +/// extension trait [`CachedExt`], which is automatically implemented for any type that +/// implements `Cached`. Bring the short names into scope with +/// `use cached::prelude::*;` or `use cached::CachedExt;`. +/// +/// The async traits (`CachedAsync`, `ConcurrentCachedAsync`) use the `async_cache_*` spelling +/// (`async_cache_get`, `async_cache_set`, `async_cache_remove`, ...) so they never collide +/// with these synchronous methods. The async traits have no short aliases. +/// +/// Via `CachedExt` (short names): /// /// ```rust -/// use cached::{Cached, UnboundCache}; +/// use cached::{CachedExt, UnboundCache}; /// /// let mut cache: UnboundCache = UnboundCache::builder().build().unwrap(); -/// /// cache.set("key".to_string(), "owned value".to_string()); +/// let v = cache.get("key"); +/// assert_eq!(v, Some(&"owned value".to_string())); +/// ``` +/// +/// Via `Cached` directly (`cache_*` prefix, use when only `Cached` is in scope): +/// +/// ```rust +/// use cached::{Cached, UnboundCache}; /// -/// let borrowed_cache_value = cache.get("key"); -/// assert_eq!(borrowed_cache_value, Some(&"owned value".to_string())) +/// let mut cache: UnboundCache = UnboundCache::builder().build().unwrap(); +/// cache.cache_set("key".to_string(), "owned value".to_string()); +/// let v = cache.cache_get("key"); +/// assert_eq!(v, Some(&"owned value".to_string())); /// ``` pub trait Cached { + // ── Associated types ────────────────────────────────────────────────── + + /// The error type returned by [`cache_try_set`](Cached::cache_try_set). + /// + /// Use [`std::convert::Infallible`] for stores where insertion can never fail. + /// TTL-capable stores that may overflow `Instant` bounds use + /// [`CacheSetError`](crate::stores::CacheSetError). + type Error; + // ── Core required methods (stores implement these) ──────────────────── /// Attempt to retrieve a cached value. @@ -606,7 +757,7 @@ pub trait Cached { /// or metrics during reads. /// /// ```rust - /// # use cached::{Cached, UnboundCache}; + /// # use cached::{CachedExt, UnboundCache}; /// # let mut cache: UnboundCache = UnboundCache::builder().build().unwrap(); /// # cache.set("key".to_string(), "owned value".to_string()); /// let v1 = cache.get("key").map(String::clone); @@ -629,21 +780,56 @@ pub trait Cached { /// Fallible variant of [`Self::cache_set`]. Returns `Err` if the store cannot accept the entry /// (e.g. the TTL duration overflows `Instant` bounds). The default implementation is - /// infallible and delegates to [`Self::cache_set`]. - fn cache_try_set(&mut self, k: K, v: V) -> Result, Box> { + /// infallible and delegates to [`Self::cache_set`]; it always returns `Ok`. + /// + /// The error type is the associated [`Self::Error`]. Infallible stores set + /// `type Error = std::convert::Infallible`, while TTL-capable stores set it to + /// [`CacheSetError`](crate::stores::CacheSetError). + fn cache_try_set(&mut self, k: K, v: V) -> Result, Self::Error> { Ok(self.cache_set(k, v)) } - /// Get or insert a key-value pair. - fn cache_get_or_set_with V>(&mut self, key: K, f: F) -> &mut V; + /// Get or insert a key-value pair, returning a mutable reference to the value. + /// + /// This is the mutable counterpart of [`cache_get_or_set_with`](Cached::cache_get_or_set_with). + /// Stores implement this method; the shared-reference variant delegates to it. + fn cache_get_or_set_with_mut V>(&mut self, key: K, f: F) -> &mut V; - /// Get or insert a key-value pair, propagating errors from the factory. - fn cache_try_get_or_set_with Result, E>( + /// Get or insert a key-value pair, propagating errors from the factory and + /// returning a mutable reference to the value. + /// + /// This is the mutable counterpart of + /// [`cache_try_get_or_set_with`](Cached::cache_try_get_or_set_with). + fn cache_try_get_or_set_with_mut Result, E>( &mut self, key: K, f: F, ) -> Result<&mut V, E>; + /// Get or insert a key-value pair, returning a shared reference to the value. + /// + /// Returns `&V`. Use [`cache_get_or_set_with_mut`](Cached::cache_get_or_set_with_mut) + /// when you need a mutable reference to the cached value. This is a provided default + /// (it delegates to `cache_get_or_set_with_mut`); external stores implement `_mut`, not this. + fn cache_get_or_set_with V>(&mut self, key: K, f: F) -> &V { + &*self.cache_get_or_set_with_mut(key, f) + } + + /// Get or insert a key-value pair, propagating errors from the factory and + /// returning a shared reference to the value. + /// + /// Returns `Result<&V, E>`. Use + /// [`cache_try_get_or_set_with_mut`](Cached::cache_try_get_or_set_with_mut) + /// when you need a mutable reference to the cached value. This is a provided default + /// (it delegates to `cache_try_get_or_set_with_mut`); external stores implement `_mut`, not this. + fn cache_try_get_or_set_with Result, E>( + &mut self, + key: K, + f: F, + ) -> Result<&V, E> { + self.cache_try_get_or_set_with_mut(key, f).map(|v| &*v) + } + /// Remove a cached value, returning it if it was both present and still live. /// /// Removing any present entry fires the store's `on_evict` callback (if set) and, @@ -658,7 +844,7 @@ pub trait Cached { /// the stored key back (relevant when `K`'s `Eq` ignores some fields). /// /// ```rust - /// # use cached::{Cached, UnboundCache}; + /// # use cached::{CachedExt, UnboundCache}; /// # let mut cache: UnboundCache = UnboundCache::builder().build().unwrap(); /// # cache.set("k1".to_string(), "v1".to_string()); /// # cache.set("k2".to_string(), "v2".to_string()); @@ -667,6 +853,7 @@ pub trait Cached { /// # assert_eq!(r1, Some("v1".to_string())); /// # assert_eq!(r2, Some("v2".to_string())); /// ``` + #[must_use] fn cache_remove(&mut self, k: &Q) -> Option where K: std::borrow::Borrow, @@ -700,6 +887,7 @@ pub trait Cached { /// // Returns None only when the key was never present. /// assert_eq!(cache.cache_remove_entry("missing"), None); /// ``` + #[must_use] fn cache_remove_entry(&mut self, k: &Q) -> Option<(K, V)> where K: std::borrow::Borrow, @@ -717,8 +905,13 @@ pub trait Cached { /// Return the number of entries currently in the cache. /// - /// For stores with TTL-based expiry, this count may include entries that have expired - /// but not yet been evicted (lazy eviction). + /// This is a cheap read of the stored entry count: no expiry scan is performed. + /// On lazy-eviction stores (`TtlCache`, `LruTtlCache`, `TtlSortedCache`, + /// `ExpiringCache`, `ExpiringLruCache`, and their sharded equivalents), the count + /// may include entries that have expired but not yet been swept. As a result, + /// `cache_size()` (and the `len()` alias) can exceed `iter().count()`. + /// Call `evict()` first if you need an accurate count of live entries. + #[must_use] fn cache_size(&self) -> usize; // ── Optional overrides ──────────────────────────────────────────────── @@ -727,22 +920,77 @@ pub trait Cached { fn cache_reset_metrics(&mut self) {} /// Return the number of times a cached value was successfully retrieved. + #[must_use] fn cache_hits(&self) -> Option { None } /// Return the number of times a cached value was not found. + #[must_use] fn cache_misses(&self) -> Option { None } /// Return the cache capacity, if bounded. + #[must_use] fn cache_capacity(&self) -> Option { None } - // ── Ergonomic aliases (new preferred API) ──────────────────────────── + /// Delete a cached entry without returning it. Returns `true` if an entry was + /// physically deleted (including expired entries), `false` if the key was absent. + /// + /// Unlike [`cache_remove`](Cached::cache_remove), this returns `true` even when the + /// deleted entry was already expired. + fn cache_delete(&mut self, k: &Q) -> bool + where + K: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized, + { + self.cache_remove_entry(k).is_some() + } + + /// Return the number of times a value was evicted from the cache. + #[must_use] + fn cache_evictions(&self) -> Option { + None + } +} +/// Short-alias extension for [`Cached`] stores. +/// +/// Every type that implements `Cached` automatically implements this trait through a blanket +/// impl. The methods here are ergonomic short names that delegate to the `cache_`-prefixed core +/// methods on `Cached`. Import this trait (or `use cached::prelude::*;`) to bring the short names +/// into scope: +/// +/// ```rust +/// use cached::{CachedExt, UnboundCache}; +/// +/// let mut cache: UnboundCache = UnboundCache::builder().build().unwrap(); +/// cache.set("key".to_string(), 42); +/// assert_eq!(cache.get("key"), Some(&42u32)); +/// assert!(cache.delete("key")); +/// assert!(!cache.delete("key")); +/// ``` +/// +/// When you need a short name that would collide with another trait's method, or when +/// only `Cached` is in scope, use the `cache_`-prefixed form: +/// +/// ```rust +/// use cached::{Cached, UnboundCache}; +/// +/// let mut cache: UnboundCache = UnboundCache::builder().build().unwrap(); +/// cache.cache_set("key".to_string(), 42); +/// assert_eq!(cache.cache_get("key"), Some(&42u32)); +/// ``` +/// +/// Custom store implementations do **not** implement this trait directly. Only implement the +/// core `Cached` trait; this extension is provided automatically via the blanket impl. +/// +/// Note: `shards()` and `shard_sizes()` on the sharded stores are inherent-only - they are +/// sharding-specific introspection not part of the generic cache trait surface. +pub trait CachedExt: Cached { /// Retrieve a cached value. Delegates to [`cache_get`](Cached::cache_get). /// /// # Mutability @@ -752,6 +1000,110 @@ pub trait Cached { /// or refresh TTL on read. For a `&self` read where the store supports it, use /// [`CachedPeek::cache_peek`] (non-mutating, no recency/TTL/metrics updates) or /// [`CachedRead::cache_get_read`] (shared-lock read preserving normal read semantics). + fn get(&mut self, k: &Q) -> Option<&V> + where + K: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized; + + /// Retrieve a cached value with mutable access. Delegates to [`cache_get_mut`](Cached::cache_get_mut). + fn get_mut(&mut self, k: &Q) -> Option<&mut V> + where + K: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized; + + /// Insert a key-value pair and return the previous value. Delegates to [`cache_set`](Cached::cache_set). + fn set(&mut self, k: K, v: V) -> Option; + + /// Fallible insert. Delegates to [`cache_try_set`](Cached::cache_try_set). + fn try_set(&mut self, k: K, v: V) -> Result, Self::Error>; + + /// Get or insert a key-value pair. Delegates to [`cache_get_or_set_with`](Cached::cache_get_or_set_with). + fn get_or_set_with V>(&mut self, key: K, f: F) -> &V; + + /// Get or insert a key-value pair, returning a mutable reference. Delegates to + /// [`cache_get_or_set_with_mut`](Cached::cache_get_or_set_with_mut). + fn get_or_set_with_mut V>(&mut self, key: K, f: F) -> &mut V; + + /// Get or insert a key-value pair with error handling. Delegates to + /// [`cache_try_get_or_set_with`](Cached::cache_try_get_or_set_with). + fn try_get_or_set_with Result, E>(&mut self, k: K, f: F) -> Result<&V, E>; + + /// Get or insert a key-value pair with error handling, returning a mutable reference. + /// Delegates to [`cache_try_get_or_set_with_mut`](Cached::cache_try_get_or_set_with_mut). + fn try_get_or_set_with_mut Result, E>( + &mut self, + k: K, + f: F, + ) -> Result<&mut V, E>; + + /// Remove a cached value. Delegates to [`cache_remove`](Cached::cache_remove). + fn remove(&mut self, k: &Q) -> Option + where + K: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized; + + /// Remove a cached entry, returning the stored key and value. Delegates to + /// [`cache_remove_entry`](Cached::cache_remove_entry). + fn remove_entry(&mut self, k: &Q) -> Option<(K, V)> + where + K: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized; + + /// Delete a cached entry without returning it. Returns `true` if an entry was + /// physically deleted (including expired entries). Delegates to + /// [`cache_delete`](Cached::cache_delete). + fn delete(&mut self, k: &Q) -> bool + where + K: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized; + + /// Return `true` if the cache contains a value for the given key. + /// + /// Requires `&mut self` because some stores update recency, expiration + /// timestamps, or metrics during reads. For a non-mutating presence check + /// use [`CachedPeek::cache_peek`] if the store implements it. + fn contains(&mut self, k: &Q) -> bool + where + K: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized; + + /// Remove all entries, keeping allocated memory for reuse. Delegates to + /// [`cache_clear`](Cached::cache_clear). + fn clear(&mut self); + + /// Return the number of entries currently in the cache. Delegates to + /// [`cache_size`](Cached::cache_size). + /// + /// On lazy-eviction stores this count may include expired-but-not-yet-swept entries. + /// Use `evict()` (via [`CacheEvict`](crate::CacheEvict)) before calling `len()` if + /// you need an accurate count of live entries. + #[must_use] + fn len(&self) -> usize; + + /// Return `true` if the cache contains no entries. Delegates to + /// [`cache_size`](Cached::cache_size). + /// + /// On lazy-eviction stores an expired-but-not-yet-swept entry causes this to return + /// `false` even when no live entries remain. + #[must_use] + fn is_empty(&self) -> bool; + + /// Return the number of cache hits, if tracked. Delegates to + /// [`cache_hits`](Cached::cache_hits). + #[must_use] + fn hits(&self) -> Option; + + /// Return the number of cache misses, if tracked. Delegates to + /// [`cache_misses`](Cached::cache_misses). + #[must_use] + fn misses(&self) -> Option; + + /// Return a snapshot of cache metrics. + #[must_use] + fn metrics(&self) -> CacheMetrics; +} + +impl> CachedExt for T { fn get(&mut self, k: &Q) -> Option<&V> where K: std::borrow::Borrow, @@ -760,7 +1112,6 @@ pub trait Cached { self.cache_get(k) } - /// Retrieve a cached value with mutable access. Delegates to [`cache_get_mut`](Cached::cache_get_mut). fn get_mut(&mut self, k: &Q) -> Option<&mut V> where K: std::borrow::Borrow, @@ -769,31 +1120,34 @@ pub trait Cached { self.cache_get_mut(k) } - /// Insert a key-value pair and return the previous value. Delegates to [`cache_set`](Cached::cache_set). fn set(&mut self, k: K, v: V) -> Option { self.cache_set(k, v) } - /// Fallible insert. Delegates to [`cache_try_set`](Cached::cache_try_set). - fn try_set(&mut self, k: K, v: V) -> Result, Box> { + fn try_set(&mut self, k: K, v: V) -> Result, Self::Error> { self.cache_try_set(k, v) } - /// Get or insert a key-value pair. Delegates to [`cache_get_or_set_with`](Cached::cache_get_or_set_with). - fn get_or_set_with V>(&mut self, key: K, f: F) -> &mut V { + fn get_or_set_with V>(&mut self, key: K, f: F) -> &V { self.cache_get_or_set_with(key, f) } - /// Get or insert a key-value pair with error handling. Delegates to [`cache_try_get_or_set_with`](Cached::cache_try_get_or_set_with). - fn try_get_or_set_with Result, E>( + fn get_or_set_with_mut V>(&mut self, key: K, f: F) -> &mut V { + self.cache_get_or_set_with_mut(key, f) + } + + fn try_get_or_set_with Result, E>(&mut self, k: K, f: F) -> Result<&V, E> { + self.cache_try_get_or_set_with(k, f) + } + + fn try_get_or_set_with_mut Result, E>( &mut self, k: K, f: F, ) -> Result<&mut V, E> { - self.cache_try_get_or_set_with(k, f) + self.cache_try_get_or_set_with_mut(k, f) } - /// Remove a cached value. Delegates to [`cache_remove`](Cached::cache_remove). fn remove(&mut self, k: &Q) -> Option where K: std::borrow::Borrow, @@ -802,8 +1156,6 @@ pub trait Cached { self.cache_remove(k) } - /// Remove a cached entry, returning the stored key and value. Delegates to - /// [`cache_remove_entry`](Cached::cache_remove_entry). fn remove_entry(&mut self, k: &Q) -> Option<(K, V)> where K: std::borrow::Borrow, @@ -812,32 +1164,6 @@ pub trait Cached { self.cache_remove_entry(k) } - /// Delete a cached entry without returning it. Returns `true` if an entry was - /// physically deleted (including expired entries), `false` if the key was absent. - /// - /// Unlike [`cache_remove`](Cached::cache_remove), this returns `true` even when the - /// deleted entry was already expired. Delegates to - /// [`cache_remove_entry`](Cached::cache_remove_entry). - /// - /// ```rust - /// use cached::{Cached, UnboundCache}; - /// - /// let mut cache: UnboundCache = UnboundCache::builder().build().unwrap(); - /// cache.cache_set("key".to_string(), 42); - /// assert!(cache.cache_delete("key")); // present — returns true - /// assert!(!cache.cache_delete("key")); // already gone — returns false - /// ``` - fn cache_delete(&mut self, k: &Q) -> bool - where - K: std::borrow::Borrow, - Q: std::hash::Hash + Eq + ?Sized, - { - self.cache_remove_entry(k).is_some() - } - - /// Delete a cached entry without returning it. Returns `true` if an entry was - /// physically deleted (including expired entries). Delegates to - /// [`cache_delete`](Cached::cache_delete). fn delete(&mut self, k: &Q) -> bool where K: std::borrow::Borrow, @@ -846,45 +1172,34 @@ pub trait Cached { self.cache_delete(k) } - /// Return `true` if the cache contains a value for the given key. - /// - /// Requires `&mut self` because some stores update recency, expiration - /// timestamps, or metrics during reads. For a non-mutating presence check - /// use [`CachedPeek::cache_peek`] if the store implements it. fn contains(&mut self, k: &Q) -> bool where K: std::borrow::Borrow, Q: std::hash::Hash + Eq + ?Sized, { - self.get(k).is_some() + self.cache_get(k).is_some() } - /// Remove all entries, keeping allocated memory for reuse. Delegates to [`cache_clear`](Cached::cache_clear). fn clear(&mut self) { self.cache_clear() } - /// Return the number of entries currently in the cache. Delegates to [`cache_size`](Cached::cache_size). fn len(&self) -> usize { self.cache_size() } - /// Return `true` if the cache contains no entries. fn is_empty(&self) -> bool { self.cache_size() == 0 } - /// Return the number of cache hits, if tracked. fn hits(&self) -> Option { self.cache_hits() } - /// Return the number of cache misses, if tracked. fn misses(&self) -> Option { self.cache_misses() } - /// Return a snapshot of cache metrics. fn metrics(&self) -> CacheMetrics { CacheMetrics { hits: self.cache_hits(), @@ -894,18 +1209,25 @@ pub trait Cached { capacity: self.cache_capacity(), } } - - /// Return the number of times a value was evicted from the cache. - fn cache_evictions(&self) -> Option { - None - } } /// Iteration over cache contents for stores that can expose borrowed entries. /// -/// Timed stores may omit expired entries from these iterators without eagerly removing them. +/// **Contract for timed and expiring stores**: `iter()` (and `keys()`, `values()`) omit +/// expired entries from the yielded view but do **not** remove them from the store. The +/// receiver is `&self`, so no mutation occurs during iteration. As a result, +/// `iter().count()` may be less than `len()` when expired-but-not-yet-swept entries are +/// present. Call `evict()` (via [`CacheEvict`](crate::CacheEvict)) to physically remove +/// expired entries and reclaim memory. +/// +/// Sharded stores (`Sharded*`) do not implement this trait; they are internally +/// synchronized and cannot expose borrowed references through `&self` iteration. +/// Use `ConcurrentCacheEvict::evict` on sharded stores to sweep expired entries. pub trait CachedIter { /// Return an iterator over the key-value pairs in the cache. + /// + /// On timed/expiring stores, expired entries are omitted from the iterator without + /// being removed. The returned count may be less than `len()`. fn iter<'a>(&'a self) -> impl Iterator + 'a where Self: Sized, @@ -1003,13 +1325,13 @@ pub trait CachedRead: CachedPeek { /// ```rust /// # #[cfg(feature = "time_stores")] /// # { -/// use cached::{Cached, CloneCached, TtlCache}; +/// use cached::{CachedExt, CloneCached, TtlCache}; /// use cached::time::Duration; /// /// let mut c = TtlCache::builder().ttl(Duration::from_secs(60)).build().unwrap(); -/// c.cache_set("k".to_string(), 1); -/// assert_eq!(c.cache_get_with_expiry_status(&"k".to_string()), (Some(1), false)); // live -/// assert_eq!(c.cache_get_with_expiry_status(&"x".to_string()), (None, false)); // absent +/// c.set("k".to_string(), 1); +/// assert_eq!(c.get_with_expiry_status(&"k".to_string()), (Some(1), false)); // live +/// assert_eq!(c.get_with_expiry_status(&"x".to_string()), (None, false)); // absent /// // (a present-but-expired entry would yield `(Some(v), true)`) /// # } /// ``` @@ -1034,6 +1356,25 @@ pub trait CloneCached { { self.cache_get_with_expiry_status(key) } + + /// Non-renewing peek that also reports whether the found entry is expired. + /// + /// This is a required method. Implementations must satisfy the contract: same + /// `(value, expired)` return shape as + /// [`cache_get_with_expiry_status`](Self::cache_get_with_expiry_status), but the read + /// must not produce any observable side effects: no LRU promotion, no hit/miss counter + /// increment, no TTL renewal. + /// + /// Returns `(Some(v), true)` for a present-but-expired entry, `(None, false)` for an + /// absent key, `(Some(v), false)` for a live entry. + /// + /// This is used on the `force_refresh` bypass path of `#[cached(result_fallback = true)]` + /// to capture the stale fallback value without touching recency or metrics. + fn cache_peek_with_expiry_status(&self, key: &Q) -> (Option, bool) + where + K: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized, + V: Clone; } /// Concurrent analogue of [`CloneCached`] for the internally-synchronized sharded stores. @@ -1047,7 +1388,7 @@ pub trait CloneCached { /// Implemented by the four expiry-capable sharded stores: /// [`ShardedTtlCache`], [`ShardedLruTtlCache`], [`ShardedExpiringCache`], and /// [`ShardedExpiringLruCache`]. -/// Non-expiry stores ([`ShardedCache`], [`ShardedLruCache`]) do not implement this trait, +/// Non-expiry stores ([`ShardedUnboundCache`], [`ShardedLruCache`]) do not implement this trait, /// mirroring how [`CloneCached`] is absent on [`UnboundCache`] and [`LruCache`]. /// /// **Why `&K` instead of `&Q` (`Borrow`)**: same reason as [`ConcurrentCached`] — the @@ -1064,11 +1405,11 @@ pub trait CloneCached { /// ```rust /// # #[cfg(feature = "time_stores")] /// # { -/// use cached::{ConcurrentCached, ConcurrentCloneCached, ShardedTtlCache}; +/// use cached::{ConcurrentCloneCached, ShardedTtlCache}; /// use cached::time::Duration; /// /// let c = ShardedTtlCache::builder().ttl(Duration::from_secs(60)).build().unwrap(); -/// c.cache_set("k".to_string(), 1_i32).expect("infallible ShardedTtlCache set"); +/// c.set("k".to_string(), 1_i32); /// assert_eq!(c.cache_get_with_expiry_status(&"k".to_string()), (Some(1_i32), false)); // live /// assert_eq!(c.cache_get_with_expiry_status(&"x".to_string()), (None, false)); // absent /// // a present-but-expired entry yields (Some(v), true) @@ -1088,6 +1429,22 @@ pub trait ConcurrentCloneCached { /// intentionally leaves it in place so it can be returned as a stale fallback. Expired /// entries are swept by a subsequent `cache_get`, an explicit `cache_remove`, or `evict()`. fn cache_get_with_expiry_status(&self, key: &K) -> (Option, bool); + + /// Look up a cached value and report whether the found entry is expired without any read + /// side effects. + /// + /// This is a required method. Implementations must satisfy the contract: same + /// `(value, expired)` return shape as + /// [`cache_get_with_expiry_status`](Self::cache_get_with_expiry_status), but the read + /// must not increment hits or misses counters, must not update LRU recency, and must + /// not renew the TTL. + /// + /// Returns `(Some(v), true)` for a present-but-expired entry, `(None, false)` for an + /// absent key, `(Some(v), false)` for a live entry. + /// + /// This is used on the `force_refresh` bypass path of `#[concurrent_cached(result_fallback = true)]` + /// to capture the stale fallback value without touching counters or recency. + fn cache_peek_with_expiry_status(&self, key: &K) -> (Option, bool); } /// TTL management for single-owner time-bounded cache stores. @@ -1098,7 +1455,7 @@ pub trait ConcurrentCloneCached { /// Internally-synchronized concurrent stores (the sharded TTL stores, `RedisCache`, /// `AsyncRedisCache`, and `RedbCache`) do **not** implement this trait. They are held /// behind an `Arc`/`static` and cannot offer `&mut self`. Manage their TTL through the -/// `&self` methods on [`ConcurrentCached`]/[`ConcurrentCachedAsync`] +/// `&self` methods on the [`ConcurrentCacheTtl`] trait /// (`ttl`/`set_ttl`/`unset_ttl`/`set_refresh_on_hit`) instead. /// /// This trait requires the `time_stores` feature. @@ -1106,30 +1463,40 @@ pub trait ConcurrentCloneCached { #[cfg_attr(docsrs, doc(cfg(feature = "time_stores")))] pub trait CacheTtl { /// Return the TTL applied to newly inserted entries. + #[must_use] fn ttl(&self) -> Option; - /// Set the TTL for newly inserted entries, returning the previous value. + /// Set the TTL for newly inserted entries, returning the previous value (or `None` + /// if expiry was disabled). /// - /// # Panics - /// - /// Implementations that accept a TTL panic if `ttl.is_zero()` — use - /// [`unset_ttl`](Self::unset_ttl) to disable expiry instead. + /// A zero `ttl` disables expiry: subsequently inserted entries never expire. This is + /// equivalent to [`unset_ttl`](Self::unset_ttl). Pre-existing entries keep their + /// original expiry. Use [`try_set_ttl`](Self::try_set_ttl) if you want a zero `ttl` + /// rejected instead. fn set_ttl(&mut self, ttl: Duration) -> Option; + /// Validated variant of [`set_ttl`](Self::set_ttl): returns [`SetTtlError::ZeroTtl`] + /// when `ttl` is zero instead of storing it. This is the strict "give me a real ttl" + /// path; to disable expiry, call [`set_ttl`](Self::set_ttl) with a zero `Duration` or + /// [`unset_ttl`](Self::unset_ttl). + fn try_set_ttl(&mut self, ttl: Duration) -> Result, crate::SetTtlError> { + if ttl.is_zero() { + return Err(crate::SetTtlError::ZeroTtl); + } + Ok(self.set_ttl(ttl)) + } + /// Remove the TTL so entries are retained indefinitely. /// /// No-op for stores that cannot retain values indefinitely. fn unset_ttl(&mut self) -> Option; /// Return `true` if cache hits refresh the TTL of the accessed entry. - fn refresh_on_hit(&self) -> bool { - false - } + #[must_use] + fn refresh_on_hit(&self) -> bool; /// Set whether cache hits should refresh the TTL. Returns the previous value. - fn set_refresh_on_hit(&mut self, _refresh: bool) -> bool { - false - } + fn set_refresh_on_hit(&mut self, refresh: bool) -> bool; } #[cfg(feature = "async_core")] @@ -1137,11 +1504,40 @@ pub trait CacheTtl { pub trait CachedAsync { /// Get the value for `k`, or compute and insert it by awaiting `f` on a miss. /// - /// The async counterpart of [`Cached::get_or_set_with`]. It is `async_`-prefixed - /// so that importing both [`Cached`] and [`CachedAsync`] (common, since the + /// The async counterpart of [`CachedExt::get_or_set_with`]. It is `async_`-prefixed + /// so that importing both [`CachedExt`] and [`CachedAsync`] (common, since the /// in-memory stores implement both) does not make `get_or_set_with` ambiguous /// at the call site. - fn async_get_or_set_with<'a, F, Fut>( + /// + /// Returns `&V`. Use + /// [`async_cache_get_or_set_with_mut`](CachedAsync::async_cache_get_or_set_with_mut) for a + /// mutable reference. + /// + /// This default returns a `Send` future, so it carries `Self: Send, K: Send` + /// (the future captures `&mut self` and `k` across the await). A store that is + /// genuinely `!Send` cannot use this default and should implement + /// [`async_cache_get_or_set_with_mut`](CachedAsync::async_cache_get_or_set_with_mut) + /// directly; the `&V` wrapper is only a convenience over it. + fn async_cache_get_or_set_with<'a, F, Fut>( + &'a mut self, + k: K, + f: F, + ) -> impl Future + Send + 'a + where + Self: Send, + K: Send + 'a, + V: Send + 'a, + F: FnOnce() -> Fut + Send + 'a, + Fut: Future + Send + 'a, + { + async move { &*self.async_cache_get_or_set_with_mut(k, f).await } + } + + /// The mutable counterpart of + /// [`async_cache_get_or_set_with`](CachedAsync::async_cache_get_or_set_with): returns + /// `&mut V`. Stores implement this method; the shared-reference variant + /// delegates to it. + fn async_cache_get_or_set_with_mut<'a, F, Fut>( &'a mut self, k: K, f: F, @@ -1152,10 +1548,41 @@ pub trait CachedAsync { F: FnOnce() -> Fut + Send + 'a, Fut: Future + Send + 'a; - /// Like [`async_get_or_set_with`](CachedAsync::async_get_or_set_with), but + /// Like [`async_cache_get_or_set_with`](CachedAsync::async_cache_get_or_set_with), but /// `f` is fallible: on a miss the value is cached only if `f` resolves to /// `Ok`, and an `Err` is returned without caching. - fn async_try_get_or_set_with<'a, F, Fut, E>( + /// + /// Returns `Result<&V, E>`. Use + /// [`async_cache_try_get_or_set_with_mut`](CachedAsync::async_cache_try_get_or_set_with_mut) + /// for a mutable reference. + /// + /// Like [`async_cache_get_or_set_with`](CachedAsync::async_cache_get_or_set_with), this + /// default returns a `Send` future and so carries `Self: Send, K: Send`; a + /// `!Send` store should implement the `_mut` variant directly. + fn async_cache_try_get_or_set_with<'a, F, Fut, E>( + &'a mut self, + k: K, + f: F, + ) -> impl Future> + Send + 'a + where + Self: Send, + K: Send + 'a, + V: Send + 'a, + E: 'a, + F: FnOnce() -> Fut + Send + 'a, + Fut: Future> + Send + 'a, + { + async move { + self.async_cache_try_get_or_set_with_mut(k, f) + .await + .map(|v| &*v) + } + } + + /// The mutable counterpart of + /// [`async_cache_try_get_or_set_with`](CachedAsync::async_cache_try_get_or_set_with): + /// returns `Result<&mut V, E>`. + fn async_cache_try_get_or_set_with_mut<'a, F, Fut, E>( &'a mut self, k: K, f: F, @@ -1171,87 +1598,297 @@ pub trait CachedAsync { /// /// Defaults to calling the synchronous [`Cached::cache_get`] implementation. Stores can /// override to provide a truly async path. - fn get_async<'a, Q>(&'a mut self, k: &'a Q) -> impl Future> + Send + 'a + fn async_cache_get<'a, Q>( + &'a mut self, + k: &'a Q, + ) -> impl Future> + Send + 'a where Self: Cached + Send, K: std::borrow::Borrow + 'a, Q: std::hash::Hash + Eq + ?Sized + Sync, V: 'a, { - async move { self.get(k) } + async move { self.cache_get(k) } } /// Insert a key-value pair asynchronously. /// /// Defaults to calling the synchronous [`Cached::cache_set`] implementation. - fn set_async(&mut self, k: K, v: V) -> impl Future> + Send + fn async_cache_set(&mut self, k: K, v: V) -> impl Future> + Send where Self: Cached + Send, K: Send, V: Send, { - async move { self.set(k, v) } + async move { self.cache_set(k, v) } } /// Remove a cached value asynchronously. /// /// Defaults to calling the synchronous [`Cached::cache_remove`] implementation. - fn remove_async<'a, Q>(&'a mut self, k: &'a Q) -> impl Future> + Send + 'a + fn async_cache_remove<'a, Q>( + &'a mut self, + k: &'a Q, + ) -> impl Future> + Send + 'a where Self: Cached + Send, K: std::borrow::Borrow + 'a, Q: std::hash::Hash + Eq + ?Sized + Sync, V: 'a, { - async move { self.remove(k) } + async move { self.cache_remove(k) } } /// Remove all entries asynchronously. /// /// Defaults to calling the synchronous [`Cached::cache_clear`] implementation. - fn clear_async(&mut self) -> impl Future + Send + fn async_cache_clear(&mut self) -> impl Future + Send where Self: Cached + Send, { - async move { self.clear() } + async move { self.cache_clear() } } } -/// Cache operations on a store that manages its own synchronization (a shared, -/// `&self` API with owned return values and a fallible `Error`). Implemented by -/// `RedisCache`/`RedbCache`; implement it directly for a custom concurrent or -/// IO-backed store (this is the ~10-line pattern the 1.0 migration guide -/// recommends in place of the removed `InMemoryAdapter`): +/// Shared base of the concurrent cache traits. /// -/// ```rust -/// use cached::ConcurrentCached; -/// use std::collections::HashMap; -/// use std::sync::Mutex; +/// [`ConcurrentCached`] and [`ConcurrentCachedAsync`] both extend this trait, which owns the +/// associated [`Error`](Self::Error) type and the cheap introspection methods +/// ([`cache_size`](Self::cache_size), [`len`](Self::len), [`is_empty`](Self::is_empty), +/// [`cache_hits`](Self::cache_hits), [`cache_misses`](Self::cache_misses), +/// [`cache_capacity`](Self::cache_capacity), [`cache_evictions`](Self::cache_evictions), +/// and [`metrics`](Self::metrics)). Hoisting these into a single base means a store that +/// implements both concurrent traits declares them once and can call them through a shared +/// reference without ambiguity. /// -/// struct MyStore(Mutex>); +/// The metric accessors mirror those on [`Cached`]/[`CachedExt`] for the non-concurrent family. +/// The sharded in-memory stores override them to aggregate across all shards; external stores +/// (Redis, Redb) leave the defaults, which return `None` / an empty `CacheMetrics`. /// -/// impl ConcurrentCached for MyStore { -/// type Error = std::convert::Infallible; -/// fn cache_get(&self, k: &String) -> Result, Self::Error> { -/// Ok(self.0.lock().unwrap().get(k).copied()) -/// } -/// fn cache_set(&self, k: String, v: u32) -> Result, Self::Error> { -/// Ok(self.0.lock().unwrap().insert(k, v)) -/// } -/// fn cache_remove(&self, k: &String) -> Result, Self::Error> { -/// Ok(self.0.lock().unwrap().remove(k)) -/// } -/// fn cache_remove_entry(&self, k: &String) -> Result, Self::Error> { -/// Ok(self.0.lock().unwrap().remove_entry(k)) -/// } -/// fn set_refresh_on_hit(&self, _refresh: bool) -> bool { false } -/// } +/// Implementors do not normally name this trait directly: implementing [`ConcurrentCached`] or +/// [`ConcurrentCachedAsync`] requires a `ConcurrentCacheBase` impl, which is where the +/// `type Error` and any `cache_size`/metric overrides live. +pub trait ConcurrentCacheBase { + /// The error type returned by fallible cache operations. + type Error; + + /// Report the number of entries currently held by the store, if the store can + /// determine it cheaply. + /// + /// Returns `Ok(Some(n))` for stores that track their own size (all in-memory sharded + /// stores), and `Ok(None)` for stores that cannot answer without an expensive or + /// semantically-ambiguous query — `RedisCache` / `AsyncRedisCache` (the key count is a + /// server-side `DBSIZE`/`SCAN` over a shared keyspace, not just this cache's entries) and + /// `RedbCache` (an `O(n)` scan of the backing table). Those stores return `Ok(None)` rather + /// than pay that cost implicitly. For `RedisCache` / `AsyncRedisCache` you can query the server + /// directly if you need a count; `RedbCache` holds an exclusive lock on its file and exposes no + /// live database handle (`RedbCache::disk_path()` gives the file location for offline inspection, + /// but the database cannot be opened by a second instance while the cache is live). + /// + /// This is the concurrent analogue of [`Cached::cache_size`], widened to + /// `Result, _>` because concurrent stores may be fallible and may not know + /// their size. The default returns `Ok(None)`. + /// + /// # Errors + /// + /// Should return `Self::Error` if determining the size fails. + fn cache_size(&self) -> Result, Self::Error> { + Ok(None) + } + + /// Ergonomic alias for [`cache_size`](Self::cache_size). + /// + /// Note: the sharded stores also expose an inherent `len(&self) -> usize`, which + /// takes priority at the call site (`store.len()` returns a bare `usize`). To call + /// this trait method, use fully-qualified syntax: `ConcurrentCacheBase::len(&store)`. + fn len(&self) -> Result, Self::Error> { + self.cache_size() + } + + /// Return `Ok(Some(true))` if the cache is known to be empty, `Ok(Some(false))` if + /// known non-empty, or `Ok(None)` if the size is unknown. + /// + /// Note: like [`len`](Self::len), the sharded stores' inherent `is_empty(&self) -> bool` + /// takes priority; use `ConcurrentCacheBase::is_empty(&store)` to call this trait method. + fn is_empty(&self) -> Result, Self::Error> { + Ok(self.cache_size()?.map(|n| n == 0)) + } + + /// Return the number of times a cached value was successfully retrieved, if tracked. + /// + /// The sharded in-memory stores track per-shard hit counters and sum them here. + /// External stores (Redis, Redb) do not track hits and return `None`. + /// + /// This mirrors [`Cached::cache_hits`] on the non-concurrent family. + #[must_use] + fn cache_hits(&self) -> Option { + None + } + + /// Return the number of times a cached value was not found, if tracked. + /// + /// The sharded in-memory stores track per-shard miss counters and sum them here. + /// External stores (Redis, Redb) do not track misses and return `None`. + /// + /// This mirrors [`Cached::cache_misses`] on the non-concurrent family. + #[must_use] + fn cache_misses(&self) -> Option { + None + } + + /// Return the cache capacity, if bounded. + /// + /// Bounded stores (e.g. `ShardedLruCache`, `ShardedLruTtlCache`, `ShardedExpiringLruCache`) + /// return `Some(total_capacity)`; unbounded stores return `None`. + /// + /// This mirrors [`Cached::cache_capacity`] on the non-concurrent family. + #[must_use] + fn cache_capacity(&self) -> Option { + None + } + + /// Return the number of entries evicted from the cache, if tracked. + /// + /// Stores that track evictions (bounded stores and expiry-capable stores) return + /// `Some(count)`. Unbounded stores without expiry (e.g. `ShardedUnboundCache`) + /// return `None`. + /// + /// This mirrors [`Cached::cache_evictions`] on the non-concurrent family. + #[must_use] + fn cache_evictions(&self) -> Option { + None + } + + /// Return a snapshot of cache metrics. + /// + /// Aggregates hits, misses, evictions, entry count, and capacity across all shards (for + /// sharded stores). The sharded stores also expose an inherent `metrics()` method that + /// returns the same `CacheMetrics` without needing this trait in scope; this trait + /// method exists so generic code over a `ConcurrentCacheBase` bound can read metrics + /// uniformly. + /// + /// Note: `shards()` and `shard_sizes()` are inherent-only on the sharded stores - they + /// are sharding-specific and not part of the generic concurrent base trait. + /// + /// This mirrors the default [`CachedExt::metrics`] on the non-concurrent family. + #[must_use] + fn metrics(&self) -> CacheMetrics { + let entry_count = self.cache_size().ok().flatten().unwrap_or(0); + CacheMetrics { + hits: self.cache_hits(), + misses: self.cache_misses(), + evictions: self.cache_evictions(), + entry_count, + capacity: self.cache_capacity(), + } + } +} + +/// Global-TTL controls for concurrent stores that have one. +/// +/// Mirrors the single-owner [`CacheTtl`] trait but with `&self` methods, since concurrent stores +/// are internally synchronized and held behind an `Arc`/`static`. Only the ttl-capable concurrent +/// stores implement it: the sharded TTL stores (`ShardedTtlCache`, `ShardedLruTtlCache`), +/// `RedisCache`, `AsyncRedisCache`, and `RedbCache`. Non-ttl concurrent stores +/// (`ShardedUnboundCache`, `ShardedLruCache`, `ShardedExpiringCache`, `ShardedExpiringLruCache`) +/// deliberately do **not** implement it — they have no global TTL knob, so `set_ttl` simply does +/// not exist on them. +pub trait ConcurrentCacheTtl { + /// Return the ttl of cached values (time to eviction). + #[must_use] + fn ttl(&self) -> Option; + + /// Set the ttl of cached values, returning the previous value. + /// + /// Takes `&self`: concurrent stores are internally synchronized, so this is callable + /// through a shared reference. + /// + /// A zero `Duration` disables expiry — it is exactly equivalent to + /// [`unset_ttl`](Self::unset_ttl), and subsequently inserted entries never expire. + /// For the Redis stores this writes keys without any expiry (a plain `SET`). Use + /// [`try_set_ttl`](Self::try_set_ttl) if you want a zero `ttl` rejected instead. + fn set_ttl(&self, ttl: Duration) -> Option; + + /// Validated variant of [`set_ttl`](Self::set_ttl): returns [`SetTtlError::ZeroTtl`] + /// when `ttl` is zero instead of storing it. This is the strict "give me a real ttl" + /// path; to disable expiry, call [`set_ttl`](Self::set_ttl) with a zero `Duration` or + /// [`unset_ttl`](Self::unset_ttl). + /// + /// # Errors + /// + /// Returns [`SetTtlError::ZeroTtl`] if `ttl` is zero. + fn try_set_ttl(&self, ttl: Duration) -> Result, crate::SetTtlError> { + if ttl.is_zero() { + return Err(crate::SetTtlError::ZeroTtl); + } + Ok(self.set_ttl(ttl)) + } + + /// Remove the ttl for cached values, returning the previous value. Equivalent to + /// [`set_ttl`](Self::set_ttl) with a zero `Duration`. + /// + /// For cache implementations that don't support retaining values indefinitely, this method is + /// a no-op. Takes `&self`: concurrent stores are internally synchronized, so this is + /// callable through a shared reference. + fn unset_ttl(&self) -> Option; + + /// Return `true` if cache hits refresh the ttl of the accessed entry. + #[must_use] + fn refresh_on_hit(&self) -> bool; + + /// Set whether cache hits refresh the ttl of cached values, returning the previous flag value. + /// + /// Takes `&self`: concurrent stores are internally synchronized (sharded stores use an + /// `AtomicBool`; `RedisCache` / `RedbCache` use interior mutability), so this is callable + /// through a shared reference such as an `Arc` or a `LazyLock` static. + fn set_refresh_on_hit(&self, refresh: bool) -> bool; +} + +/// Cache operations on a store that manages its own synchronization (a shared, +/// `&self` API with owned return values and a fallible `Error`). Implemented by +/// `RedisCache`/`RedbCache`; implement it directly for a custom concurrent or +/// IO-backed store (this is the ~10-line pattern the 1.0 migration guide +/// recommends in place of the removed `InMemoryAdapter`): +/// +/// ```rust +/// use cached::{ConcurrentCacheBase, ConcurrentCached, ConcurrentCachedExt}; +/// use std::collections::HashMap; +/// use std::sync::Mutex; +/// +/// struct MyStore(Mutex>); +/// +/// impl ConcurrentCacheBase for MyStore { +/// type Error = std::convert::Infallible; +/// } +/// +/// impl ConcurrentCached for MyStore { +/// fn cache_get(&self, k: &String) -> Result, Self::Error> { +/// Ok(self.0.lock().unwrap().get(k).copied()) +/// } +/// fn cache_set(&self, k: String, v: u32) -> Result, Self::Error> { +/// Ok(self.0.lock().unwrap().insert(k, v)) +/// } +/// fn cache_remove(&self, k: &String) -> Result, Self::Error> { +/// Ok(self.0.lock().unwrap().remove(k)) +/// } +/// fn cache_remove_entry(&self, k: &String) -> Result, Self::Error> { +/// Ok(self.0.lock().unwrap().remove_entry(k)) +/// } +/// fn cache_clear(&self) -> Result<(), Self::Error> { +/// self.0.lock().unwrap().clear(); +/// Ok(()) +/// } +/// fn cache_reset(&self) -> Result<(), Self::Error> { +/// self.0.lock().unwrap().clear(); +/// Ok(()) +/// } +/// } /// /// let store = MyStore(Mutex::new(HashMap::new())); -/// assert_eq!(store.cache_get(&"k".to_string()).expect("MyStore is infallible"), None); -/// assert_eq!(store.cache_set("k".to_string(), 7).expect("MyStore is infallible"), None); -/// assert_eq!(store.cache_get(&"k".to_string()).expect("MyStore is infallible"), Some(7)); -/// assert_eq!(store.cache_remove(&"k".to_string()).expect("MyStore is infallible"), Some(7)); +/// assert_eq!(store.get(&"k".to_string()).expect("MyStore is infallible"), None); +/// assert_eq!(store.set("k".to_string(), 7).expect("MyStore is infallible"), None); +/// assert_eq!(store.get(&"k".to_string()).expect("MyStore is infallible"), Some(7)); +/// assert_eq!(store.remove(&"k".to_string()).expect("MyStore is infallible"), Some(7)); /// ``` /// **Async counterpart**: /// @@ -1267,9 +1904,7 @@ pub trait CachedAsync { /// a lookup. A generic `&Q` where only `K: Borrow` carries no serialization guarantee, and /// adding a `Q: Serialize` bound to the trait would bleed a serde dependency into every /// `ConcurrentCached` implementation. All key-lookup methods therefore take `&K` directly. -pub trait ConcurrentCached { - type Error; - +pub trait ConcurrentCached: ConcurrentCacheBase { /// Attempt to retrieve a cached value /// /// # Errors @@ -1341,17 +1976,17 @@ pub trait ConcurrentCached { /// # Example /// /// ```rust - /// use cached::{ConcurrentCached, ShardedCache}; + /// use cached::{ConcurrentCached, ConcurrentCachedExt, ShardedUnboundCache}; /// - /// let cache: ShardedCache = ShardedCache::builder().build().unwrap(); - /// cache.cache_set("key".to_string(), 42).expect("ShardedCache is infallible"); + /// let cache: ShardedUnboundCache = ShardedUnboundCache::builder().build().unwrap(); + /// cache.set("key".to_string(), 42); /// - /// // cache_remove_entry always returns Some when the key was present. - /// let entry = cache.cache_remove_entry(&"key".to_string()).expect("ShardedCache is infallible"); + /// // remove_entry always returns Some when the key was present. + /// let entry = cache.remove_entry(&"key".to_string()); /// assert_eq!(entry, Some(("key".to_string(), 42))); /// /// // Returns None only when the key was never present. - /// assert_eq!(cache.cache_remove_entry(&"missing".to_string()).expect("ShardedCache is infallible"), None); + /// assert_eq!(cache.remove_entry(&"missing".to_string()), None); /// ``` fn cache_remove_entry(&self, k: &K) -> Result, Self::Error>; @@ -1370,93 +2005,35 @@ pub trait ConcurrentCached { self.cache_remove_entry(k).map(|removed| removed.is_some()) } - /// Retrieve a cached value. Delegates to [`cache_get`](ConcurrentCached::cache_get). - #[inline] - fn get(&self, k: &K) -> Result, Self::Error> { - self.cache_get(k) - } - - /// Insert a key-value pair and return the previous value. Delegates to [`cache_set`](ConcurrentCached::cache_set). - #[inline] - fn set(&self, k: K, v: V) -> Result, Self::Error> { - self.cache_set(k, v) - } - - /// Remove a cached value and return it. Delegates to [`cache_remove`](ConcurrentCached::cache_remove). - #[inline] - fn remove(&self, k: &K) -> Result, Self::Error> { - self.cache_remove(k) - } - - /// Remove a cached entry and return the stored key and value. Delegates to [`cache_remove_entry`](ConcurrentCached::cache_remove_entry). - #[inline] - fn remove_entry(&self, k: &K) -> Result, Self::Error> { - self.cache_remove_entry(k) - } - - /// Delete a cached value without returning it. Delegates to [`cache_delete`](ConcurrentCached::cache_delete). - #[inline] - fn delete(&self, k: &K) -> Result { - self.cache_delete(k) - } - - /// Report the number of entries currently held by the store, if the store can - /// determine it cheaply. - /// - /// Returns `Ok(Some(n))` for stores that track their own size (all in-memory sharded - /// stores), and `Ok(None)` for stores that cannot answer without an expensive or - /// semantically-ambiguous query — `RedisCache` / `AsyncRedisCache` (the key count is a - /// server-side `DBSIZE`/`SCAN` over a shared keyspace, not just this cache's entries) and - /// `RedbCache` (an `O(n)` scan of the backing table). Those stores return `Ok(None)` rather - /// than pay that cost implicitly. For `RedisCache` / `AsyncRedisCache` you can query the server - /// directly if you need a count; `RedbCache` holds an exclusive lock on its file and exposes no - /// live database handle (`RedbCache::disk_path()` gives the file location for offline inspection, - /// but the database cannot be opened by a second instance while the cache is live). - /// - /// This is the concurrent analogue of [`Cached::cache_size`], widened to - /// `Result, _>` because concurrent stores may be fallible and may not know - /// their size. The default returns `Ok(None)`. - /// - /// # Errors - /// - /// Should return `Self::Error` if determining the size fails. - fn cache_size(&self) -> Result, Self::Error> { - Ok(None) - } - /// Remove all cached entries while preserving capacity allocation and metrics. /// - /// The concurrent analogue of [`Cached::cache_clear`]. The default implementation is a - /// **no-op that does nothing and returns `Ok(())`**: `RedisCache` / `AsyncRedisCache` keep - /// this default because clearing a shared external keyspace is expensive and potentially - /// destructive — calling `cache_clear()` on them succeeds without removing anything. The - /// internally-synchronized sharded in-memory stores override this to clear every shard, and - /// `RedbCache` overrides it to clear its (local, single-file) redb table. To also reset metrics, call + /// This is a required method. The concurrent analogue of [`Cached::cache_clear`]. + /// The internally-synchronized sharded in-memory stores clear every shard; `RedbCache` + /// clears its (local, single-file) redb table; and `RedisCache` / `AsyncRedisCache` + /// use a namespace-scoped `SCAN` + batched `DEL` (O(n) in matching keys and not atomic; + /// see the store docs). To also reset metrics, call /// [`cache_reset_metrics`](ConcurrentCached::cache_reset_metrics), or use /// [`cache_reset`](ConcurrentCached::cache_reset) to do both at once. /// /// # Errors /// /// Should return `Self::Error` if the operation fails. - fn cache_clear(&self) -> Result<(), Self::Error> { - Ok(()) - } + fn cache_clear(&self) -> Result<(), Self::Error>; /// Reset all entries and metrics (hits, misses, evictions) to zero. /// - /// The concurrent analogue of [`Cached::cache_reset`]. Store configuration — capacity, - /// TTL, and `on_evict` callbacks — is preserved. The default is a **no-op** for the same - /// reason as [`cache_clear`](ConcurrentCached::cache_clear); the sharded in-memory stores - /// override it to clear every shard and zero their metrics, and `RedbCache` overrides it to - /// clear its redb table (it tracks no in-memory metrics). To reset entries without - /// resetting metrics, use [`cache_clear`](ConcurrentCached::cache_clear). + /// This is a required method. The concurrent analogue of [`Cached::cache_reset`]. Store + /// configuration (capacity, TTL, `on_evict` callbacks) is preserved. The sharded in-memory + /// stores clear every shard and zero their metrics; `RedbCache` and `RedisCache` / + /// `AsyncRedisCache` clear their entries (they track no in-memory metrics, so resetting + /// is exactly [`cache_clear`](ConcurrentCached::cache_clear)). + /// To reset entries without resetting metrics, use + /// [`cache_clear`](ConcurrentCached::cache_clear). /// /// # Errors /// /// Should return `Self::Error` if the operation fails. - fn cache_reset(&self) -> Result<(), Self::Error> { - Ok(()) - } + fn cache_reset(&self) -> Result<(), Self::Error>; /// Reset hit/miss/eviction counters to zero without removing entries. /// @@ -1470,33 +2047,105 @@ pub trait ConcurrentCached { Ok(()) } - /// Set whether cache hits refresh the ttl of cached values, returning the previous flag value. + /// Return the cached value for `k`, or compute `f()`, store it, and return it. /// - /// Takes `&self`: concurrent stores are internally synchronized (sharded stores use an - /// `AtomicBool`; `RedisCache` / `RedbCache` use interior mutability), so this is callable - /// through a shared reference such as an `Arc` or a `LazyLock` static. - fn set_refresh_on_hit(&self, refresh: bool) -> bool; + /// This is a non-atomic get-then-set: on a miss, another thread may store a value + /// for the same key between the get and the set, in which case the computed value + /// overwrites the concurrent write. For workloads requiring atomicity, use a store + /// that provides internal locking (e.g. `sync_writes` on the proc macro). + /// + /// # Errors + /// + /// Returns `Self::Error` if `cache_get` or `cache_set` fails. + fn cache_get_or_set_with V>(&self, k: K, f: F) -> Result + where + V: Clone, + { + if let Some(v) = self.cache_get(&k)? { + return Ok(v); + } + let v = f(); + self.cache_set(k, v.clone())?; + Ok(v) + } +} - /// Return the ttl of cached values (time to eviction). - fn ttl(&self) -> Option { - None +/// Short-alias extension for [`ConcurrentCached`] stores. +/// +/// Every type that implements `ConcurrentCached` automatically implements this trait +/// through a blanket impl. The methods here are ergonomic short names that delegate to the +/// `cache_`-prefixed core methods on `ConcurrentCached`. Import this trait (or +/// `use cached::prelude::*;`) to bring the short names into scope: +/// +/// ```rust +/// use cached::{ConcurrentCachedExt, ShardedUnboundCache}; +/// +/// let cache: ShardedUnboundCache = ShardedUnboundCache::builder().build().unwrap(); +/// // Inherent get/set take priority over trait methods on sharded stores; +/// // use fully-qualified syntax for the trait version. +/// ConcurrentCachedExt::set(&cache, "key".to_string(), 42).unwrap(); +/// assert_eq!(ConcurrentCachedExt::get(&cache, &"key".to_string()).unwrap(), Some(42)); +/// ConcurrentCachedExt::remove(&cache, &"key".to_string()).unwrap(); +/// ``` +/// +/// Note: for the sharded in-memory stores, the inherent `get`/`set`/`remove`/`delete` methods +/// (returning unwrapped `Option`/`bool` rather than `Result`) take priority at the call site +/// over these trait methods. Use fully-qualified syntax +/// (`ConcurrentCachedExt::get(&store, &k)`) or the `cache_`-prefixed form +/// (`ConcurrentCached::cache_get`) to explicitly call the trait version. +pub trait ConcurrentCachedExt: ConcurrentCached { + /// Retrieve a cached value. Delegates to [`cache_get`](ConcurrentCached::cache_get). + fn get(&self, k: &K) -> Result, Self::Error>; + + /// Insert a key-value pair and return the previous value. Delegates to + /// [`cache_set`](ConcurrentCached::cache_set). + fn set(&self, k: K, v: V) -> Result, Self::Error>; + + /// Remove a cached value and return it. Delegates to + /// [`cache_remove`](ConcurrentCached::cache_remove). + fn remove(&self, k: &K) -> Result, Self::Error>; + + /// Remove a cached entry and return the stored key and value. Delegates to + /// [`cache_remove_entry`](ConcurrentCached::cache_remove_entry). + fn remove_entry(&self, k: &K) -> Result, Self::Error>; + + /// Delete a cached value without returning it. Delegates to + /// [`cache_delete`](ConcurrentCached::cache_delete). + fn delete(&self, k: &K) -> Result; + + /// Return the cached value for `k`, or compute and store `f()`. Delegates to + /// [`cache_get_or_set_with`](ConcurrentCached::cache_get_or_set_with). + fn get_or_set_with V>(&self, k: K, f: F) -> Result + where + V: Clone; +} + +impl> ConcurrentCachedExt for T { + fn get(&self, k: &K) -> Result, Self::Error> { + self.cache_get(k) } - /// Set the ttl of cached values, returning the previous value. - /// - /// Takes `&self`: concurrent stores are internally synchronized, so this is callable - /// through a shared reference. The default is a no-op returning `None`. - fn set_ttl(&self, _ttl: Duration) -> Option { - None + fn set(&self, k: K, v: V) -> Result, Self::Error> { + self.cache_set(k, v) } - /// Remove the ttl for cached values, returning the previous value. - /// - /// For cache implementations that don't support retaining values indefinitely, this method is - /// a no-op. Takes `&self`: concurrent stores are internally synchronized, so this is - /// callable through a shared reference. - fn unset_ttl(&self) -> Option { - None + fn remove(&self, k: &K) -> Result, Self::Error> { + self.cache_remove(k) + } + + fn remove_entry(&self, k: &K) -> Result, Self::Error> { + self.cache_remove_entry(k) + } + + fn delete(&self, k: &K) -> Result { + self.cache_delete(k) + } + + fn get_or_set_with V>(&self, k: K, f: F) -> Result + where + V: Clone, + { + self.cache_get_or_set_with(k, f) } } @@ -1521,8 +2170,7 @@ pub trait ConcurrentCached { /// for these methods (even an identical no-op) rather than relying on the defaults. #[cfg(feature = "async_core")] #[cfg_attr(docsrs, doc(cfg(feature = "async_core")))] -pub trait ConcurrentCachedAsync { - type Error; +pub trait ConcurrentCachedAsync: ConcurrentCacheBase { #[doc(alias = "async_get")] #[doc(alias = "cache_get")] fn async_cache_get(&self, k: &K) @@ -1568,10 +2216,10 @@ pub trait ConcurrentCachedAsync { /// Remove all cached entries while preserving capacity allocation and metrics. /// - /// The async counterpart of [`ConcurrentCached::cache_clear`]. The default is a **no-op** - /// that resolves to `Ok(())`: external stores (`AsyncRedisCache`) keep this default because - /// clearing a shared external keyspace is destructive. The internally-synchronized sharded - /// in-memory stores and `RedbCache` override it. + /// This is a required method. The async counterpart of [`ConcurrentCached::cache_clear`]. + /// The internally-synchronized sharded in-memory stores and `RedbCache` clear their entries; + /// `AsyncRedisCache` uses a namespace-scoped `SCAN` + batched `DEL` (O(n) in matching keys + /// and not atomic; see the store docs). /// /// # Errors /// @@ -1579,17 +2227,15 @@ pub trait ConcurrentCachedAsync { #[doc(alias = "cache_clear")] fn async_cache_clear(&self) -> impl Future> + Send where - Self: Sync, - { - async move { Ok(()) } - } + Self: Sync; /// Reset all entries and metrics (hits, misses, evictions) to zero. /// - /// The async counterpart of [`ConcurrentCached::cache_reset`]. The default is a **no-op** - /// that resolves to `Ok(())`, for the same reason as - /// [`async_cache_clear`](ConcurrentCachedAsync::async_cache_clear); the sharded in-memory - /// stores and `RedbCache` override it. + /// This is a required method. The async counterpart of [`ConcurrentCached::cache_reset`]. + /// Store configuration (capacity, TTL, `on_evict` callbacks) is preserved. The sharded + /// in-memory stores clear every shard and zero their metrics; `RedbCache` and + /// `AsyncRedisCache` clear their entries (they track no in-memory metrics, so resetting + /// is exactly [`async_cache_clear`](ConcurrentCachedAsync::async_cache_clear)). /// /// # Errors /// @@ -1597,10 +2243,7 @@ pub trait ConcurrentCachedAsync { #[doc(alias = "cache_reset")] fn async_cache_reset(&self) -> impl Future> + Send where - Self: Sync, - { - async move { Ok(()) } - } + Self: Sync; /// Reset hit/miss/eviction counters to zero without removing entries. /// @@ -1619,47 +2262,240 @@ pub trait ConcurrentCachedAsync { async move { Ok(()) } } - /// Report the number of entries currently held by the store, if the store can - /// determine it cheaply. + /// Return the cached value for `k`, or compute `f()`, store it, and return it. /// - /// The concurrent-async analogue of [`Cached::cache_size`]. Returns `Ok(None)` by default; - /// external stores (`RedbCache`, `RedisCache`, `AsyncRedisCache`) keep that default because - /// they cannot report their entry count without an expensive or ambiguous backend query. + /// This is a non-atomic get-then-set: on a miss, another thread may store a value + /// for the same key between the get and the set, in which case the computed value + /// overwrites the concurrent write. For workloads requiring atomicity, use a store + /// that provides internal locking (e.g. `sync_writes` on the proc macro). /// /// # Errors /// - /// Should return `Self::Error` if determining the size fails. - fn cache_size(&self) -> Result, Self::Error> { - Ok(None) + /// Returns `Self::Error` if `async_cache_get` or `async_cache_set` fails. + fn async_cache_get_or_set_with( + &self, + k: K, + f: F, + ) -> impl Future> + Send + where + Self: Sync, + K: Send + Sync, + V: Clone + Send, + F: FnOnce() -> Fut + Send, + Fut: Future + Send, + { + async move { + if let Some(v) = self.async_cache_get(&k).await? { + return Ok(v); + } + let v = f().await; + self.async_cache_set(k, v.clone()).await?; + Ok(v) + } } +} - /// Set whether cache hits refresh the ttl of cached values, returning the previous flag value. +/// Borrowed-set extension for serialize-based concurrent stores. +/// +/// Stores that persist values by serialization (`RedisCache`, `RedbCache`) can accept the +/// key and value by reference, avoiding the clone that +/// [`ConcurrentCached::cache_set`] requires to take ownership. +/// This trait is additive: it does not replace `cache_set`, and the sharded in-memory stores +/// (which store the value directly) do not implement it. +/// +/// The `#[concurrent_cached]` macro automatically routes its cache-set through this trait when +/// the concrete store implements it (no opt-in needed), falling back to the owned `cache_set` +/// otherwise. +/// +/// Implementing this on a custom `#[concurrent_cached]` store (one supplied via `ty` / `create`) +/// is worthwhile when the store serializes its values: the macro will then set entries from the +/// borrowed `&V` with no clone. Without it, the macro takes the owned fallback, which requires +/// `V: Clone` and clones the value on every set. +pub trait SerializeCached: ConcurrentCached { + /// Insert a key/value pair, taking both by reference, and return the previous value if any. /// - /// Takes `&self`: concurrent stores are internally synchronized (sharded stores use an - /// `AtomicBool`; `RedisCache` / `RedbCache` use interior mutability), so this is callable - /// through a shared reference such as an `Arc` or a `LazyLock` static. - fn set_refresh_on_hit(&self, refresh: bool) -> bool; + /// Semantically equivalent to [`ConcurrentCached::cache_set`] but serializes from the + /// borrowed `k`/`v` without cloning. + /// + /// # Errors + /// + /// Returns `Self::Error` if the operation fails. + fn cache_set_ref(&self, k: &K, v: &V) -> Result, Self::Error>; +} - /// Return the ttl of cached values (time to eviction). - fn ttl(&self) -> Option { - None +/// Async borrowed-set extension for serialize-based concurrent stores. +/// +/// The async counterpart of [`SerializeCached`], implemented by `AsyncRedisCache` and `RedbCache`. +/// +/// Note that for the async set path the macro holds the `&V` across the set future, so the +/// borrowed (clone-eliding) route is taken only when the store is also `Sync` and `V: Sync`. +/// A custom `Send + !Sync` async store that implements this trait still falls back to the owned +/// `async_cache_set` clone path (requiring `V: Clone`); a `Send + !Sync + !Clone` value cannot +/// take either route and fails to compile. That failure surfaces as a trait-resolution error at the +/// generated `#[concurrent_cached]` set site (referring to internal dispatch helpers), not at your +/// `impl`; add a `V: Sync` or `V: Clone` bound to resolve it. +#[cfg(feature = "async_core")] +#[cfg_attr(docsrs, doc(cfg(feature = "async_core")))] +pub trait SerializeCachedAsync: ConcurrentCachedAsync { + /// Insert a key/value pair, taking both by reference, and return the previous value if any. + /// + /// The async counterpart of [`SerializeCached::cache_set_ref`]. + /// + /// # Errors + /// + /// Returns `Self::Error` if the operation fails. + fn async_cache_set_ref( + &self, + k: &K, + v: &V, + ) -> impl Future, Self::Error>> + Send; +} + +/// Autoref-specialization shim used by the generated `#[concurrent_cached]` set site +/// to prefer the borrowed setter (`SerializeCached::cache_set_ref`, no value clone) when +/// the concrete store implements `SerializeCached`, falling back to owned `cache_set` +/// (cloning the value) otherwise. Internal implementation detail of the proc-macro; no +/// stability guarantee. +#[doc(hidden)] +pub mod __set_dispatch { + use super::{ConcurrentCacheBase, ConcurrentCached, SerializeCached}; + use core::marker::PhantomData; + + pub struct SetDispatch<'s, S, K, V> { + store: &'s S, + _pd: PhantomData<(K, V)>, } - /// Set the ttl of cached values, returning the previous value. - /// - /// Takes `&self`: concurrent stores are internally synchronized, so this is callable - /// through a shared reference. The default is a no-op returning `None`. - fn set_ttl(&self, _ttl: Duration) -> Option { - None + impl<'s, S, K, V> SetDispatch<'s, S, K, V> { + #[inline] + pub fn new(store: &'s S) -> Self { + SetDispatch { + store, + _pd: PhantomData, + } + } } - /// Remove the ttl for cached values, returning the previous value. - /// - /// For cache implementations that don't support retaining values indefinitely, this method is - /// a no-op. Takes `&self`: concurrent stores are internally synchronized, so this is - /// callable through a shared reference. - fn unset_ttl(&self) -> Option { - None + // PREFERRED arm: inherent method, only exists when S: SerializeCached. No V: Clone. + impl SetDispatch<'_, S, K, V> + where + S: SerializeCached, + { + #[inline] + pub fn cache_set_dispatch( + &self, + key: K, + value: &V, + ) -> Result, ::Error> { + SerializeCached::cache_set_ref(self.store, &key, value) + } + } + + // FALLBACK arm: trait method, reached only when the inherent one is pruned. Requires V: Clone. + pub trait SetDispatchFallback { + type Error; + fn cache_set_dispatch(&self, key: K, value: &V) -> Result, Self::Error>; + } + + impl SetDispatchFallback for SetDispatch<'_, S, K, V> + where + S: ConcurrentCached, + V: Clone, + { + type Error = ::Error; + #[inline] + fn cache_set_dispatch(&self, key: K, value: &V) -> Result, Self::Error> { + ConcurrentCached::cache_set(self.store, key, value.clone()) + } + } +} + +/// Async counterpart of [`__set_dispatch`]: prefers the borrowed async setter +/// (`SerializeCachedAsync::async_cache_set_ref`, no value clone) when the concrete store +/// implements `SerializeCachedAsync`, falling back to owned `async_cache_set` (cloning the +/// value) otherwise. Internal implementation detail of the proc-macro; no stability guarantee. +#[cfg(feature = "async_core")] +#[cfg_attr(docsrs, doc(cfg(feature = "async_core")))] +#[doc(hidden)] +pub mod __set_dispatch_async { + use super::{ConcurrentCacheBase, ConcurrentCachedAsync, SerializeCachedAsync}; + use core::future::Future; + use core::marker::PhantomData; + + pub struct SetDispatchAsync<'s, S, K, V> { + store: &'s S, + _pd: PhantomData<(K, V)>, + } + + impl<'s, S, K, V> SetDispatchAsync<'s, S, K, V> { + #[inline] + pub fn new(store: &'s S) -> Self { + SetDispatchAsync { + store, + _pd: PhantomData, + } + } + } + + // PREFERRED async arm: inherent, gated on SerializeCachedAsync. The key is moved into the + // method by value, so we wrap in `async move` to own it across the await; `value: &V` is + // borrowed from the caller and lives across the caller's immediate await. (Direct-forward + // of the future would let it borrow the local `key`, which escapes; the async-move form + // moves `key` into the future instead.) + // + // Because the returned future captures `&V` across its `.await`, it is `Send` only when + // `V: Sync` -- a stronger bound than the store's own `async_cache_set_ref` needs (`V: Send`, + // since the store serializes `&V` before its first await and does not hold it across one). + // A `Send + !Sync` value therefore does not match this arm: if it is `Clone` it takes the + // owned fallback below (one clone, the pre-shim behavior); if it is also `!Clone` neither arm + // applies and `#[concurrent_cached]` fails to compile. Both are rare for serialize-backed + // async stores and never worse than the previous always-clone path. + impl SetDispatchAsync<'_, S, K, V> + where + S: SerializeCachedAsync + Sync, + K: Send, + V: Sync, + { + #[inline] + pub fn cache_set_dispatch( + &self, + key: K, + value: &V, + ) -> impl Future, ::Error>> + Send + { + let store = self.store; + async move { SerializeCachedAsync::async_cache_set_ref(store, &key, value).await } + } + } + + // FALLBACK async arm: trait. Clone the value EAGERLY before building the future so no + // &V borrow is held across .await (matches the macro's current behavior). + pub trait SetDispatchAsyncFallback { + type Error; + fn cache_set_dispatch( + &self, + key: K, + value: &V, + ) -> impl Future, Self::Error>> + Send; + } + + impl SetDispatchAsyncFallback for SetDispatchAsync<'_, S, K, V> + where + S: ConcurrentCachedAsync + Sync, + K: Send, + V: Clone + Send, + { + type Error = ::Error; + #[inline] + fn cache_set_dispatch( + &self, + key: K, + value: &V, + ) -> impl Future, Self::Error>> + Send { + let v = value.clone(); + let store = self.store; + async move { ConcurrentCachedAsync::async_cache_set(store, key, v).await } + } } } @@ -1667,3 +2503,64 @@ pub trait ConcurrentCachedAsync { // bring your own lock or use a macro (`#[cached]`/`#[once]` generate the lock). // `ConcurrentCached`/`ConcurrentCachedAsync` is the contract for stores that // manage their own synchronization (`RedisCache`, `RedbCache`). + +#[cfg(all(test, feature = "async"))] +mod cached_async_rename_tests { + //! Tests that the four renamed `CachedAsync` default methods exist and behave correctly. + //! These tests fail if the old names (`get_async`, `set_async`, etc.) are used instead, + //! because the new names (`async_cache_get`, `async_cache_set`, etc.) are what the trait + //! now declares. + + use crate::{Cached, CachedAsync, UnboundCache}; + + /// Call all four renamed default methods on an in-memory `UnboundCache` and verify + /// that `async_cache_get`, `async_cache_set`, `async_cache_remove`, and + /// `async_cache_clear` behave like their synchronous counterparts. + #[tokio::test] + async fn async_cache_get_set_remove_clear_behavioral() { + let mut cache: UnboundCache = UnboundCache::builder().build().unwrap(); + + // async_cache_set inserts a value and returns None (no previous entry). + let prev = cache.async_cache_set("a".to_string(), 1u32).await; + assert_eq!(prev, None, "async_cache_set: first insert returns None"); + + // async_cache_set on existing key returns the old value. + let prev = cache.async_cache_set("a".to_string(), 2u32).await; + assert_eq!( + prev, + Some(1u32), + "async_cache_set: overwrite returns old value" + ); + + // async_cache_get returns the current value. + let got = cache.async_cache_get("a").await; + assert_eq!(got, Some(&2u32), "async_cache_get: hit returns Some"); + + // async_cache_get on a missing key returns None. + let missing = cache.async_cache_get("z").await; + assert_eq!(missing, None, "async_cache_get: miss returns None"); + + // async_cache_remove removes and returns the value. + let removed = cache.async_cache_remove("a").await; + assert_eq!( + removed, + Some(2u32), + "async_cache_remove: returns removed value" + ); + + // The entry is gone after removal. + let gone = cache.async_cache_get("a").await; + assert_eq!(gone, None, "async_cache_get: after removal returns None"); + + // async_cache_clear removes all entries. + cache.async_cache_set("x".to_string(), 10u32).await; + cache.async_cache_set("y".to_string(), 20u32).await; + assert_eq!(cache.cache_size(), 2); + cache.async_cache_clear().await; + assert_eq!( + cache.cache_size(), + 0, + "async_cache_clear: empties the cache" + ); + } +} diff --git a/src/macros.rs b/src/macros.rs index 40884a3e..56458b46 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -36,7 +36,7 @@ use cached::macros::cached; /// Use a timed-lru cache with size 1, a TTL of 60s, /// and a `(usize, usize)` cache key # #[cfg(feature = "time_stores")] -#[cached(max_size=1, ttl =60)] +#[cached(max_size=1, ttl_secs=60)] fn keyed(a: usize, b: usize) -> usize { let total = a + b; sleep(Duration::new(total as u64, 0)); @@ -78,7 +78,7 @@ use cached::macros::cached; /// that refreshes the entry TTL on cache hit, /// and a `(String, String)` cache key # #[cfg(feature = "time_stores")] -#[cached(ttl =60, refresh =true)] +#[cached(ttl_secs=60, refresh=true)] fn keyed(a: String, b: String) -> usize { let size = a.len() + b.len(); sleep(Duration::new(size as u64, 0)); @@ -246,12 +246,12 @@ use cached::time::Duration; /// Only cache the initial function call. /// Function will be re-executed after the cache -/// expires (according to `ttl` seconds). +/// expires (according to `ttl_secs`). /// When no (or expired) cache, concurrent calls /// will synchronize (`sync_writes`) so the function /// is only executed once. # #[cfg(feature = "time_stores")] -#[once(ttl =10, sync_writes = true)] +#[once(ttl_secs=10, sync_writes = true)] fn keyed(a: String) -> Option { if a == "a" { Some(a.len()) @@ -272,7 +272,7 @@ use cached::macros::cached; /// Use a timed cache with a TTL of 60s. /// Run a background thread to continuously refresh a specific key. # #[cfg(feature = "time_stores")] -#[cached(ttl = 60, key = "String", convert = r#"{ String::from(a) }"#)] +#[cached(ttl_secs = 60, key = "String", convert = r#"{ String::from(a) }"#)] fn keyed(a: &str) -> usize { a.len() } @@ -321,7 +321,7 @@ use std::thread::sleep; use cached::time::Duration; use cached::macros::cached; -/// Run a background thread to continuously refresh every key of a cache +/// Run a background thread to continuously refresh a specific cache key #[cached(key = "String", convert = r#"{ String::from(a) }"#)] fn keyed(a: &str) -> usize { a.len() @@ -330,14 +330,9 @@ pub fn main() { let _handler = std::thread::spawn(|| { loop { sleep(Duration::from_secs(60)); - let keys: Vec = { - // note the cache keys are a tuple of all function arguments, unless it's one value - KEYED.read().store().keys().map(|k| k.clone()).collect() - }; - for k in &keys { - // this method is generated by the `cached` macro - keyed_prime_cache(k); - } + // keyed_prime_cache bypasses the cache and forces a fresh computation + // for the given key. + keyed_prime_cache("hello"); } }); // handler.join().unwrap(); diff --git a/src/stores/expiring.rs b/src/stores/expiring.rs index 1e766974..b0385106 100644 --- a/src/stores/expiring.rs +++ b/src/stores/expiring.rs @@ -1,6 +1,6 @@ -use super::{CacheEvict, Cached, Expires, UnboundCache}; +use super::{CacheEvict, Cached, DefaultHashBuilder, Expires, UnboundCache}; use crate::{CachedIter, CachedPeek, CloneCached}; -use std::hash::Hash; +use std::hash::{BuildHasher, Hash}; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; @@ -18,6 +18,11 @@ use {super::CachedAsync, std::collections::hash_map::Entry, std::future::Future} /// When using the `#[cached]` proc macro, `expires = true` automatically selects this store /// (or `ExpiringLruCache` when `size` is also specified). /// +/// **`len` / `iter` / `evict` contract**: `len()` returns the raw stored entry count +/// and may include expired-but-not-yet-swept entries. `iter()` omits expired entries +/// from the view but does not remove them. Call `evict()` (via [`CacheEvict`](crate::CacheEvict)) +/// to physically remove expired entries, reclaim memory, and obtain an accurate live count. +/// /// ## Memory note /// /// `ExpiringCache` is **unbounded** and only removes expired entries when the same key is @@ -27,7 +32,7 @@ use {super::CachedAsync, std::collections::hash_map::Entry, std::future::Future} /// with a `size` bound to cap memory usage automatically. /// /// ```rust -/// use cached::{Cached, Expires, ExpiringCache}; +/// use cached::{CachedExt, Expires, ExpiringCache}; /// /// struct Token { /// #[allow(dead_code)] @@ -38,23 +43,23 @@ use {super::CachedAsync, std::collections::hash_map::Entry, std::future::Future} /// fn is_expired(&self) -> bool { self.expired } /// } /// -/// let mut cache: ExpiringCache = ExpiringCache::builder().build().unwrap(); -/// cache.cache_set(1, Token { value: "live".into(), expired: false }); -/// assert!(cache.cache_get(&1).is_some()); -/// cache.cache_set(2, Token { value: "stale".into(), expired: true }); -/// assert!(cache.cache_get(&2).is_none()); // expired → not returned +/// let mut cache: ExpiringCache = ExpiringCache::new(); +/// cache.set(1, Token { value: "live".into(), expired: false }); +/// assert!(cache.get(&1).is_some()); +/// cache.set(2, Token { value: "stale".into(), expired: true }); +/// assert!(cache.get(&2).is_none()); // expired -> not returned /// ``` /// /// Note: This cache is in-memory only. -pub struct ExpiringCache { - pub(super) store: UnboundCache, +pub struct ExpiringCache { + pub(super) store: UnboundCache, pub(super) hits: AtomicU64, pub(super) misses: AtomicU64, pub(super) evictions: AtomicU64, pub(super) on_evict: Option>, } -impl std::fmt::Debug for ExpiringCache { +impl std::fmt::Debug for ExpiringCache { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ExpiringCache") .field("hits", &self.hits.load(Ordering::Relaxed)) @@ -65,10 +70,33 @@ impl std::fmt::Debug for ExpiringCache { } } -impl Clone for ExpiringCache +/// Two `ExpiringCache` values are equal when their stored entries are equal. +/// Metrics (hits, misses, evictions) and the `on_evict` callback are not +/// part of the comparison. +impl PartialEq for ExpiringCache +where + K: Hash + Eq, + V: Expires + PartialEq, + S: BuildHasher, +{ + fn eq(&self, other: &Self) -> bool { + self.store == other.store + } +} + +impl Eq for ExpiringCache +where + K: Hash + Eq, + V: Expires + Eq, + S: BuildHasher, +{ +} + +impl Clone for ExpiringCache where K: Clone + Hash + Eq, V: Expires + Clone, + S: Clone, { fn clone(&self) -> Self { Self { @@ -84,25 +112,27 @@ where /// Builder for [`ExpiringCache`]. /// /// Note: there is intentionally **no `.ttl()` setter**. An `ExpiringCache` has no global -/// expiry duration — each value decides when it is expired via the [`Expires`] trait. For a +/// expiry duration -- each value decides when it is expired via the [`Expires`] trait. For a /// single global TTL applied to every entry, use [`TtlCache`](crate::stores::TtlCache) or /// [`LruTtlCache`](crate::stores::LruTtlCache) instead. #[doc(alias = "ttl")] -pub struct ExpiringCacheBuilder { +pub struct ExpiringCacheBuilder { capacity: Option, on_evict: Option>, + hasher: S, } -impl Default for ExpiringCacheBuilder { +impl Default for ExpiringCacheBuilder { fn default() -> Self { Self { capacity: None, on_evict: None, + hasher: super::new_default_hash_builder(), } } } -impl ExpiringCacheBuilder { +impl ExpiringCacheBuilder { /// Set the initial allocation capacity (optional). #[must_use] pub fn capacity(mut self, capacity: usize) -> Self { @@ -113,7 +143,9 @@ impl ExpiringCacheBuilder { /// Set a callback to be invoked when an entry is removed from the cache. /// /// The callback fires when an expired value is encountered during `cache_get`, - /// `cache_get_mut`, `cache_get_or_set_with`, `cache_try_get_or_set_with`, + /// `cache_get_mut`, `cache_get_or_set_with_mut`, `cache_try_get_or_set_with_mut` + /// (the primary implementations), `cache_get_or_set_with`, `cache_try_get_or_set_with` + /// (default-impl wrappers that delegate to the `_mut` variants), /// their async equivalents, an explicit `evict()` sweep, or an explicit /// `cache_remove` (including when the removed entry was already expired). /// It does **not** fire on `cache_clear` or `cache_reset` (consistent with @@ -127,6 +159,37 @@ impl ExpiringCacheBuilder { self } + /// Switch to a custom hash builder `S2`, returning a builder parameterized on `S2`. + /// + /// The hasher is used to hash keys in the internal `UnboundCache`. Calling this method + /// changes the builder's type parameter so `build()` returns an `ExpiringCache`. + /// + /// # Example + /// + /// ```rust + /// use cached::{Cached, Expires, ExpiringCache}; + /// use std::collections::hash_map::RandomState; + /// + /// struct Val(bool); + /// impl Expires for Val { fn is_expired(&self) -> bool { self.0 } } + /// + /// let mut cache = ExpiringCache::::builder() + /// .hasher(RandomState::new()) + /// .build() + /// .unwrap(); + /// cache.cache_set(1, Val(false)); + /// assert!(cache.cache_get(&1).is_some()); + /// ``` + #[doc(alias = "with_hasher")] + #[must_use] + pub fn hasher(self, hasher: S2) -> ExpiringCacheBuilder { + ExpiringCacheBuilder { + capacity: self.capacity, + on_evict: self.on_evict, + hasher, + } + } + /// Build the cache. /// /// `ExpiringCache` has no required fields and this call never fails. @@ -134,16 +197,21 @@ impl ExpiringCacheBuilder { /// # Errors /// /// This method currently never returns an error. - pub fn build(self) -> Result, super::BuildError> + pub fn build(self) -> Result, super::BuildError> where K: Hash + Eq, + S: BuildHasher, { let store = match self.capacity { Some(cap) => UnboundCache::builder() .capacity(cap) + .hasher(self.hasher) + .build() + .expect("infallible"), + None => UnboundCache::builder() + .hasher(self.hasher) .build() .expect("infallible"), - None => UnboundCache::builder().build().expect("infallible"), }; Ok(ExpiringCache { store, @@ -156,17 +224,31 @@ impl ExpiringCacheBuilder { } impl ExpiringCache { + /// Construct a ready-to-use [`ExpiringCache`] with default configuration. + /// + /// `ExpiringCache` has no required configuration, so this never fails. For + /// optional settings (initial capacity, `on_evict`) use [`builder`](Self::builder). + #[must_use] + pub fn new() -> Self { + Self::builder() + .build() + .expect("ExpiringCache default build is infallible") + } + /// Return a builder for constructing an [`ExpiringCache`]. #[must_use] pub fn builder() -> ExpiringCacheBuilder { ExpiringCacheBuilder::default() } +} +impl ExpiringCache { /// Evict all expired entries from the cache. /// /// Returns the number of entries removed. Fires the `on_evict` callback for each /// removed entry. Use this periodically for high-cardinality workloads to reclaim /// memory from entries that expire but are never re-accessed. + #[must_use] pub fn evict(&mut self) -> usize { let on_evict = &self.on_evict; let evictions = &self.evictions; @@ -210,13 +292,15 @@ impl ExpiringCache { } } -impl Default for ExpiringCache { +impl Default for ExpiringCache { fn default() -> Self { Self::builder().build().expect("infallible") } } -impl Cached for ExpiringCache { +impl Cached for ExpiringCache { + type Error = std::convert::Infallible; + fn cache_get(&mut self, k: &Q) -> Option<&V> where K: std::borrow::Borrow, @@ -277,7 +361,7 @@ impl Cached for ExpiringCache { } } - fn cache_get_or_set_with V>(&mut self, k: K, f: F) -> &mut V { + fn cache_get_or_set_with_mut V>(&mut self, k: K, f: F) -> &mut V { match self.store.store.entry(k) { std::collections::hash_map::Entry::Occupied(mut occupied) => { if !occupied.get().is_expired() { @@ -300,7 +384,7 @@ impl Cached for ExpiringCache { } } - fn cache_try_get_or_set_with Result, E>( + fn cache_try_get_or_set_with_mut Result, E>( &mut self, k: K, f: F, @@ -394,7 +478,7 @@ impl Cached for ExpiringCache { } } -impl CachedIter for ExpiringCache { +impl CachedIter for ExpiringCache { fn iter<'a>(&'a self) -> impl Iterator + 'a where K: 'a, @@ -407,7 +491,7 @@ impl CachedIter for ExpiringCache { } } -impl CachedPeek for ExpiringCache { +impl CachedPeek for ExpiringCache { fn cache_peek(&self, key: &Q) -> Option<&V> where K: std::borrow::Borrow, @@ -424,12 +508,13 @@ impl CachedPeek for ExpiringCache { } #[cfg(feature = "async_core")] -impl CachedAsync for ExpiringCache +impl CachedAsync for ExpiringCache where K: Hash + Eq + Send, V: Expires + Send, + S: BuildHasher + Send, { - fn async_get_or_set_with<'a, F, Fut>( + fn async_cache_get_or_set_with_mut<'a, F, Fut>( &'a mut self, k: K, f: F, @@ -464,7 +549,7 @@ where } } - fn async_try_get_or_set_with<'a, F, Fut, E>( + fn async_cache_try_get_or_set_with_mut<'a, F, Fut, E>( &'a mut self, k: K, f: F, @@ -503,7 +588,9 @@ where } } -impl CloneCached for ExpiringCache { +impl CloneCached + for ExpiringCache +{ // Unlike `cache_get`, this intentionally leaves an expired entry in the map so the // `result_fallback` path can clone and return it as a stale-but-present value on `Err`. // The entry remains visible via `cache_size()` and `CachedIter` until the next @@ -527,9 +614,28 @@ impl CloneCached for ExpiringCache (None, false) } } + + /// Peek at the entry (including expired entries) without any read side effects. + /// + /// Returns `(Some(v), true)` for an expired entry, `(Some(v), false)` for a live + /// entry, and `(None, false)` when the key is absent. Does not update hit/miss + /// counters or remove the entry. + fn cache_peek_with_expiry_status(&self, k: &Q) -> (Option, bool) + where + K: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized, + V: Clone, + { + if let Some(value) = self.store.store.get(k) { + let expired = value.is_expired(); + (Some(value.clone()), expired) + } else { + (None, false) + } + } } -impl CacheEvict for ExpiringCache { +impl CacheEvict for ExpiringCache { fn evict(&mut self) -> usize { ExpiringCache::evict(self) } @@ -538,7 +644,7 @@ impl CacheEvict for ExpiringCache { #[cfg(test)] mod tests { use super::*; - use crate::Cached; + use crate::{Cached, CachedExt}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct ExpiredU8(pub u8); @@ -549,6 +655,16 @@ mod tests { } } + #[test] + fn new_returns_ready_cache() { + let mut c: ExpiringCache = ExpiringCache::new(); + assert_eq!(c.set(1, ExpiredU8(2)), None); + assert_eq!(c.get(&1), Some(&ExpiredU8(2))); + // Expired values are not returned. + c.set(2, ExpiredU8(15)); + assert_eq!(c.get(&2), None); + } + #[test] fn expiring_cache_get_miss() { let mut c: ExpiringCache = ExpiringCache::builder().build().unwrap(); @@ -705,7 +821,7 @@ mod tests { fn expiring_cache_try_get_or_set_with_err_keeps_expired() { let mut c: ExpiringCache = ExpiringCache::builder().build().unwrap(); c.set(1, ExpiredU8(15)); // expired - let result: Result<&mut ExpiredU8, &str> = c.cache_try_get_or_set_with(1, || Err("fail")); + let result: Result<&ExpiredU8, &str> = c.cache_try_get_or_set_with(1, || Err("fail")); assert!(result.is_err()); assert_eq!(c.cache_size(), 1, "expired entry must remain after Err"); assert_eq!(c.cache_evictions(), Some(0)); @@ -725,8 +841,7 @@ mod tests { .build() .unwrap(); c.set(1, ExpiredU8(15)); // expired - let result: Result<&mut ExpiredU8, &str> = - c.cache_try_get_or_set_with(1, || Ok(ExpiredU8(3))); + let result: Result<&ExpiredU8, &str> = c.cache_try_get_or_set_with(1, || Ok(ExpiredU8(3))); assert_eq!(*result.unwrap(), ExpiredU8(3)); assert_eq!(c.cache_evictions(), Some(1)); assert_eq!(c.cache_misses(), Some(1)); @@ -965,14 +1080,14 @@ mod tests { .unwrap(); c.cache_set(1u8, ExpiredU8(20)); // expired - c.cache_remove_entry(&1u8); + let _ = c.cache_remove_entry(&1u8); assert_eq!( count.load(Ordering::Relaxed), 1, "on_evict fires for expired entries" ); - c.cache_remove_entry(&99u8); + let _ = c.cache_remove_entry(&99u8); assert_eq!(count.load(Ordering::Relaxed), 1, "no fire for absent key"); } @@ -987,12 +1102,68 @@ mod tests { let mut c: ExpiringCache = ExpiringCache::builder().build().unwrap(); c.cache_set(1u8, ExpiredU8(20)); // expired: value > 10 let before = c.cache_evictions().expect("evictions are always tracked"); - c.cache_remove_entry(&1u8); // expired but present — must increment - c.cache_remove_entry(&99u8); // absent — must not increment + let _ = c.cache_remove_entry(&1u8); // expired but present — must increment + let _ = c.cache_remove_entry(&99u8); // absent — must not increment assert_eq!( c.cache_evictions().expect("evictions are always tracked") - before, 1, "cache_remove_entry must increment evictions for present key only" ); } + + #[test] + fn eq_same_entries_compare_equal() { + let mut a: ExpiringCache = ExpiringCache::builder().build().unwrap(); + let mut b: ExpiringCache = ExpiringCache::builder().build().unwrap(); + a.cache_set(1, ExpiredU8(5)); + a.cache_set(2, ExpiredU8(6)); + // Insert in a different order: HashMap-backed equality is order-independent. + b.cache_set(2, ExpiredU8(6)); + b.cache_set(1, ExpiredU8(5)); + assert_eq!( + a, b, + "caches with the same stored entries must compare equal" + ); + } + + #[test] + fn eq_ignores_metrics_and_on_evict() { + // Equality is over stored entries only: differing hit/miss/eviction + // counters and an `on_evict` callback on one side must not break it. + let mut a: ExpiringCache = ExpiringCache::builder().build().unwrap(); + let mut b: ExpiringCache = ExpiringCache::builder() + .on_evict(|_k: &u8, _v: &ExpiredU8| {}) + .build() + .unwrap(); + a.cache_set(1, ExpiredU8(5)); + b.cache_set(1, ExpiredU8(5)); + // Drive `a`'s metrics away from `b`'s. + a.get(&1); + a.get(&99); + assert_ne!(a.cache_hits(), b.cache_hits()); + assert_eq!( + a, b, + "metrics and on_evict must not participate in equality" + ); + } + + #[test] + fn ne_differing_entries() { + let mut a: ExpiringCache = ExpiringCache::builder().build().unwrap(); + let mut b: ExpiringCache = ExpiringCache::builder().build().unwrap(); + a.cache_set(1, ExpiredU8(5)); + b.cache_set(1, ExpiredU8(6)); // same key, different value + assert_ne!(a, b, "differing values must compare unequal"); + + let mut c: ExpiringCache = ExpiringCache::builder().build().unwrap(); + c.cache_set(1, ExpiredU8(5)); + c.cache_set(2, ExpiredU8(5)); // extra key + assert_ne!(a, c, "differing key sets must compare unequal"); + + // An empty cache differs from a populated one and equals another empty one. + let empty1: ExpiringCache = ExpiringCache::builder().build().unwrap(); + let empty2: ExpiringCache = ExpiringCache::builder().build().unwrap(); + assert_eq!(empty1, empty2); + assert_ne!(empty1, a); + } } diff --git a/src/stores/expiring_lru.rs b/src/stores/expiring_lru.rs index 1f426a72..2fbb7e1a 100644 --- a/src/stores/expiring_lru.rs +++ b/src/stores/expiring_lru.rs @@ -1,6 +1,6 @@ -use super::{CacheEvict, Cached, LruCache}; +use super::{CacheEvict, Cached, DefaultHashBuilder, LruCache}; use crate::{CachedIter, CachedPeek, CloneCached}; -use std::hash::Hash; +use std::hash::{BuildHasher, Hash}; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; @@ -12,7 +12,7 @@ use {super::CachedAsync, std::future::Future}; /// and are removed on access: /// /// ```rust -/// use cached::{Cached, Expires, ExpiringCache, ExpiringLruCache}; +/// use cached::{CachedExt, Expires, ExpiringCache, ExpiringLruCache}; /// /// struct Token { /// #[allow(dead_code)] @@ -26,20 +26,36 @@ use {super::CachedAsync, std::future::Future}; /// } /// /// // Unbounded store (default for `#[cached(expires = true)]`) -/// let mut cache: ExpiringCache = ExpiringCache::builder().build().unwrap(); -/// cache.cache_set(1, Token { value: "live".into(), expired: false }); -/// assert!(cache.cache_get(&1).is_some()); -/// cache.cache_set(2, Token { value: "stale".into(), expired: true }); -/// assert!(cache.cache_get(&2).is_none()); // expired -> not returned +/// let mut cache: ExpiringCache = ExpiringCache::new(); +/// cache.set(1, Token { value: "live".into(), expired: false }); +/// assert!(cache.get(&1).is_some()); +/// cache.set(2, Token { value: "stale".into(), expired: true }); +/// assert!(cache.get(&2).is_none()); // expired -> not returned /// /// // LRU-bounded store (`#[cached(expires = true, max_size = N)]`) -/// let mut lru: ExpiringLruCache = ExpiringLruCache::builder().max_size(8).build().unwrap(); -/// lru.cache_set(3, Token { value: "live".into(), expired: false }); -/// assert!(lru.cache_get(&3).is_some()); +/// let mut lru: ExpiringLruCache = ExpiringLruCache::new(8); +/// lru.set(3, Token { value: "live".into(), expired: false }); +/// assert!(lru.get(&3).is_some()); /// ``` pub trait Expires { /// `is_expired` returns whether the value has expired. + /// + /// This is the authoritative liveness check: callers must use `is_expired` to + /// decide whether a cached value may be returned, not `expires_at`. fn is_expired(&self) -> bool; + + /// Returns the [`std::time::Instant`] at which this value expires, or `None` if the + /// expiry instant is unknown or not tracked by this type. + /// + /// The default implementation returns `None`. Override this in types that record a + /// concrete deadline to enable observability (logging, metrics) and to allow callers + /// to extend or compare deadlines without re-computing them. + /// + /// `is_expired()` remains the authoritative liveness check; `expires_at` is advisory + /// and must not be used as a substitute for `is_expired`. + fn expires_at(&self) -> Option { + None + } } /// LRU-bounded cache with per-value expiry. @@ -54,19 +70,24 @@ pub trait Expires { /// /// Note: This cache is in-memory only. /// +/// **`len` / `iter` / `evict` contract**: `len()` returns the raw stored entry count +/// and may include expired-but-not-yet-swept entries. `iter()` omits expired entries +/// from the view but does not remove them. Call `evict()` (via [`CacheEvict`](crate::CacheEvict)) +/// to physically remove expired entries and obtain an accurate live count. +/// /// Note: once specialization is stable (`#[feature(specialization)]`), the expiry-checking /// behavior here could be folded into [`LruCache`] via a specialized `Cached` impl /// for `V: Expires`, eliminating this separate type. Until then, the two must remain /// distinct because overlapping blanket impls are not allowed on stable Rust. -pub struct ExpiringLruCache { - pub(super) store: LruCache, +pub struct ExpiringLruCache { + pub(super) store: LruCache, pub(super) hits: AtomicU64, pub(super) misses: AtomicU64, pub(super) evictions: AtomicU64, pub(super) on_evict: Option>, } -impl std::fmt::Debug for ExpiringLruCache { +impl std::fmt::Debug for ExpiringLruCache { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("ExpiringLruCache") .field("hits", &self.hits.load(Ordering::Relaxed)) @@ -77,10 +98,34 @@ impl std::fmt::Debug for ExpiringLruCache { } } -impl Clone for ExpiringLruCache +/// Two `ExpiringLruCache` values are equal when their stored entries are equal +/// (same keys, same values). Equality is membership-based: LRU recency order is +/// not compared. Metrics (hits, misses, evictions) and the `on_evict` callback +/// are not part of the comparison. +impl PartialEq for ExpiringLruCache +where + K: Clone + Hash + Eq, + V: Expires + PartialEq, + S: BuildHasher, +{ + fn eq(&self, other: &Self) -> bool { + self.store == other.store + } +} + +impl Eq for ExpiringLruCache +where + K: Clone + Hash + Eq, + V: Expires + Eq, + S: BuildHasher, +{ +} + +impl Clone for ExpiringLruCache where K: Clone + Hash + Eq, V: Expires + Clone, + S: Clone, { fn clone(&self) -> Self { Self { @@ -96,16 +141,27 @@ where /// Builder for [`ExpiringLruCache`]. /// /// Note: there is intentionally **no `.ttl()` setter**. An `ExpiringLruCache` has no global -/// expiry duration — each value decides when it is expired via the [`Expires`] trait, while +/// expiry duration -- each value decides when it is expired via the [`Expires`] trait, while /// `max_size` bounds the entry count via LRU. For a single global TTL applied to every entry, /// use [`LruTtlCache`](crate::stores::LruTtlCache) instead. #[doc(alias = "ttl")] -pub struct ExpiringLruCacheBuilder { +pub struct ExpiringLruCacheBuilder { size: Option, on_evict: Option>, + hasher: S, +} + +impl Default for ExpiringLruCacheBuilder { + fn default() -> Self { + Self { + size: None, + on_evict: None, + hasher: super::new_default_hash_builder(), + } + } } -impl ExpiringLruCacheBuilder { +impl ExpiringLruCacheBuilder { /// Set the maximum number of entries. #[doc(alias = "size")] #[doc(alias = "capacity")] @@ -126,24 +182,60 @@ impl ExpiringLruCacheBuilder { self } + /// Switch to a custom hash builder `S2`, returning a builder parameterized on `S2`. + /// + /// The hasher is used to hash keys in the internal `LruCache`. Calling this method + /// changes the builder's type parameter so `build()` returns an `ExpiringLruCache`. + /// + /// # Example + /// + /// ```rust + /// use cached::{Cached, Expires, ExpiringLruCache}; + /// use std::collections::hash_map::RandomState; + /// + /// struct Val(bool); + /// impl Expires for Val { fn is_expired(&self) -> bool { self.0 } } + /// + /// let mut cache = ExpiringLruCache::::builder() + /// .max_size(10) + /// .hasher(RandomState::new()) + /// .build() + /// .unwrap(); + /// cache.cache_set(1, Val(false)); + /// assert!(cache.cache_get(&1).is_some()); + /// ``` + #[doc(alias = "with_hasher")] + #[must_use] + pub fn hasher(self, hasher: S2) -> ExpiringLruCacheBuilder { + ExpiringLruCacheBuilder { + size: self.size, + on_evict: self.on_evict, + hasher, + } + } + /// Build the cache. /// /// # Errors /// /// Returns [`BuildError::MissingRequired`](super::BuildError) if `max_size` was not set, /// or [`BuildError::InvalidValue`](super::BuildError) if `max_size` is `0`. - pub fn build(self) -> Result, super::BuildError> + pub fn build(self) -> Result, super::BuildError> where K: Hash + Eq + Clone, + S: BuildHasher, { let size = self .size .ok_or(super::BuildError::MissingRequired("max_size"))?; - let mut store = LruCache::builder().max_size(size).build()?; + let mut store = LruCache::builder() + .max_size(size) + .hasher(self.hasher) + .build()?; store.disable_hit_miss_tracking(); // Two separate callbacks for two separate eviction causes: - // cache.on_evict — fires when ExpiringLruCache itself removes an expired entry - // cache.store.on_evict — fires when LruCache::check_capacity evicts for capacity + // cache.on_evict -- fires when ExpiringLruCache itself removes an expired entry + // cache.store.on_evict -- fires when LruCache::check_capacity evicts for capacity // Both must be registered independently so neither path is silently skipped. let mut cache = ExpiringLruCache { store, @@ -160,15 +252,31 @@ impl ExpiringLruCacheBuilder { } impl ExpiringLruCache { + /// Construct a ready-to-use [`ExpiringLruCache`] holding up to `max_size` entries. + /// + /// For optional settings (`on_evict`) use [`builder`](Self::builder). + /// + /// # Panics + /// + /// Panics if `max_size` is `0`, or if pre-allocating the backing store for + /// `max_size` entries fails (e.g. `usize::MAX`). Use [`builder`](Self::builder) + /// with [`build`](ExpiringLruCacheBuilder::build) to handle those cases without panicking. + #[must_use] + pub fn new(max_size: usize) -> Self { + Self::builder() + .max_size(max_size) + .build() + .expect("ExpiringLruCache::new requires a non-zero max_size with a valid allocation") + } + /// Return a builder for constructing an [`ExpiringLruCache`]. #[must_use] pub fn builder() -> ExpiringLruCacheBuilder { - ExpiringLruCacheBuilder { - size: None, - on_evict: None, - } + ExpiringLruCacheBuilder::default() } +} +impl ExpiringLruCache { /// Returns the maximum number of entries this cache will hold before evicting. /// /// This is the bound set via [`ExpiringLruCacheBuilder::max_size`], @@ -180,13 +288,38 @@ impl ExpiringLruCache { self.store.capacity() } - /// Returns a reference to the inner [`LruCache`]. - #[must_use] - pub fn store(&self) -> &LruCache { - &self.store + /// Change the maximum number of entries, returning the previous capacity; + /// shrinking below the current entry count immediately evicts least-recently-used + /// entries. + /// + /// Eviction on shrink fires `on_evict` and counts evictions until the cache + /// fits. Growing the capacity does not pre-allocate; the backing stores grow + /// on demand as entries are inserted. + /// + /// This is useful for sizing a `#[cached(create = "{ ... }")]` cache from a value + /// loaded at startup (e.g. config), then adjusting it later as load changes. + /// + /// # Panics + /// + /// Panics if `max_size` is 0. Use [`try_set_max_size`](ExpiringLruCache::try_set_max_size) + /// to validate first and avoid the panic. + pub fn set_max_size(&mut self, max_size: usize) -> usize { + self.store.set_max_size(max_size) + } + + /// Fallible counterpart of [`set_max_size`](ExpiringLruCache::set_max_size): validates + /// that `max_size` is non-zero and then delegates to `set_max_size`. + /// Returns the previous capacity on success. + /// + /// # Errors + /// + /// Returns [`SetMaxSizeError::ZeroSize`](super::SetMaxSizeError) if `max_size` is 0. + pub fn try_set_max_size(&mut self, max_size: usize) -> Result { + self.store.try_set_max_size(max_size) } /// Evict expired values from the cache. + #[must_use] pub fn evict(&mut self) -> usize { let on_evict = &self.on_evict; let evictions = &self.evictions; @@ -237,7 +370,9 @@ impl ExpiringLruCache { } // https://docs.rs/cached/latest/cached/trait.Cached.html -impl Cached for ExpiringLruCache { +impl Cached for ExpiringLruCache { + type Error = std::convert::Infallible; + fn cache_get(&mut self, k: &Q) -> Option<&V> where K: std::borrow::Borrow, @@ -294,7 +429,7 @@ impl Cached for ExpiringLruCache { } } - fn cache_get_or_set_with V>(&mut self, k: K, f: F) -> &mut V { + fn cache_get_or_set_with_mut V>(&mut self, k: K, f: F) -> &mut V { let key_for_evict = k.clone(); // get_or_set_with_if will set the value in the cache if an existing // value is not valid, which, in our case, is if the value has expired. @@ -313,7 +448,7 @@ impl Cached for ExpiringLruCache { } v } - fn cache_try_get_or_set_with Result, E>( + fn cache_try_get_or_set_with_mut Result, E>( &mut self, key: K, f: F, @@ -336,7 +471,7 @@ impl Cached for ExpiringLruCache { Ok(v) } fn cache_set(&mut self, k: K, v: V) -> Option { - self.store.set(k, v) + self.store.cache_set(k, v) } fn cache_remove(&mut self, k: &Q) -> Option where @@ -364,17 +499,12 @@ impl Cached for ExpiringLruCache { } fn cache_clear(&mut self) { - self.store.clear(); + self.store.cache_clear(); } fn cache_reset(&mut self) { // Entries are dropped in-place; `on_evict` is NOT called for cleared entries. - let on_evict = self.store.on_evict.clone(); - let capacity = self.store.capacity; - self.store = LruCache::builder() - .max_size(capacity) - .build() - .expect("LruCache build failed"); - self.store.on_evict = on_evict; + // Delegate to the inner LruCache's reset which preserves the hash builder. + self.store.cache_reset(); self.cache_reset_metrics(); } fn cache_size(&self) -> usize { @@ -402,7 +532,9 @@ impl Cached for ExpiringLruCache { } } -impl CachedIter for ExpiringLruCache { +impl CachedIter + for ExpiringLruCache +{ fn iter<'a>(&'a self) -> impl Iterator + 'a where K: 'a, @@ -414,7 +546,9 @@ impl CachedIter for ExpiringLruCache CachedPeek for ExpiringLruCache { +impl CachedPeek + for ExpiringLruCache +{ fn cache_peek(&self, key: &Q) -> Option<&V> where K: std::borrow::Borrow, @@ -431,12 +565,13 @@ impl CachedPeek for ExpiringLruCache CachedAsync for ExpiringLruCache +impl CachedAsync for ExpiringLruCache where K: Hash + Eq + Clone + Send, V: Expires + Send, + S: BuildHasher + Send, { - fn async_get_or_set_with<'a, F, Fut>( + fn async_cache_get_or_set_with_mut<'a, F, Fut>( &'a mut self, k: K, f: F, @@ -468,7 +603,7 @@ where } } - fn async_try_get_or_set_with<'a, F, Fut, E>( + fn async_cache_try_get_or_set_with_mut<'a, F, Fut, E>( &'a mut self, k: K, f: F, @@ -502,7 +637,9 @@ where } } -impl CloneCached for ExpiringLruCache { +impl CloneCached + for ExpiringLruCache +{ fn cache_get_with_expiry_status(&mut self, k: &Q) -> (Option, bool) where K: std::borrow::Borrow, @@ -528,9 +665,31 @@ impl CloneCached for ExpiringLru (None, false) } } + + /// Peek at the entry (including expired entries) without any read side effects. + /// + /// Returns `(Some(v), true)` for an expired entry, `(Some(v), false)` for a live + /// entry, and `(None, false)` when the key is absent. Does not update hit/miss + /// counters and does not promote in LRU order. + fn cache_peek_with_expiry_status(&self, k: &Q) -> (Option, bool) + where + K: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized, + V: Clone, + { + // Use the inner LruCache's `cache_peek` to avoid LRU promotion. + if let Some(value) = self.store.cache_peek(k) { + let expired = value.is_expired(); + (Some(value.clone()), expired) + } else { + (None, false) + } + } } -impl CacheEvict for ExpiringLruCache { +impl CacheEvict + for ExpiringLruCache +{ fn evict(&mut self) -> usize { ExpiringLruCache::evict(self) } @@ -540,7 +699,7 @@ impl CacheEvict for ExpiringLruCach /// Expiring Value Cache tests mod tests { use super::*; - use crate::Cached; + use crate::{Cached, CachedExt}; use std::sync::atomic::{AtomicU64, Ordering}; type ExpiredU8 = u8; @@ -551,6 +710,24 @@ mod tests { } } + #[test] + fn new_returns_ready_cache_respecting_max_size() { + let mut c: ExpiringLruCache = ExpiringLruCache::new(2); + assert_eq!(c.capacity(), 2); + assert_eq!(c.set(1, 5), None); + assert_eq!(c.get(&1), Some(&5)); + c.set(2, 6); + c.set(3, 7); // evicts LRU (1) + assert_eq!(c.cache_size(), 2); + assert_eq!(c.get(&1), None); + } + + #[test] + #[should_panic(expected = "non-zero max_size")] + fn new_zero_max_size_panics() { + let _c: ExpiringLruCache = ExpiringLruCache::new(0); + } + #[test] fn expiring_value_cache_get_miss() { let mut c: ExpiringLruCache = @@ -698,21 +875,15 @@ mod tests { let mut c: ExpiringLruCache = ExpiringLruCache::builder().max_size(3).build().unwrap(); - assert_eq!( - c.cache_try_get_or_set_with(1, || Ok::<_, ()>(1)), - Ok(&mut 1) - ); + assert_eq!(c.cache_try_get_or_set_with(1, || Ok::<_, ()>(1)), Ok(&1)); assert_eq!(c.cache_hits(), Some(0)); assert_eq!(c.cache_misses(), Some(1)); - assert_eq!(c.cache_try_get_or_set_with(1, || Err(())), Ok(&mut 1)); + assert_eq!(c.cache_try_get_or_set_with(1, || Err(())), Ok(&1)); assert_eq!(c.cache_hits(), Some(1)); assert_eq!(c.cache_misses(), Some(1)); - assert_eq!( - c.cache_try_get_or_set_with(2, || Ok::<_, ()>(2)), - Ok(&mut 2) - ); + assert_eq!(c.cache_try_get_or_set_with(2, || Ok::<_, ()>(2)), Ok(&2)); assert_eq!(c.cache_hits(), Some(1)); assert_eq!(c.cache_misses(), Some(2)); } @@ -729,7 +900,7 @@ mod tests { // It should only evict n > 10 assert_eq!(2, c.cache_size()); - c.evict(); + let _ = c.evict(); assert_eq!(1, c.cache_size()); } @@ -969,14 +1140,14 @@ mod tests { .unwrap(); c.cache_set(1u8, 20u8); // expired - c.cache_remove_entry(&1u8); + let _ = c.cache_remove_entry(&1u8); assert_eq!( count.load(Ordering::Relaxed), 1, "on_evict fires for expired entries" ); - c.cache_remove_entry(&99u8); + let _ = c.cache_remove_entry(&99u8); assert_eq!(count.load(Ordering::Relaxed), 1, "no fire for absent key"); } @@ -993,12 +1164,319 @@ mod tests { ExpiringLruCache::builder().max_size(4).build().unwrap(); c.cache_set(1u8, 20u8); // expired: 20 > 10 let before = c.cache_evictions().expect("evictions are always tracked"); - c.cache_remove_entry(&1u8); // expired but present — must increment - c.cache_remove_entry(&99u8); // absent — must not increment + let _ = c.cache_remove_entry(&1u8); // expired but present - must increment + let _ = c.cache_remove_entry(&99u8); // absent - must not increment assert_eq!( c.cache_evictions().expect("evictions are always tracked") - before, 1, "cache_remove_entry must increment evictions for present key only" ); } + + #[test] + fn set_max_size_changes_capacity_and_evicts() { + let mut c: ExpiringLruCache = + ExpiringLruCache::builder().max_size(3).build().unwrap(); + c.cache_set(1, 1); + c.cache_set(2, 2); + c.cache_set(3, 3); + assert_eq!(c.capacity(), 3); + + // Shrink to 2: LRU entry (1) should be evicted. + let prev = c.set_max_size(2); + assert_eq!(prev, 3); + assert_eq!(c.capacity(), 2); + assert_eq!(c.cache_size(), 2); + + // Insert beyond new cap triggers eviction. + c.cache_set(4, 4); + assert_eq!(c.cache_size(), 2); + } + + #[test] + fn set_max_size_shrink_fires_on_evict_and_counts_evictions() { + use std::sync::{Arc, Mutex}; + let evicted_keys: Arc>> = Arc::new(Mutex::new(Vec::new())); + let evicted_keys2 = evicted_keys.clone(); + let mut c: ExpiringLruCache = ExpiringLruCache::builder() + .max_size(4) + .on_evict(move |k: &u8, _v: &ExpiredU8| { + evicted_keys2.lock().unwrap().push(*k); + }) + .build() + .unwrap(); + + // Values 1..=4 are all <= 10, so none are expired. + c.cache_set(1, 1); + c.cache_set(2, 2); + c.cache_set(3, 3); + c.cache_set(4, 4); + // Touch 1 and 2 so 3 and 4 become least-recently-used. + assert_eq!(c.cache_get(&1), Some(&1)); + assert_eq!(c.cache_get(&2), Some(&2)); + + let evictions_before = c.cache_evictions().expect("evictions tracked"); + let prev = c.set_max_size(2); + assert_eq!(prev, 4); + assert_eq!(c.capacity(), 2); + assert_eq!(c.cache_size(), 2); + + // Two entries were dropped; eviction counter must reflect that. + assert_eq!( + c.cache_evictions().expect("evictions tracked") - evictions_before, + 2, + "set_max_size shrink must increment cache_evictions by the number of dropped entries" + ); + + // on_evict must have fired for exactly the two LRU keys (3 and 4). + let mut fired: Vec = evicted_keys.lock().unwrap().clone(); + fired.sort(); + assert_eq!( + fired, + vec![3, 4], + "on_evict must fire for the evicted (least-recently-used) keys" + ); + + // The two most-recently-used entries must survive. + assert_eq!(c.cache_get(&1), Some(&1)); + assert_eq!(c.cache_get(&2), Some(&2)); + assert_eq!(c.cache_get(&3), None); + assert_eq!(c.cache_get(&4), None); + } + + #[test] + fn try_set_max_size_rejects_zero() { + let mut c: ExpiringLruCache = + ExpiringLruCache::builder().max_size(3).build().unwrap(); + assert_eq!( + c.try_set_max_size(0), + Err(super::super::SetMaxSizeError::ZeroSize) + ); + assert_eq!(c.try_set_max_size(5).unwrap(), 3); + } + + #[test] + #[should_panic(expected = "max_size must be greater than zero")] + fn set_max_size_zero_panics() { + let mut c: ExpiringLruCache = + ExpiringLruCache::builder().max_size(3).build().unwrap(); + c.set_max_size(0); + } + + #[test] + fn eq_same_entries_compare_equal() { + let mut a: ExpiringLruCache = + ExpiringLruCache::builder().max_size(4).build().unwrap(); + let mut b: ExpiringLruCache = + ExpiringLruCache::builder().max_size(4).build().unwrap(); + a.cache_set(1, 5); + a.cache_set(2, 6); + // Insert in a different order: inner LruCache equality is membership-based. + b.cache_set(2, 6); + b.cache_set(1, 5); + assert_eq!( + a, b, + "caches with the same stored entries must compare equal" + ); + } + + #[test] + fn eq_ignores_metrics_and_on_evict() { + // Equality is over stored entries only: differing metrics and an + // `on_evict` callback on one side must not break it. + let mut a: ExpiringLruCache = + ExpiringLruCache::builder().max_size(4).build().unwrap(); + let mut b: ExpiringLruCache = ExpiringLruCache::builder() + .max_size(4) + .on_evict(|_k: &u8, _v: &ExpiredU8| {}) + .build() + .unwrap(); + a.cache_set(1, 5); + b.cache_set(1, 5); + // Drive `a`'s metrics away from `b`'s. + a.cache_get(&1); + a.cache_get(&99); + assert_ne!(a.cache_hits(), b.cache_hits()); + assert_eq!( + a, b, + "metrics and on_evict must not participate in equality" + ); + } + + #[test] + fn ne_differing_entries() { + let mut a: ExpiringLruCache = + ExpiringLruCache::builder().max_size(4).build().unwrap(); + let mut b: ExpiringLruCache = + ExpiringLruCache::builder().max_size(4).build().unwrap(); + a.cache_set(1, 5); + b.cache_set(1, 6); // same key, different value + assert_ne!(a, b, "differing values must compare unequal"); + + let mut c: ExpiringLruCache = + ExpiringLruCache::builder().max_size(4).build().unwrap(); + c.cache_set(1, 5); + c.cache_set(2, 5); // extra key + assert_ne!(a, c, "differing key sets must compare unequal"); + + // An empty cache differs from a populated one and equals another empty one. + let empty1: ExpiringLruCache = + ExpiringLruCache::builder().max_size(4).build().unwrap(); + let empty2: ExpiringLruCache = + ExpiringLruCache::builder().max_size(4).build().unwrap(); + assert_eq!(empty1, empty2); + assert_ne!(empty1, a); + } + + // --- expires_at tests --- + + /// A type that overrides `expires_at` to return a concrete deadline. + struct TimedValue { + deadline: std::time::Instant, + } + + impl Expires for TimedValue { + fn is_expired(&self) -> bool { + std::time::Instant::now() >= self.deadline + } + + fn expires_at(&self) -> Option { + Some(self.deadline) + } + } + + #[test] + fn expires_at_default_returns_none() { + // ExpiredU8 does not override expires_at, so the default must return None. + let v: ExpiredU8 = 5; + assert_eq!( + v.expires_at(), + None, + "default expires_at must return None for types that do not track a deadline" + ); + } + + #[test] + fn expires_at_override_returns_some_instant() { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + let v = TimedValue { deadline }; + assert_eq!( + v.expires_at(), + Some(deadline), + "expires_at must return the overridden deadline when the impl provides one" + ); + // Confirm is_expired is not confused: a future deadline is not yet expired. + assert!( + !v.is_expired(), + "a value whose deadline is in the future must not be expired" + ); + } + + /// `is_expired` is the authoritative liveness check; `expires_at` is advisory only. + /// This type deliberately reports a deadline that is already in the past while + /// claiming to be live. A correct cache must consult `is_expired` (live), NOT + /// `expires_at` (past), and therefore keep the entry. + struct LiveDespitePastDeadline { + past: std::time::Instant, + } + + impl Expires for LiveDespitePastDeadline { + fn is_expired(&self) -> bool { + // Authoritative: always live, regardless of the advisory deadline below. + false + } + + fn expires_at(&self) -> Option { + // Advisory: a deadline in the past. Must not be used for liveness. + Some(self.past) + } + } + + #[test] + fn expires_at_past_does_not_override_is_expired_for_value() { + // Sanity at the value level: the two methods disagree on purpose. + let v = LiveDespitePastDeadline { + past: std::time::Instant::now() - std::time::Duration::from_secs(3600), + }; + assert!( + !v.is_expired(), + "is_expired is authoritative and reports the value as live" + ); + let reported = v.expires_at().expect("override returns Some"); + assert!( + reported < std::time::Instant::now(), + "expires_at advisory deadline is in the past" + ); + } + + #[test] + fn cache_keeps_entry_with_past_expires_at_but_live_is_expired() { + // Contract: the cache must decide liveness from is_expired, not expires_at. + // The stored value's expires_at is in the past, but is_expired() == false, + // so the entry must be returned as a live hit and survive in the cache. + let past = std::time::Instant::now() - std::time::Duration::from_secs(3600); + let mut c: ExpiringLruCache = + ExpiringLruCache::builder().max_size(3).build().unwrap(); + c.cache_set(1, LiveDespitePastDeadline { past }); + + // get must treat it as a live hit (is_expired() == false). + assert!( + c.cache_get(&1u8).is_some(), + "entry whose is_expired() is false must be returned even if expires_at is in the past" + ); + assert_eq!(c.cache_hits(), Some(1), "the access must count as a hit"); + assert_eq!( + c.cache_misses(), + Some(0), + "an entry the cache treats as live must not register a miss" + ); + assert_eq!(c.cache_size(), 1, "the live entry must remain in the cache"); + + // evict() must also consult is_expired, not expires_at: nothing is removed. + assert_eq!( + c.evict(), + 0, + "evict must not remove an entry whose is_expired() is false" + ); + assert_eq!(c.cache_size(), 1); + + // peek and iter must likewise keep it. + assert!( + c.cache_peek(&1u8).is_some(), + "peek must surface the live entry" + ); + let keys: Vec = c.iter().map(|(&k, _)| k).collect(); + assert_eq!(keys, vec![1], "iter must include the live entry"); + } + + /// A type that provides ONLY `is_expired`, relying on the trait default for + /// `expires_at`. The fact that this compiles and is usable as a cache value is + /// the contract: adding `expires_at` did not break impls that omit it. + struct OnlyIsExpired(bool); + + impl Expires for OnlyIsExpired { + fn is_expired(&self) -> bool { + self.0 + } + // expires_at intentionally not provided — exercises the default impl. + } + + #[test] + fn impl_with_only_is_expired_compiles_and_defaults_expires_at_to_none() { + let live = OnlyIsExpired(false); + assert!(!live.is_expired()); + assert_eq!( + live.expires_at(), + None, + "an impl omitting expires_at must inherit the None default" + ); + + // And it works as a cache value type end to end. + let mut c: ExpiringLruCache = + ExpiringLruCache::builder().max_size(2).build().unwrap(); + c.cache_set(1, OnlyIsExpired(false)); // live + c.cache_set(2, OnlyIsExpired(true)); // expired + assert!(c.cache_get(&1u8).is_some(), "live entry returned"); + assert!(c.cache_get(&2u8).is_none(), "expired entry not returned"); + } } diff --git a/src/stores/lru.rs b/src/stores/lru.rs index 337748e4..d5e09ad0 100644 --- a/src/stores/lru.rs +++ b/src/stores/lru.rs @@ -1,4 +1,4 @@ -use super::Cached; +use super::{Cached, DefaultHashBuilder}; use crate::lru_list::LRUList; use crate::{CachedIter, CachedPeek}; use hashbrown::HashTable; @@ -7,12 +7,6 @@ use std::cmp::Eq; use std::fmt; use std::hash::{BuildHasher, Hash, Hasher}; -#[cfg(feature = "ahash")] -use ahash::RandomState; - -#[cfg(not(feature = "ahash"))] -use std::collections::hash_map::RandomState; - #[cfg(feature = "async_core")] use {super::CachedAsync, std::future::Future}; @@ -25,10 +19,15 @@ use std::sync::atomic::{AtomicU64, Ordering}; /// to evict the least recently used keys /// /// Note: This cache is in-memory only -pub struct LruCache { +/// +/// The optional type parameter `S` selects the hash builder. It defaults to +/// [`DefaultHashBuilder`] (ahash when the `ahash` feature is enabled, otherwise +/// `std::collections::hash_map::RandomState`). Supply a custom `S` via +/// [`LruCacheBuilder::hasher`] to use a different hasher. +pub struct LruCache { // `store` contains a hash of K -> index of (K, V) tuple in `order` pub(super) store: HashTable, - pub(super) hash_builder: RandomState, + pub(super) hash_builder: S, pub(super) order: LRUList<(K, V)>, pub(super) capacity: usize, pub(super) hits: AtomicU64, @@ -41,10 +40,11 @@ pub struct LruCache { pub(crate) track_hit_miss: bool, } -impl Clone for LruCache +impl Clone for LruCache where K: Clone + Hash + Eq, V: Clone, + S: Clone, { fn clone(&self) -> Self { Self { @@ -61,7 +61,7 @@ where } } -impl fmt::Debug for LruCache { +impl fmt::Debug for LruCache { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("LruCache") .field("capacity", &self.capacity) @@ -73,12 +73,13 @@ impl fmt::Debug for LruCache { } } -impl PartialEq for LruCache +impl PartialEq for LruCache where K: Eq + Hash + Clone, V: PartialEq, + S: BuildHasher, { - fn eq(&self, other: &LruCache) -> bool { + fn eq(&self, other: &LruCache) -> bool { self.store.len() == other.store.len() && { self.order .iter() @@ -90,21 +91,33 @@ where } } -impl Eq for LruCache +impl Eq for LruCache where K: Eq + Hash + Clone, V: PartialEq, + S: BuildHasher, { } /// Builder for [`LruCache`]. -pub struct LruCacheBuilder { +pub struct LruCacheBuilder { size: Option, on_evict: Option>, + hasher: S, +} + +impl Default for LruCacheBuilder { + fn default() -> Self { + Self { + size: None, + on_evict: None, + hasher: super::new_default_hash_builder(), + } + } } -impl LruCacheBuilder { - /// Set the maximum number of entries. Required — `build` returns `Err` if not set. +impl LruCacheBuilder { + /// Set the maximum number of entries. Required -- `build` returns `Err` if not set. #[doc(alias = "size")] #[doc(alias = "capacity")] #[must_use] @@ -124,16 +137,46 @@ impl LruCacheBuilder { self } + /// Switch to a custom hash builder `S2`, returning a builder parameterized on `S2`. + /// + /// The hasher is used to hash keys in the internal `HashTable`. Calling this method + /// changes the builder's type parameter so `build()` returns an `LruCache`. + /// + /// # Example + /// + /// ```rust + /// use cached::{Cached, LruCache}; + /// use std::collections::hash_map::RandomState; + /// + /// let mut cache = LruCache::::builder() + /// .max_size(10) + /// .hasher(RandomState::new()) + /// .build() + /// .unwrap(); + /// cache.cache_set(1, 100); + /// assert_eq!(cache.cache_get(&1), Some(&100)); + /// ``` + #[doc(alias = "with_hasher")] + #[must_use] + pub fn hasher(self, hasher: S2) -> LruCacheBuilder { + LruCacheBuilder { + size: self.size, + on_evict: self.on_evict, + hasher, + } + } + /// Build the cache. /// /// # Errors /// - /// Returns [`BuildError::MissingRequired`](super::BuildError) if `max_size` was not set, - /// or [`BuildError::InvalidValue`](super::BuildError) if `max_size` is `0` or capacity + /// Returns [`BuildError::MissingRequired`](super::BuildError::MissingRequired) if `max_size` was not set, + /// or [`BuildError::InvalidValue`](super::BuildError::InvalidValue) if `max_size` is `0` or capacity /// pre-allocation fails. - pub fn build(self) -> Result, super::BuildError> + pub fn build(self) -> Result, super::BuildError> where K: Hash + Eq + Clone, + S: BuildHasher, { let size = self .size @@ -146,8 +189,9 @@ impl LruCacheBuilder { } let mut store = HashTable::new(); + // Use a temporary hasher for pre-reservation; the actual hash_builder is stored on the cache. if let Err(_e) = store.try_reserve(size, |&index: &usize| { - let hasher = &mut RandomState::new().build_hasher(); + let hasher = &mut self.hasher.build_hasher(); index.hash(hasher); hasher.finish() }) { @@ -159,7 +203,7 @@ impl LruCacheBuilder { let mut cache = LruCache { store, - hash_builder: RandomState::new(), + hash_builder: self.hasher, order: LRUList::<(K, V)>::try_with_capacity(size)?, capacity: size, hits: AtomicU64::new(0), @@ -174,15 +218,31 @@ impl LruCacheBuilder { } impl LruCache { + /// Construct a ready-to-use [`LruCache`] holding up to `max_size` entries. + /// + /// For optional settings (`on_evict`) use [`builder`](Self::builder). + /// + /// # Panics + /// + /// Panics if `max_size` is `0`, or if pre-allocating the backing store for + /// `max_size` entries fails (e.g. `usize::MAX`). Use [`builder`](Self::builder) + /// with [`build`](LruCacheBuilder::build) to handle those cases without panicking. + #[must_use] + pub fn new(max_size: usize) -> Self { + Self::builder() + .max_size(max_size) + .build() + .expect("LruCache::new requires a non-zero max_size with a valid allocation") + } + /// Return a builder for constructing a [`LruCache`]. #[must_use] pub fn builder() -> LruCacheBuilder { - LruCacheBuilder { - size: None, - on_evict: None, - } + LruCacheBuilder::default() } +} +impl LruCache { /// Disable hit/miss counter increments on this cache. /// /// Called by wrapper stores (`LruTtlCache`, `ExpiringLruCache`, and the sharded equivalents) @@ -202,6 +262,56 @@ impl LruCache { self.capacity } + /// Change the maximum number of entries, returning the previous capacity; + /// shrinking below the current entry count immediately evicts least-recently-used + /// entries. + /// + /// Eviction on shrink fires `on_evict` and counts evictions until the cache + /// fits. Growing the capacity does not pre-allocate; the backing stores grow + /// on demand as entries are inserted. + /// + /// This is useful for sizing a `#[cached(create = "{ ... }")]` cache from a value + /// loaded at startup (e.g. config), then adjusting it later as load changes. + /// + /// # Panics + /// + /// Panics if `max_size` is 0. Use [`try_set_max_size`](LruCache::try_set_max_size) + /// to validate first and avoid the panic. + /// + /// # See also + /// + /// [`LruTtlCache::set_max_size`](super::LruTtlCache::set_max_size), + /// [`ExpiringLruCache::set_max_size`](super::ExpiringLruCache::set_max_size), and + /// [`TtlSortedCache::set_max_size`](super::TtlSortedCache::set_max_size) are + /// parallel methods on the other LRU-family stores. Note that `TtlSortedCache::set_max_size` + /// returns `Option` (the previous bound, which is optional) rather than `usize`. + /// All stores also provide a fallible `try_set_max_size` counterpart. + pub fn set_max_size(&mut self, max_size: usize) -> usize { + assert!(max_size > 0, "max_size must be greater than zero"); + let prev = self.capacity; + self.capacity = max_size; + // `check_capacity` evicts at most one entry per call (it normally runs after + // a single insert), so loop until the cache fits the new, smaller bound. + while self.store.len() > self.capacity { + self.check_capacity(); + } + prev + } + + /// Fallible counterpart of [`set_max_size`](LruCache::set_max_size): validates + /// that `max_size` is non-zero and then delegates to `set_max_size`. + /// Returns the previous capacity on success. + /// + /// # Errors + /// + /// Returns [`SetMaxSizeError::ZeroSize`](super::SetMaxSizeError) if `max_size` is 0. + pub fn try_set_max_size(&mut self, max_size: usize) -> Result { + if max_size == 0 { + return Err(super::SetMaxSizeError::ZeroSize); + } + Ok(self.set_max_size(max_size)) + } + /// Return all entries in current LRU order (most-recently-used first) as a `Vec` of `(K, V)` pairs. pub fn iter_order(&self) -> Vec<(K, V)> where @@ -448,7 +558,7 @@ impl LruCache { .collect::>() }; for k in remove_keys { - self.cache_remove(&k); + let _ = self.cache_remove(&k); } } @@ -497,9 +607,10 @@ impl LruCache { } #[cfg(feature = "async_core")] -impl LruCache +impl LruCache where K: Hash + Eq + Clone + Send, + S: BuildHasher, { pub(super) async fn get_or_set_with_if_async( &mut self, @@ -598,7 +709,9 @@ where } } -impl Cached for LruCache { +impl Cached for LruCache { + type Error = std::convert::Infallible; + fn cache_get(&mut self, key: &Q) -> Option<&V> where K: Borrow, @@ -628,12 +741,12 @@ impl Cached for LruCache { v } - fn cache_get_or_set_with V>(&mut self, key: K, f: F) -> &mut V { + fn cache_get_or_set_with_mut V>(&mut self, key: K, f: F) -> &mut V { let (_, _, _, v) = self.get_or_set_with_if(key, f, |_| true); v } - fn cache_try_get_or_set_with Result, E>( + fn cache_try_get_or_set_with_mut Result, E>( &mut self, key: K, f: F, @@ -697,7 +810,7 @@ impl Cached for LruCache { } } -impl CachedIter for LruCache { +impl CachedIter for LruCache { fn iter<'a>(&'a self) -> impl Iterator + 'a where K: 'a, @@ -707,7 +820,7 @@ impl CachedIter for LruCache { } } -impl CachedPeek for LruCache { +impl CachedPeek for LruCache { fn cache_peek(&self, k: &Q) -> Option<&V> where K: Borrow, @@ -721,11 +834,12 @@ impl CachedPeek for LruCache { } #[cfg(feature = "async_core")] -impl CachedAsync for LruCache +impl CachedAsync for LruCache where K: Hash + Eq + Clone + Send, + S: BuildHasher + Send, { - fn async_get_or_set_with<'a, F, Fut>( + fn async_cache_get_or_set_with_mut<'a, F, Fut>( &'a mut self, k: K, f: F, @@ -742,7 +856,7 @@ where } } - fn async_try_get_or_set_with<'a, F, Fut, E>( + fn async_cache_try_get_or_set_with_mut<'a, F, Fut, E>( &'a mut self, k: K, f: F, @@ -764,8 +878,27 @@ where #[cfg(test)] mod tests { use super::*; + use crate::CachedExt; use crate::stores::Cached; + #[test] + fn new_returns_ready_cache_respecting_max_size() { + let mut c: LruCache = LruCache::new(2); + assert_eq!(c.capacity(), 2); + assert_eq!(c.set(1, 10), None); + assert_eq!(c.get(&1), Some(&10)); + c.set(2, 20); + c.set(3, 30); // evicts LRU (1) + assert_eq!(c.cache_size(), 2); + assert_eq!(c.get(&1), None); + } + + #[test] + #[should_panic(expected = "non-zero max_size")] + fn new_zero_max_size_panics() { + let _c: LruCache = LruCache::new(0); + } + #[test] fn sized_cache() { let mut c = LruCache::builder().max_size(5).build().unwrap(); @@ -1027,13 +1160,13 @@ mod tests { Err("dead".to_string()) } } - let res: Result<&mut usize, String> = c.cache_try_get_or_set_with(0, || _try_get(10)); + let res: Result<&usize, String> = c.cache_try_get_or_set_with(0, || _try_get(10)); assert!(res.is_err()); assert!(c.key_order().is_empty()); - let res: Result<&mut usize, String> = c.cache_try_get_or_set_with(0, || _try_get(1)); + let res: Result<&usize, String> = c.cache_try_get_or_set_with(0, || _try_get(1)); assert_eq!(res.unwrap(), &1); - let res: Result<&mut usize, String> = c.cache_try_get_or_set_with(0, || _try_get(5)); + let res: Result<&usize, String> = c.cache_try_get_or_set_with(0, || _try_get(5)); assert_eq!(res.unwrap(), &1); } @@ -1090,37 +1223,37 @@ mod tests { } assert_eq!( - CachedAsync::async_get_or_set_with(&mut c, 0, || async { _get(0).await }).await, + CachedAsync::async_cache_get_or_set_with(&mut c, 0, || async { _get(0).await }).await, &0 ); assert_eq!( - CachedAsync::async_get_or_set_with(&mut c, 1, || async { _get(1).await }).await, + CachedAsync::async_cache_get_or_set_with(&mut c, 1, || async { _get(1).await }).await, &1 ); assert_eq!( - CachedAsync::async_get_or_set_with(&mut c, 2, || async { _get(2).await }).await, + CachedAsync::async_cache_get_or_set_with(&mut c, 2, || async { _get(2).await }).await, &2 ); assert_eq!( - CachedAsync::async_get_or_set_with(&mut c, 3, || async { _get(3).await }).await, + CachedAsync::async_cache_get_or_set_with(&mut c, 3, || async { _get(3).await }).await, &3 ); // hits — should not re-evaluate assert_eq!( - CachedAsync::async_get_or_set_with(&mut c, 0, || async { _get(99).await }).await, + CachedAsync::async_cache_get_or_set_with(&mut c, 0, || async { _get(99).await }).await, &0 ); assert_eq!( - CachedAsync::async_get_or_set_with(&mut c, 1, || async { _get(99).await }).await, + CachedAsync::async_cache_get_or_set_with(&mut c, 1, || async { _get(99).await }).await, &1 ); assert_eq!( - CachedAsync::async_get_or_set_with(&mut c, 2, || async { _get(99).await }).await, + CachedAsync::async_cache_get_or_set_with(&mut c, 2, || async { _get(99).await }).await, &2 ); assert_eq!( - CachedAsync::async_get_or_set_with(&mut c, 3, || async { _get(99).await }).await, + CachedAsync::async_cache_get_or_set_with(&mut c, 3, || async { _get(99).await }).await, &3 ); @@ -1134,30 +1267,34 @@ mod tests { } assert_eq!( - CachedAsync::async_try_get_or_set_with(&mut c, 0, || async { _try_get(0).await }) + CachedAsync::async_cache_try_get_or_set_with(&mut c, 0, || async { _try_get(0).await }) .await .unwrap(), &0 ); assert_eq!( - CachedAsync::async_try_get_or_set_with(&mut c, 0, || async { _try_get(5).await }) + CachedAsync::async_cache_try_get_or_set_with(&mut c, 0, || async { _try_get(5).await }) .await .unwrap(), &0 // cached value, 5 never evaluated ); c.cache_reset(); - let res: Result<&mut usize, String> = - CachedAsync::async_try_get_or_set_with(&mut c, 0, || async { _try_get(10).await }) - .await; + let res: Result<&usize, String> = + CachedAsync::async_cache_try_get_or_set_with(&mut c, 0, || async { + _try_get(10).await + }) + .await; assert!(res.is_err()); assert!(c.key_order().is_empty()); - let res: Result<&mut usize, String> = - CachedAsync::async_try_get_or_set_with(&mut c, 0, || async { _try_get(1).await }).await; + let res: Result<&usize, String> = + CachedAsync::async_cache_try_get_or_set_with(&mut c, 0, || async { _try_get(1).await }) + .await; assert_eq!(res.unwrap(), &1); - let res: Result<&mut usize, String> = - CachedAsync::async_try_get_or_set_with(&mut c, 0, || async { _try_get(5).await }).await; + let res: Result<&usize, String> = + CachedAsync::async_cache_try_get_or_set_with(&mut c, 0, || async { _try_get(5).await }) + .await; assert_eq!(res.unwrap(), &1); } @@ -1296,11 +1433,11 @@ mod tests { .build() .unwrap(); c.cache_set(1u32, 10u32); - c.cache_remove_entry(&1u32); + let _ = c.cache_remove_entry(&1u32); assert_eq!(count.load(Ordering::Relaxed), 1); // No fire for absent key. - c.cache_remove_entry(&999u32); + let _ = c.cache_remove_entry(&999u32); assert_eq!(count.load(Ordering::Relaxed), 1); } @@ -1309,8 +1446,8 @@ mod tests { let mut c = LruCache::builder().max_size(4).build().unwrap(); c.cache_set(1u32, 10u32); let before = c.cache_evictions().expect("evictions are always tracked"); - c.cache_remove_entry(&1u32); - c.cache_remove_entry(&999u32); // absent — must not increment + let _ = c.cache_remove_entry(&1u32); + let _ = c.cache_remove_entry(&999u32); // absent — must not increment assert_eq!( c.cache_evictions().expect("evictions are always tracked") - before, 1, @@ -1325,4 +1462,122 @@ mod tests { assert!(c.cache_delete(&1u32)); assert!(!c.cache_delete(&1u32)); } + + #[test] + fn set_max_size_grow_returns_previous_and_keeps_entries() { + let mut c = LruCache::builder().max_size(2).build().unwrap(); + c.cache_set(1u32, 10u32); + c.cache_set(2u32, 20u32); + let prev = c.set_max_size(4); + assert_eq!(prev, 2); + assert_eq!(c.capacity(), 4); + // Growing keeps existing entries. + assert_eq!(c.cache_get(&1), Some(&10)); + assert_eq!(c.cache_get(&2), Some(&20)); + // Room for more before eviction. + c.cache_set(3u32, 30u32); + c.cache_set(4u32, 40u32); + assert_eq!(c.cache_size(), 4); + } + + #[test] + fn set_max_size_shrink_evicts_lru_entries() { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering as AOrdering}; + let evicted = Arc::new(AtomicUsize::new(0)); + let evicted2 = evicted.clone(); + let mut c = LruCache::builder() + .max_size(4) + .on_evict(move |_k: &u32, _v: &u32| { + evicted2.fetch_add(1, AOrdering::Relaxed); + }) + .build() + .unwrap(); + c.cache_set(1u32, 10u32); + c.cache_set(2u32, 20u32); + c.cache_set(3u32, 30u32); + c.cache_set(4u32, 40u32); + // Touch 1 and 2 so 3 and 4 become the least-recently-used. + assert_eq!(c.cache_get(&1), Some(&10)); + assert_eq!(c.cache_get(&2), Some(&20)); + + let prev = c.set_max_size(2); + assert_eq!(prev, 4); + assert_eq!(c.capacity(), 2); + assert_eq!(c.cache_size(), 2); + // Shrinking fires on_evict for each evicted entry and counts evictions. + assert_eq!(evicted.load(AOrdering::Relaxed), 2); + assert_eq!(c.cache_evictions(), Some(2)); + // The two most-recently-used survive. + assert_eq!(c.cache_get(&1), Some(&10)); + assert_eq!(c.cache_get(&2), Some(&20)); + assert_eq!(c.cache_get(&3), None); + assert_eq!(c.cache_get(&4), None); + } + + #[test] + #[should_panic(expected = "max_size must be greater than zero")] + fn set_max_size_zero_panics() { + let mut c: LruCache = LruCache::builder().max_size(2).build().unwrap(); + c.set_max_size(0); + } + + #[test] + fn try_set_max_size_rejects_zero() { + let mut c: LruCache = LruCache::builder().max_size(2).build().unwrap(); + assert_eq!( + c.try_set_max_size(0), + Err(super::super::SetMaxSizeError::ZeroSize) + ); + assert_eq!(c.try_set_max_size(8).unwrap(), 2); + assert_eq!(c.capacity(), 8); + } + + // --- custom hasher tests --- + + #[test] + fn custom_hasher_get_set_round_trip() { + use std::collections::hash_map::RandomState; + let mut c = LruCache::::builder() + .max_size(10) + .hasher(RandomState::new()) + .build() + .unwrap(); + assert_eq!(c.cache_set(1, 100), None); + assert_eq!(c.cache_set(2, 200), None); + assert_eq!(c.cache_get(&1), Some(&100)); + assert_eq!(c.cache_get(&2), Some(&200)); + assert_eq!(c.cache_hits(), Some(2)); + assert_eq!(c.cache_misses(), Some(0)); + assert_eq!(c.cache_get(&99), None); + assert_eq!(c.cache_misses(), Some(1)); + } + + #[test] + fn default_constructor_still_works() { + let mut c: LruCache = LruCache::new(5); + c.cache_set(1, 10); + assert_eq!(c.cache_get(&1), Some(&10)); + + let mut b = LruCache::::builder().max_size(5).build().unwrap(); + b.cache_set(2, 20); + assert_eq!(b.cache_get(&2), Some(&20)); + } + + #[test] + fn custom_hasher_respects_lru_eviction() { + use std::collections::hash_map::RandomState; + let mut c = LruCache::::builder() + .max_size(2) + .hasher(RandomState::new()) + .build() + .unwrap(); + c.cache_set(1, 10); + c.cache_set(2, 20); + c.cache_get(&1); // make 1 most-recently-used + c.cache_set(3, 30); // should evict 2 (least-recently-used) + assert_eq!(c.cache_get(&1), Some(&10)); + assert_eq!(c.cache_get(&2), None); // evicted + assert_eq!(c.cache_get(&3), Some(&30)); + } } diff --git a/src/stores/lru_ttl.rs b/src/stores/lru_ttl.rs index 45da7d89..85ea20c1 100644 --- a/src/stores/lru_ttl.rs +++ b/src/stores/lru_ttl.rs @@ -8,7 +8,8 @@ use {super::CachedAsync, std::future::Future}; use crate::{CachedIter, CachedPeek, CloneCached}; -use super::{CacheEvict, Cached, LruCache, TimedEntry}; +use super::{CacheEvict, Cached, DefaultHashBuilder, LruCache, TimedEntry}; +use std::hash::BuildHasher; use std::marker::PhantomData; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; @@ -22,8 +23,18 @@ use std::sync::atomic::{AtomicU64, Ordering}; /// Set `refresh = true` to refresh the TTL on cache hits. /// /// Note: This cache is in-memory only -pub struct LruTtlCache { - pub(super) store: LruCache>, +/// +/// **`len` / `iter` / `evict` contract**: `len()` returns the raw stored entry count +/// and may include expired-but-not-yet-swept entries. `iter()` omits expired entries +/// from the view but does not remove them. Call `evict()` (via [`CacheEvict`](crate::CacheEvict)) +/// to physically remove expired entries and obtain an accurate live count. +/// +/// The optional type parameter `S` selects the hash builder. It defaults to +/// [`DefaultHashBuilder`] (ahash when the `ahash` feature is enabled, otherwise +/// `std::collections::hash_map::RandomState`). Supply a custom `S` via +/// [`LruTtlCacheBuilder::hasher`] to use a different hasher. +pub struct LruTtlCache { + pub(super) store: LruCache, S>, pub(super) size: usize, pub(super) ttl: Duration, pub(super) hits: AtomicU64, @@ -33,7 +44,7 @@ pub struct LruTtlCache { pub(super) on_evict: Option>, } -impl std::fmt::Debug for LruTtlCache { +impl std::fmt::Debug for LruTtlCache { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("LruTtlCache") .field("size", &self.size) @@ -47,10 +58,11 @@ impl std::fmt::Debug for LruTtlCache { } } -impl Clone for LruTtlCache +impl Clone for LruTtlCache where K: Clone + Hash + Eq, V: Clone, + S: Clone, { fn clone(&self) -> Self { let store = self.store.clone(); @@ -87,16 +99,20 @@ pub struct HasEvict; /// - [`HasEvict`]: an eviction callback was registered via [`on_evict`](LruTtlCacheBuilder::on_evict); /// `build` requires `K: 'static + V: 'static` so the callback /// can be wired into the inner LRU eviction path. -pub struct LruTtlCacheBuilder { +/// +/// The `S` type parameter selects the hash builder; it defaults to [`DefaultHashBuilder`]. +/// Call [`.hasher()`](LruTtlCacheBuilder::hasher) to use a custom hasher. +pub struct LruTtlCacheBuilder { size: Option, ttl: Option, refresh: bool, on_evict: Option>, + hasher: S, _evict: PhantomData, } -// size / ttl / refresh work regardless of eviction state -impl LruTtlCacheBuilder { +// size / ttl / refresh work regardless of eviction state or hasher +impl LruTtlCacheBuilder { /// Set the maximum number of entries. Required. #[doc(alias = "size")] #[doc(alias = "capacity")] @@ -107,22 +123,77 @@ impl LruTtlCacheBuilder { } /// Set the TTL for cache entries. Required. + /// + /// Overrides any previously set ttl/ttl_secs/ttl_millis on this builder. #[must_use] pub fn ttl(mut self, ttl: Duration) -> Self { self.ttl = Some(ttl); self } + /// Set the TTL for cache entries in whole seconds. Equivalent to + /// `ttl(Duration::from_secs(secs))`. + /// + /// Overrides any previously set ttl/ttl_secs/ttl_millis on this builder. + #[must_use] + pub fn ttl_secs(self, secs: u64) -> Self { + self.ttl(Duration::from_secs(secs)) + } + + /// Set the TTL for cache entries in milliseconds. Equivalent to + /// `ttl(Duration::from_millis(millis))`. + /// + /// Overrides any previously set ttl/ttl_secs/ttl_millis on this builder. + #[must_use] + pub fn ttl_millis(self, millis: u64) -> Self { + self.ttl(Duration::from_millis(millis)) + } + /// Set whether cache hits refresh the TTL of the accessed entry. #[must_use] pub fn refresh_on_hit(mut self, refresh: bool) -> Self { self.refresh = refresh; self } + + /// Switch to a custom hash builder `S2`, returning a builder parameterized on `S2`. + /// + /// The hasher is used to hash keys in the internal backing `LruCache`. Calling this + /// method changes the builder's `S` type parameter so `build()` returns an + /// `LruTtlCache`. + /// + /// # Example + /// + /// ```rust + /// use cached::{Cached, LruTtlCache}; + /// use cached::time::Duration; + /// use std::collections::hash_map::RandomState; + /// + /// let mut cache = LruTtlCache::::builder() + /// .max_size(10) + /// .ttl_secs(60) + /// .hasher(RandomState::new()) + /// .build() + /// .unwrap(); + /// cache.cache_set(1, 100); + /// assert_eq!(cache.cache_get(&1), Some(&100)); + /// ``` + #[doc(alias = "with_hasher")] + #[must_use] + pub fn hasher(self, hasher: S2) -> LruTtlCacheBuilder { + LruTtlCacheBuilder { + size: self.size, + ttl: self.ttl, + refresh: self.refresh, + on_evict: self.on_evict, + hasher, + _evict: PhantomData, + } + } } -// on_evict transitions the builder from NoEvict → HasEvict -impl LruTtlCacheBuilder { +// on_evict transitions the builder from NoEvict -> HasEvict +impl LruTtlCacheBuilder { /// Set a callback to be invoked when an entry is evicted. /// /// Calling this method changes the builder's type to @@ -137,25 +208,26 @@ impl LruTtlCacheBuilder { pub fn on_evict( self, on_evict: impl Fn(&K, &V) + Send + Sync + 'static, - ) -> LruTtlCacheBuilder { + ) -> LruTtlCacheBuilder { LruTtlCacheBuilder { size: self.size, ttl: self.ttl, refresh: self.refresh, on_evict: Some(Arc::new(on_evict)), + hasher: self.hasher, _evict: PhantomData, } } } -// build without an eviction callback — no 'static required -impl LruTtlCacheBuilder { +// build without an eviction callback -- no 'static required +impl LruTtlCacheBuilder { /// Build the cache. /// /// # Errors /// /// Returns [`BuildError`](super::BuildError) if `max_size` or `ttl` was not set, if `ttl` is zero, or if `max_size` is `0`. - pub fn build(self) -> Result, super::BuildError> + pub fn build(self) -> Result, super::BuildError> where K: Hash + Eq + Clone, { @@ -164,18 +236,18 @@ impl LruTtlCacheBuilder { .ok_or(super::BuildError::MissingRequired("max_size"))?; let ttl = self.ttl.ok_or(super::BuildError::MissingRequired("ttl"))?; super::validate_ttl(ttl)?; - LruTtlCache::new_internal(size, ttl, self.refresh) + LruTtlCache::new_internal(size, ttl, self.refresh, self.hasher) } } -// build with an eviction callback — 'static required for sync_on_evict -impl LruTtlCacheBuilder { +// build with an eviction callback -- 'static required for sync_on_evict +impl LruTtlCacheBuilder { /// Build the cache. /// /// # Errors /// /// Returns [`BuildError`](super::BuildError) if `max_size` or `ttl` was not set, if `ttl` is zero, or if `max_size` is `0`. - pub fn build(self) -> Result, super::BuildError> + pub fn build(self) -> Result, super::BuildError> where K: Hash + Eq + Clone + 'static, V: 'static, @@ -185,7 +257,7 @@ impl LruTtlCacheBuilder { .ok_or(super::BuildError::MissingRequired("max_size"))?; let ttl = self.ttl.ok_or(super::BuildError::MissingRequired("ttl"))?; super::validate_ttl(ttl)?; - let mut cache = LruTtlCache::new_internal(size, ttl, self.refresh)?; + let mut cache = LruTtlCache::new_internal(size, ttl, self.refresh, self.hasher)?; cache.on_evict = self.on_evict; cache.sync_on_evict(); Ok(cache) @@ -193,6 +265,25 @@ impl LruTtlCacheBuilder { } impl LruTtlCache { + /// Construct a ready-to-use [`LruTtlCache`] holding up to `max_size` entries with + /// the given `ttl`. + /// + /// For optional settings (`refresh_on_hit`, `on_evict`) use [`builder`](Self::builder). + /// + /// # Panics + /// + /// Panics if `max_size` is `0`, if `ttl` is zero, or if pre-allocating the backing + /// store for `max_size` entries fails (e.g. `usize::MAX`). Use [`builder`](Self::builder) + /// with [`build`](LruTtlCacheBuilder::build) to handle those cases without panicking. + #[must_use] + pub fn new(max_size: usize, ttl: Duration) -> Self { + Self::builder() + .max_size(max_size) + .ttl(ttl) + .build() + .expect("LruTtlCache::new requires a non-zero max_size with a valid allocation and a non-zero ttl") + } + /// Return a builder for constructing a [`LruTtlCache`]. #[must_use] pub fn builder() -> LruTtlCacheBuilder { @@ -201,10 +292,13 @@ impl LruTtlCache { ttl: None, refresh: false, on_evict: None, + hasher: super::new_default_hash_builder(), _evict: PhantomData, } } +} +impl LruTtlCache { pub(super) fn sync_on_evict(&mut self) where K: 'static, @@ -220,8 +314,37 @@ impl LruTtlCache { } } - fn new_internal(size: usize, ttl: Duration, refresh: bool) -> Result { - let mut store = LruCache::builder().max_size(size).build()?; + /// `true` if the entry is still live. + /// `expires_at = None` means the entry never expires (TTL was disabled at insert time). + #[inline] + pub(super) fn entry_live(expires_at: Option) -> bool { + expires_at.is_none_or(|t| Instant::now() < t) + } + + /// Compute the expiry instant for a new or refreshed entry given the current TTL. + /// Returns `None` when `ttl` is zero (expiry disabled), or `Some(now + ttl)`. + /// Returns `Err(CacheSetError::TimeBounds)` on overflow. + #[inline] + pub(super) fn compute_expires_at( + ttl: Duration, + now: Instant, + ) -> Result, super::CacheSetError> { + if ttl.is_zero() { + Ok(None) + } else { + now.checked_add(ttl) + .map(Some) + .ok_or(super::CacheSetError::TimeBounds) + } + } + + fn new_internal( + size: usize, + ttl: Duration, + refresh: bool, + hasher: S, + ) -> Result { + let mut store = LruCache::builder().max_size(size).hasher(hasher).build()?; store.disable_hit_miss_tracking(); Ok(LruTtlCache { store, @@ -235,19 +358,21 @@ impl LruTtlCache { }) } - pub fn iter_order(&self) -> Vec<(K, (Instant, V))> + /// Return an iterator of key-value pairs with their expiry instants + /// in the current order from most to least recently used. + /// Items past their expiry will be excluded. + pub fn iter_order(&self) -> Vec<(K, (Option, V))> where K: Clone, V: Clone, { - let max_ttl = self.ttl; self.store .iter_order() .into_iter() .filter_map(|(k, entry)| { - let instant = entry.instant; - if instant.elapsed() < max_ttl { - Some((k.clone(), (instant, entry.value.clone()))) + let expires_at = entry.expires_at; + if Self::entry_live(expires_at) { + Some((k.clone(), (expires_at, entry.value.clone()))) } else { None } @@ -257,17 +382,16 @@ impl LruTtlCache { /// Return an iterator of keys in the current order from most /// to least recently used. - /// Items passed their expiration seconds will be excluded. + /// Items past their expiry will be excluded. pub fn key_order(&self) -> Vec where K: Clone, { - let max_ttl = self.ttl; self.store .order .iter() .filter_map(|(k, entry)| { - if entry.instant.elapsed() < max_ttl { + if Self::entry_live(entry.expires_at) { Some(k.clone()) } else { None @@ -276,21 +400,20 @@ impl LruTtlCache { .collect() } - /// Return an iterator of timestamped values in the current order + /// Return an iterator of (expiry, value) pairs in the current order /// from most to least recently used. - /// Items passed their expiration seconds will be excluded. - pub fn value_order(&self) -> Vec<(Instant, V)> + /// Items past their expiry will be excluded. + pub fn value_order(&self) -> Vec<(Option, V)> where V: Clone, { - let max_ttl = self.ttl; self.store .order .iter() .filter_map(|(_k, entry)| { - let instant = entry.instant; - if instant.elapsed() < max_ttl { - Some((instant, entry.value.clone())) + let expires_at = entry.expires_at; + if Self::entry_live(expires_at) { + Some((expires_at, entry.value.clone())) } else { None } @@ -309,31 +432,59 @@ impl LruTtlCache { self.size } - /// Returns whether the ttl is refreshed when the value is retrieved. - #[must_use] - pub fn refresh_on_hit(&self) -> bool { - self.refresh - } - - /// Sets whether the ttl is refreshed when the value is retrieved. - pub fn set_refresh_on_hit(&mut self, refresh: bool) { - self.refresh = refresh; + /// Change the maximum number of entries, returning the previous capacity; + /// shrinking below the current entry count immediately evicts least-recently-used + /// entries. + /// + /// Eviction on shrink fires `on_evict` and counts evictions until the cache + /// fits. Growing the capacity does not pre-allocate; the backing stores grow + /// on demand as entries are inserted. + /// + /// This is useful for sizing a `#[cached(create = "{ ... }")]` cache from a value + /// loaded at startup (e.g. config), then adjusting it later as load changes. + /// + /// # Panics + /// + /// Panics if `max_size` is 0. Use [`try_set_max_size`](LruTtlCache::try_set_max_size) + /// to validate first and avoid the panic. + /// + /// # See also + /// + /// [`LruCache::set_max_size`](super::LruCache::set_max_size) and + /// [`TtlSortedCache::set_max_size`](super::TtlSortedCache::set_max_size) are + /// parallel methods on the other LRU-family stores. All stores also provide a + /// fallible `try_set_max_size` counterpart. + pub fn set_max_size(&mut self, max_size: usize) -> usize { + assert!(max_size > 0, "max_size must be greater than zero"); + let prev = self.store.set_max_size(max_size); + self.size = self.store.capacity; + prev } - /// Returns a reference to the cache's `store` - #[must_use] - pub fn store(&self) -> &LruCache> { - &self.store + /// Fallible counterpart of [`set_max_size`](LruTtlCache::set_max_size): validates + /// that `max_size` is non-zero and then delegates to `set_max_size`. + /// Returns the previous capacity on success. + /// + /// # Errors + /// + /// Returns [`SetMaxSizeError::ZeroSize`](super::SetMaxSizeError) if `max_size` is 0. + pub fn try_set_max_size(&mut self, max_size: usize) -> Result { + if max_size == 0 { + return Err(super::SetMaxSizeError::ZeroSize); + } + Ok(self.set_max_size(max_size)) } /// Evict expired values from the cache. + #[must_use] pub fn evict(&mut self) -> usize { - let ttl = self.ttl; let on_evict = &self.on_evict; let evictions = &self.evictions; let mut removed = 0; + let now = Instant::now(); self.store.retain_silent(|key, entry| { - if entry.instant.elapsed() < ttl { + // None means never-expires; Some(t) expires when now >= t. + if entry.expires_at.is_none_or(|t| now < t) { true } else { if let Some(on_evict) = on_evict { @@ -353,11 +504,10 @@ impl LruTtlCache { /// are removed. `on_evict` is called and the eviction counter incremented /// for each removed entry. pub fn retain bool>(&mut self, mut keep: F) { - let ttl = self.ttl; let on_evict = &self.on_evict; let evictions = &self.evictions; self.store.retain_silent(|key, entry| { - let expired = entry.instant.elapsed() >= ttl; + let expired = !Self::entry_live(entry.expires_at); if expired || !keep(key, &entry.value) { if let Some(on_evict) = on_evict { on_evict(key, &entry.value); @@ -400,7 +550,9 @@ impl LruTtlCache { } } -impl Cached for LruTtlCache { +impl Cached for LruTtlCache { + type Error = super::CacheSetError; + fn cache_get(&mut self, key: &Q) -> Option<&V> where K: std::borrow::Borrow, @@ -409,11 +561,16 @@ impl Cached for LruTtlCache { let hash = self.store.hash(key); if let Some(index) = self.store.get_index(hash, key) { let entry = &self.store.order.get(index).1; - if entry.instant.elapsed() < self.ttl { + if Self::entry_live(entry.expires_at) { self.store.order.move_to_front(index); self.hits.fetch_add(1, Ordering::Relaxed); if self.refresh { - self.store.order.get_mut(index).1.instant = Instant::now(); + let now = Instant::now(); + let new_exp = Self::compute_expires_at(self.ttl, now) + .ok() + .flatten() + .or(self.store.order.get(index).1.expires_at); + self.store.order.get_mut(index).1.expires_at = new_exp; } Some(&self.store.order.get(index).1.value) } else { @@ -440,11 +597,16 @@ impl Cached for LruTtlCache { let hash = self.store.hash(key); if let Some(index) = self.store.get_index(hash, key) { let entry = &self.store.order.get(index).1; - if entry.instant.elapsed() < self.ttl { + if Self::entry_live(entry.expires_at) { self.store.order.move_to_front(index); self.hits.fetch_add(1, Ordering::Relaxed); if self.refresh { - self.store.order.get_mut(index).1.instant = Instant::now(); + let now = Instant::now(); + let new_exp = Self::compute_expires_at(self.ttl, now) + .ok() + .flatten() + .or(self.store.order.get(index).1.expires_at); + self.store.order.get_mut(index).1.expires_at = new_exp; } Some(&mut self.store.order.get_mut(index).1.value) } else { @@ -463,19 +625,28 @@ impl Cached for LruTtlCache { } } - fn cache_get_or_set_with V>(&mut self, key: K, f: F) -> &mut V { + fn cache_get_or_set_with_mut V>(&mut self, key: K, f: F) -> &mut V { let key_for_evict = key.clone(); - let setter = || TimedEntry { - instant: Instant::now(), - value: f(), + let ttl = self.ttl; + let setter = || { + let now = Instant::now(); + let expires_at = Self::compute_expires_at(ttl, now).unwrap_or(None); + TimedEntry { + expires_at, + value: f(), + } }; - let max_ttl = self.ttl; let (was_present, was_valid, old_entry, entry) = self.store - .get_or_set_with_if(key, setter, |entry| entry.instant.elapsed() < max_ttl); + .get_or_set_with_if(key, setter, |entry| Self::entry_live(entry.expires_at)); if was_present && was_valid { if self.refresh { - entry.instant = Instant::now(); + let now = Instant::now(); + let new_exp = Self::compute_expires_at(self.ttl, now) + .ok() + .flatten() + .or(entry.expires_at); + entry.expires_at = new_exp; } self.hits.fetch_add(1, Ordering::Relaxed); } else { @@ -490,25 +661,32 @@ impl Cached for LruTtlCache { &mut entry.value } - fn cache_try_get_or_set_with Result, E>( + fn cache_try_get_or_set_with_mut Result, E>( &mut self, key: K, f: F, ) -> Result<&mut V, E> { let key_for_evict = key.clone(); + let ttl = self.ttl; let setter = || { + let now = Instant::now(); + let expires_at = Self::compute_expires_at(ttl, now).unwrap_or(None); Ok(TimedEntry { - instant: Instant::now(), + expires_at, value: f()?, }) }; - let max_ttl = self.ttl; let (was_present, was_valid, old_entry, entry) = self.store - .try_get_or_set_with_if(key, setter, |entry| entry.instant.elapsed() < max_ttl)?; + .try_get_or_set_with_if(key, setter, |entry| Self::entry_live(entry.expires_at))?; if was_present && was_valid { if self.refresh { - entry.instant = Instant::now(); + let now = Instant::now(); + let new_exp = Self::compute_expires_at(self.ttl, now) + .ok() + .flatten() + .or(entry.expires_at); + entry.expires_at = new_exp; } self.hits.fetch_add(1, Ordering::Relaxed); } else { @@ -526,13 +704,15 @@ impl Cached for LruTtlCache { /// Insert a key-value pair. Returns the previous value only if it had not yet expired. /// Expired previous values are silently discarded. fn cache_set(&mut self, key: K, val: V) -> Option { + let now = Instant::now(); + let expires_at = Self::compute_expires_at(self.ttl, now).unwrap_or(None); let entry = TimedEntry { - instant: Instant::now(), + expires_at, value: val, }; - let stamped = self.store.set(key, entry); + let stamped = self.store.cache_set(key, entry); stamped.and_then(|entry| { - if entry.instant.elapsed() < self.ttl { + if Self::entry_live(entry.expires_at) { Some(entry.value) } else { None @@ -540,6 +720,22 @@ impl Cached for LruTtlCache { }) } + fn cache_try_set(&mut self, key: K, val: V) -> Result, super::CacheSetError> { + let now = Instant::now(); + let expires_at = Self::compute_expires_at(self.ttl, now)?; + let entry = TimedEntry { + expires_at, + value: val, + }; + Ok(self.store.cache_set(key, entry).and_then(|entry| { + if Self::entry_live(entry.expires_at) { + Some(entry.value) + } else { + None + } + })) + } + fn cache_remove(&mut self, k: &Q) -> Option where K: std::borrow::Borrow, @@ -550,7 +746,7 @@ impl Cached for LruTtlCache { on_evict(&stored_k, &entry.value); } self.evictions.fetch_add(1, Ordering::Relaxed); - if entry.instant.elapsed() < self.ttl { + if Self::entry_live(entry.expires_at) { Some(entry.value) } else { None @@ -577,16 +773,12 @@ impl Cached for LruTtlCache { } fn cache_clear(&mut self) { - self.store.clear(); + self.store.cache_clear(); } fn cache_reset(&mut self) { // Entries are dropped in-place; `on_evict` is NOT called for cleared entries. - let on_evict = self.store.on_evict.clone(); - self.store = LruCache::builder() - .max_size(self.size) - .build() - .expect("LruCache build failed"); - self.store.on_evict = on_evict; + // Delegate to the inner LruCache's reset which preserves the hash builder. + self.store.cache_reset(); self.cache_reset_metrics(); } fn cache_reset_metrics(&mut self) { @@ -613,15 +805,14 @@ impl Cached for LruTtlCache { } } -impl CachedIter for LruTtlCache { +impl CachedIter for LruTtlCache { fn iter<'a>(&'a self) -> impl Iterator + 'a where K: 'a, V: 'a, { - let max_ttl = self.ttl; CachedIter::iter(&self.store).filter_map(move |(k, entry)| { - if entry.instant.elapsed() < max_ttl { + if Self::entry_live(entry.expires_at) { Some((k, &entry.value)) } else { None @@ -630,14 +821,14 @@ impl CachedIter for LruTtlCache { } } -impl CachedPeek for LruTtlCache { +impl CachedPeek for LruTtlCache { fn cache_peek(&self, k: &Q) -> Option<&V> where K: std::borrow::Borrow, Q: std::hash::Hash + Eq + ?Sized, { if let Some(entry) = self.store.cache_peek(k) - && entry.instant.elapsed() < self.ttl + && Self::entry_live(entry.expires_at) { return Some(&entry.value); } @@ -645,17 +836,26 @@ impl CachedPeek for LruTtlCache { } } -impl crate::CacheTtl for LruTtlCache { +impl crate::CacheTtl for LruTtlCache { fn ttl(&self) -> Option { - Some(self.ttl) + // A zero TTL means expiry is disabled. + if self.ttl.is_zero() { + None + } else { + Some(self.ttl) + } } + /// A zero `ttl` disables expiry — exactly equivalent to `unset_ttl`. + /// Returns the previous TTL, or `None` if expiry was already disabled. fn set_ttl(&mut self, ttl: Duration) -> Option { let old = self.ttl; self.ttl = ttl; - Some(old) + if old.is_zero() { None } else { Some(old) } } fn unset_ttl(&mut self) -> Option { - None + let old = self.ttl; + self.ttl = Duration::ZERO; + if old.is_zero() { None } else { Some(old) } } fn refresh_on_hit(&self) -> bool { self.refresh @@ -667,7 +867,9 @@ impl crate::CacheTtl for LruTtlCache { } } -impl CloneCached for LruTtlCache { +impl CloneCached + for LruTtlCache +{ fn cache_get_with_expiry_status(&mut self, k: &Q) -> (Option, bool) where K: std::borrow::Borrow, @@ -676,7 +878,7 @@ impl CloneCached for LruTtlCache { let hash = self.store.hash(k); if let Some(index) = self.store.get_index(hash, k) { let entry = &self.store.order.get(index).1; - let expired = entry.instant.elapsed() >= self.ttl; + let expired = !Self::entry_live(entry.expires_at); if expired { self.misses.fetch_add(1, Ordering::Relaxed); (Some(self.store.order.get(index).1.value.clone()), true) @@ -684,7 +886,12 @@ impl CloneCached for LruTtlCache { self.store.order.move_to_front(index); self.hits.fetch_add(1, Ordering::Relaxed); if self.refresh { - self.store.order.get_mut(index).1.instant = Instant::now(); + let now = Instant::now(); + let new_exp = Self::compute_expires_at(self.ttl, now) + .ok() + .flatten() + .or(self.store.order.get(index).1.expires_at); + self.store.order.get_mut(index).1.expires_at = new_exp; } (Some(self.store.order.get(index).1.value.clone()), false) } @@ -693,14 +900,35 @@ impl CloneCached for LruTtlCache { (None, false) } } + + /// Peek at the entry (including expired entries) without any read side effects. + /// + /// Returns `(Some(v), true)` for an expired entry, `(Some(v), false)` for a live + /// entry, and `(None, false)` when the key is absent. Does not update hit/miss + /// counters, does not promote in LRU order, and does not renew the TTL. + fn cache_peek_with_expiry_status(&self, k: &Q) -> (Option, bool) + where + K: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized, + V: Clone, + { + // Use the inner LruCache's `cache_peek` to avoid LRU promotion. + if let Some(entry) = self.store.cache_peek(k) { + let expired = !Self::entry_live(entry.expires_at); + (Some(entry.value.clone()), expired) + } else { + (None, false) + } + } } #[cfg(feature = "async_core")] -impl CachedAsync for LruTtlCache +impl CachedAsync for LruTtlCache where K: Hash + Eq + Clone + Send, + S: BuildHasher + Send, { - fn async_get_or_set_with<'a, F, Fut>( + fn async_cache_get_or_set_with_mut<'a, F, Fut>( &'a mut self, key: K, f: F, @@ -713,20 +941,27 @@ where { async move { let key_for_evict = key.clone(); - let setter = || async { + let ttl = self.ttl; + let setter = || async move { + let now = Instant::now(); + let expires_at = Self::compute_expires_at(ttl, now).unwrap_or(None); TimedEntry { - instant: Instant::now(), + expires_at, value: f().await, } }; - let max_ttl = self.ttl; let (was_present, was_valid, old_entry, entry) = self .store - .get_or_set_with_if_async(key, setter, |entry| entry.instant.elapsed() < max_ttl) + .get_or_set_with_if_async(key, setter, |entry| Self::entry_live(entry.expires_at)) .await; if was_present && was_valid { if self.refresh { - entry.instant = Instant::now(); + let now = Instant::now(); + let new_exp = Self::compute_expires_at(self.ttl, now) + .ok() + .flatten() + .or(entry.expires_at); + entry.expires_at = new_exp; } self.hits.fetch_add(1, Ordering::Relaxed); } else { @@ -742,7 +977,7 @@ where } } - fn async_try_get_or_set_with<'a, F, Fut, E>( + fn async_cache_try_get_or_set_with_mut<'a, F, Fut, E>( &'a mut self, key: K, f: F, @@ -756,23 +991,30 @@ where { async move { let key_for_evict = key.clone(); - let setter = || async { + let ttl = self.ttl; + let setter = || async move { let new_val = f().await?; + let now = Instant::now(); + let expires_at = Self::compute_expires_at(ttl, now).unwrap_or(None); Ok(TimedEntry { - instant: Instant::now(), + expires_at, value: new_val, }) }; - let max_ttl = self.ttl; let (was_present, was_valid, old_entry, entry) = self .store .try_get_or_set_with_if_async(key, setter, |entry| { - entry.instant.elapsed() < max_ttl + Self::entry_live(entry.expires_at) }) .await?; if was_present && was_valid { if self.refresh { - entry.instant = Instant::now(); + let now = Instant::now(); + let new_exp = Self::compute_expires_at(self.ttl, now) + .ok() + .flatten() + .or(entry.expires_at); + entry.expires_at = new_exp; } self.hits.fetch_add(1, Ordering::Relaxed); } else { @@ -789,7 +1031,7 @@ where } } -impl CacheEvict for LruTtlCache { +impl CacheEvict for LruTtlCache { fn evict(&mut self) -> usize { LruTtlCache::evict(self) } @@ -798,9 +1040,77 @@ impl CacheEvict for LruTtlCache { #[cfg(test)] mod tests { use super::*; - use crate::Cached; + use crate::{Cached, CachedExt}; use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; + #[test] + fn new_returns_ready_cache_respecting_max_size_and_ttl() { + use crate::CacheTtl; + let mut c: LruTtlCache = LruTtlCache::new(2, Duration::from_millis(50)); + assert_eq!(c.capacity(), 2); + assert_eq!(CacheTtl::ttl(&c), Some(Duration::from_millis(50))); + assert_eq!(c.cache_set(1, 10), None); + assert_eq!(c.cache_get(&1), Some(&10)); + // max_size respected. + c.cache_set(2, 20); + c.cache_set(3, 30); // evicts LRU (1) + assert_eq!(c.cache_size(), 2); + assert_eq!(c.cache_get(&1), None); + // ttl respected. + std::thread::sleep(std::time::Duration::from_millis(100)); + assert_eq!(c.cache_get(&2), None, "entry must expire after ttl"); + } + + #[test] + #[should_panic(expected = "non-zero max_size with a valid allocation and a non-zero ttl")] + fn new_zero_max_size_panics() { + let _c: LruTtlCache = LruTtlCache::new(0, Duration::from_secs(1)); + } + + #[test] + #[should_panic(expected = "non-zero max_size with a valid allocation and a non-zero ttl")] + fn new_zero_ttl_panics() { + let _c: LruTtlCache = LruTtlCache::new(2, Duration::ZERO); + } + + #[test] + fn ttl_secs_and_ttl_millis_set_duration() { + use crate::CacheTtl; + let c: LruTtlCache = LruTtlCache::builder() + .max_size(4) + .ttl_secs(7) + .build() + .unwrap(); + assert_eq!(CacheTtl::ttl(&c), Some(Duration::from_secs(7))); + + let c: LruTtlCache = LruTtlCache::builder() + .max_size(4) + .ttl_millis(250) + .build() + .unwrap(); + assert_eq!(CacheTtl::ttl(&c), Some(Duration::from_millis(250))); + } + + #[test] + fn ttl_setters_override_last_writer_wins() { + use crate::CacheTtl; + let c: LruTtlCache = LruTtlCache::builder() + .max_size(4) + .ttl(Duration::from_secs(10)) + .ttl_secs(5) + .build() + .unwrap(); + assert_eq!(CacheTtl::ttl(&c), Some(Duration::from_secs(5))); + + let c: LruTtlCache = LruTtlCache::builder() + .max_size(4) + .ttl_secs(10) + .ttl_millis(500) + .build() + .unwrap(); + assert_eq!(CacheTtl::ttl(&c), Some(Duration::from_millis(500))); + } + #[test] fn status_does_not_inflate_inner_store_hits() { let mut cache = LruTtlCache::builder() @@ -983,6 +1293,105 @@ mod tests { assert_eq!(cache.cache_get(&"key"), Some(&"val")); } + #[test] + fn set_max_size_changes_capacity_and_evicts() { + let mut cache: LruTtlCache = LruTtlCache::builder() + .max_size(3) + .ttl(Duration::from_secs(60)) + .build() + .unwrap(); + cache.cache_set(1, 10); + cache.cache_set(2, 20); + cache.cache_set(3, 30); + assert_eq!(cache.capacity(), 3); + + // Shrink to 2: LRU entry (1) should be evicted. + let prev = cache.set_max_size(2); + assert_eq!(prev, 3); + assert_eq!(cache.capacity(), 2); + assert_eq!(cache.cache_size(), 2); + + // Insert beyond new cap triggers eviction. + cache.cache_set(4, 40); + assert_eq!(cache.cache_size(), 2); + } + + #[test] + fn set_max_size_shrink_fires_on_evict_and_counts_evictions() { + use std::sync::Mutex; + let evicted_keys: Arc>> = Arc::new(Mutex::new(Vec::new())); + let evicted_keys2 = evicted_keys.clone(); + let mut cache = LruTtlCache::builder() + .max_size(4) + .ttl(Duration::from_secs(60)) + .on_evict(move |k: &u32, _v: &u32| { + evicted_keys2.lock().unwrap().push(*k); + }) + .build() + .unwrap(); + + cache.cache_set(1, 10); + cache.cache_set(2, 20); + cache.cache_set(3, 30); + cache.cache_set(4, 40); + // Touch 1 and 2 so 3 and 4 become least-recently-used. + assert_eq!(cache.cache_get(&1), Some(&10)); + assert_eq!(cache.cache_get(&2), Some(&20)); + + let evictions_before = cache.cache_evictions().expect("evictions tracked"); + let prev = cache.set_max_size(2); + assert_eq!(prev, 4); + assert_eq!(cache.capacity(), 2); + assert_eq!(cache.cache_size(), 2); + + // Two entries were dropped; eviction counter must reflect that. + assert_eq!( + cache.cache_evictions().expect("evictions tracked") - evictions_before, + 2, + "set_max_size shrink must increment cache_evictions by the number of dropped entries" + ); + + // on_evict must have fired for exactly the two LRU keys (3 and 4). + let mut fired: Vec = evicted_keys.lock().unwrap().clone(); + fired.sort(); + assert_eq!( + fired, + vec![3, 4], + "on_evict must fire for the evicted (least-recently-used) keys" + ); + + // The two most-recently-used entries must survive. + assert_eq!(cache.cache_get(&1), Some(&10)); + assert_eq!(cache.cache_get(&2), Some(&20)); + assert_eq!(cache.cache_get(&3), None); + assert_eq!(cache.cache_get(&4), None); + } + + #[test] + fn try_set_max_size_rejects_zero() { + let mut cache: LruTtlCache = LruTtlCache::builder() + .max_size(3) + .ttl(Duration::from_secs(60)) + .build() + .unwrap(); + assert_eq!( + cache.try_set_max_size(0), + Err(super::super::SetMaxSizeError::ZeroSize) + ); + assert_eq!(cache.try_set_max_size(5).unwrap(), 3); + } + + #[test] + #[should_panic(expected = "max_size must be greater than zero")] + fn set_max_size_zero_panics() { + let mut cache: LruTtlCache = LruTtlCache::builder() + .max_size(3) + .ttl(Duration::from_secs(60)) + .build() + .unwrap(); + cache.set_max_size(0); + } + #[cfg(feature = "async")] #[tokio::test] async fn test_async_trait() { @@ -998,15 +1407,15 @@ mod tests { } assert_eq!( - CachedAsync::async_get_or_set_with(&mut c, 0, || async { _get(0).await }).await, + CachedAsync::async_cache_get_or_set_with(&mut c, 0, || async { _get(0).await }).await, &0 ); assert_eq!( - CachedAsync::async_get_or_set_with(&mut c, 1, || async { _get(1).await }).await, + CachedAsync::async_cache_get_or_set_with(&mut c, 1, || async { _get(1).await }).await, &1 ); assert_eq!( - CachedAsync::async_get_or_set_with(&mut c, 0, || async { _get(99).await }).await, + CachedAsync::async_cache_get_or_set_with(&mut c, 0, || async { _get(99).await }).await, &0 ); } @@ -1130,14 +1539,14 @@ mod tests { c.cache_set(1u32, 10u32); std::thread::sleep(std::time::Duration::from_millis(100)); - c.cache_remove_entry(&1u32); + let _ = c.cache_remove_entry(&1u32); assert_eq!( count.load(Ordering::Relaxed), 1, "on_evict fires for expired entries" ); - c.cache_remove_entry(&999u32); + let _ = c.cache_remove_entry(&999u32); assert_eq!(count.load(Ordering::Relaxed), 1, "no fire for absent key"); } @@ -1151,12 +1560,71 @@ mod tests { c.cache_set(1u32, 10u32); std::thread::sleep(std::time::Duration::from_millis(100)); let before = c.cache_evictions().expect("evictions are always tracked"); - c.cache_remove_entry(&1u32); // expired but present — must increment - c.cache_remove_entry(&999u32); // absent — must not increment + let _ = c.cache_remove_entry(&1u32); // expired but present -- must increment + let _ = c.cache_remove_entry(&999u32); // absent -- must not increment assert_eq!( c.cache_evictions().expect("evictions are always tracked") - before, 1, "cache_remove_entry must increment evictions for present key only" ); } + + // --- custom hasher tests --- + + #[test] + fn custom_hasher_get_set_round_trip() { + use std::collections::hash_map::RandomState; + let mut c = LruTtlCache::::builder() + .max_size(10) + .ttl_secs(60) + .hasher(RandomState::new()) + .build() + .unwrap(); + assert_eq!(c.cache_set(1, 100), None); + assert_eq!(c.cache_set(2, 200), None); + assert_eq!(c.cache_get(&1), Some(&100)); + assert_eq!(c.cache_get(&2), Some(&200)); + assert_eq!(c.cache_hits(), Some(2)); + assert_eq!(c.cache_misses(), Some(0)); + assert_eq!(c.cache_get(&99), None); + assert_eq!(c.cache_misses(), Some(1)); + } + + #[test] + fn default_constructor_still_works() { + let mut c: LruTtlCache = LruTtlCache::new(5, Duration::from_secs(60)); + c.cache_set(1, 10); + assert_eq!(c.cache_get(&1), Some(&10)); + } + + #[test] + fn custom_hasher_respects_lru_eviction_and_ttl() { + use std::collections::hash_map::RandomState; + // Test LRU eviction + let mut c = LruTtlCache::::builder() + .max_size(2) + .ttl_secs(60) + .hasher(RandomState::new()) + .build() + .unwrap(); + c.cache_set(1, 10); + c.cache_set(2, 20); + c.cache_get(&1); // make 1 most-recently-used + c.cache_set(3, 30); // should evict 2 + assert_eq!(c.cache_get(&1), Some(&10)); + assert_eq!(c.cache_get(&2), None); // evicted + assert_eq!(c.cache_get(&3), Some(&30)); + + // Test TTL expiry + let mut c2 = LruTtlCache::::builder() + .max_size(10) + .ttl(Duration::from_millis(50)) + .hasher(RandomState::new()) + .build() + .unwrap(); + c2.cache_set(1, 10); + assert_eq!(c2.cache_get(&1), Some(&10)); + std::thread::sleep(std::time::Duration::from_millis(100)); + assert_eq!(c2.cache_get(&1), None, "entry must expire after ttl"); + } } diff --git a/src/stores/mod.rs b/src/stores/mod.rs index b4abc147..d536c243 100644 --- a/src/stores/mod.rs +++ b/src/stores/mod.rs @@ -5,6 +5,66 @@ use std::collections::hash_map::Entry; use std::hash::Hash; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +/// Default hash builder for non-sharded in-memory stores. +/// +/// Resolves to `ahash::RandomState` when the `ahash` feature is enabled, +/// and to `std::collections::hash_map::RandomState` otherwise. This +/// matches the behavior prior to the introduction of the `S` type parameter, +/// so existing code that does not name the hasher is unaffected. +/// +/// # Example +/// +/// Use the default hasher (no change required for existing code): +/// +/// ```rust +/// use cached::{Cached, UnboundCache}; +/// +/// let mut cache: UnboundCache = UnboundCache::new(); +/// cache.cache_set(1, 100); +/// assert_eq!(cache.cache_get(&1), Some(&100)); +/// ``` +/// +/// Use a custom hasher via the builder's `.hasher()` method: +/// +/// ```rust +/// use cached::{Cached, UnboundCache, DefaultHashBuilder}; +/// use std::collections::hash_map::RandomState; +/// +/// let mut cache = UnboundCache::::builder() +/// .hasher(RandomState::new()) +/// .build() +/// .unwrap(); +/// cache.cache_set(1, 100); +/// assert_eq!(cache.cache_get(&1), Some(&100)); +/// ``` +#[cfg(feature = "ahash")] +pub type DefaultHashBuilder = ahash::RandomState; + +/// Default hash builder for non-sharded in-memory stores. +/// +/// Resolves to `ahash::RandomState` when the `ahash` feature is enabled, +/// and to `std::collections::hash_map::RandomState` otherwise. This +/// matches the behavior prior to the introduction of the `S` type parameter, +/// so existing code that does not name the hasher is unaffected. +#[cfg(not(feature = "ahash"))] +pub type DefaultHashBuilder = std::collections::hash_map::RandomState; + +/// Construct a fresh [`DefaultHashBuilder`]. +/// +/// Abstracts over `ahash::RandomState::new()` vs `std::collections::hash_map::RandomState::new()` +/// since `ahash::RandomState` does not implement `Default`. +#[inline] +pub(super) fn new_default_hash_builder() -> DefaultHashBuilder { + #[cfg(feature = "ahash")] + { + ahash::RandomState::new() + } + #[cfg(not(feature = "ahash"))] + { + std::collections::hash_map::RandomState::new() + } +} + const STRIPE_COUNT: usize = 16; #[repr(align(128))] @@ -81,7 +141,7 @@ mod expiring_lru; mod lru; #[cfg(feature = "time_stores")] mod lru_ttl; -#[cfg(feature = "disk_store")] +#[cfg(feature = "redb_store")] mod redb; #[cfg(feature = "redis_store")] mod redis; @@ -92,13 +152,20 @@ mod ttl; mod ttl_sorted; mod unbound; -use crate::time::{Duration, Instant}; +#[cfg(any( + feature = "time_stores", + feature = "redb_store", + feature = "redis_store" +))] +use crate::time::Duration; +#[cfg(feature = "time_stores")] +use crate::time::Instant; pub(super) type OnEvict = std::sync::Arc; /// Error returned by cache builder `build()` methods. #[non_exhaustive] -#[derive(Debug)] +#[derive(Debug, Clone, PartialEq, Eq)] pub enum BuildError { /// A required field was not supplied to the builder. MissingRequired(&'static str), @@ -109,11 +176,6 @@ pub enum BuildError { /// Human-readable reason. reason: &'static str, }, - /// A zero TTL was supplied; TTL must be greater than zero. - InvalidTtl { - /// The invalid TTL value. - ttl: Duration, - }, } impl std::fmt::Display for BuildError { @@ -123,55 +185,131 @@ impl std::fmt::Display for BuildError { BuildError::InvalidValue { field, reason } => { write!(f, "invalid value for field `{field}`: {reason}") } - BuildError::InvalidTtl { ttl } => { - write!(f, "invalid ttl {ttl:?}: must be greater than zero") - } } } } impl std::error::Error for BuildError {} +/// Error returned by `try_set_max_size` methods. +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SetMaxSizeError { + /// A max size of zero was supplied; max_size must be greater than zero. + ZeroSize, +} + +impl std::fmt::Display for SetMaxSizeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SetMaxSizeError::ZeroSize => write!(f, "max_size must be greater than zero"), + } + } +} + +impl std::error::Error for SetMaxSizeError {} + +/// Error returned by [`CacheTtl::try_set_ttl`](crate::CacheTtl::try_set_ttl). +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SetTtlError { + /// A TTL of zero was supplied; ttl must be greater than zero. + ZeroTtl, +} + +impl std::fmt::Display for SetTtlError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SetTtlError::ZeroTtl => write!(f, "ttl must be greater than zero"), + } + } +} + +impl std::error::Error for SetTtlError {} + +/// Error returned by [`TtlCache`](crate::stores::TtlCache), +/// [`LruTtlCache`](crate::stores::LruTtlCache), and +/// [`TtlSortedCache`](crate::stores::TtlSortedCache) via [`Cached::cache_try_set`] when +/// an entry cannot be stored - currently only when computing the entry's expiry +/// `Instant` overflows. +/// +/// The separate `TtlSortedCacheError` type was removed in favor of this unified type; the +/// following must not compile (guards against the old error type being reintroduced): +/// +/// ```compile_fail +/// use cached::stores::TtlSortedCacheError; +/// ``` +/// ```compile_fail +/// let _ = cached::TtlSortedCacheError::TimeBounds; +/// ``` +#[non_exhaustive] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CacheSetError { + /// Computing the entry's expiry `Instant` overflowed `Instant`'s representable range. + TimeBounds, +} +impl std::fmt::Display for CacheSetError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CacheSetError::TimeBounds => f.write_str("ttl is outside Instant bounds"), + } + } +} +impl std::error::Error for CacheSetError {} + /// Validate that `ttl` is non-zero; used by all TTL-capable store builders. +#[cfg(any( + feature = "time_stores", + feature = "redb_store", + feature = "redis_store" +))] pub(crate) fn validate_ttl(ttl: Duration) -> Result<(), BuildError> { if ttl.is_zero() { - Err(BuildError::InvalidTtl { ttl }) + Err(BuildError::InvalidValue { + field: "ttl", + reason: "must be greater than zero", + }) } else { Ok(()) } } -/// A cached value paired with its insertion timestamp for TTL tracking. +/// A cached value paired with its per-entry expiry instant for TTL tracking. +/// +/// Used internally by [`TtlCache`], [`LruTtlCache`], [`ShardedTtlCache`], and +/// [`ShardedLruTtlCache`]. The `expires_at` field holds the absolute instant at +/// which this entry expires, or `None` if the entry never expires (i.e. the TTL +/// was disabled at insert time via `set_ttl(Duration::ZERO)` / `unset_ttl()`). /// -/// Exposed through `TtlCache::store` and `LruTtlCache::store` for -/// advanced introspection of cache internals. +/// Because expiry is fixed at insertion time, calling `set_ttl` after inserting an +/// entry does **not** retroactively change when existing entries expire. Only newly +/// inserted (or refreshed-on-hit) entries use the TTL current at that point. +#[cfg(feature = "time_stores")] #[derive(Debug)] -pub struct TimedEntry { - /// The instant this entry was inserted (or last refreshed). - pub instant: Instant, +pub(crate) struct TimedEntry { + /// The absolute instant at which this entry expires, or `None` for never. + pub(crate) expires_at: Option, /// The cached value. - pub value: V, + pub(crate) value: V, } +#[cfg(feature = "time_stores")] impl Clone for TimedEntry { fn clone(&self) -> Self { Self { - instant: self.instant, + expires_at: self.expires_at, value: self.value.clone(), } } } -#[cfg(feature = "disk_store")] -#[cfg_attr(docsrs, doc(cfg(feature = "disk_store")))] -pub use crate::stores::redb::{ - DiskCache, DiskCacheBuildError, DiskCacheBuilder, DiskCacheError, RedbCache, - RedbCacheBuildError, RedbCacheBuilder, RedbCacheError, -}; +#[cfg(feature = "redb_store")] +#[cfg_attr(docsrs, doc(cfg(feature = "redb_store")))] +pub use crate::stores::redb::{RedbCache, RedbCacheBuildError, RedbCacheBuilder, RedbCacheError}; #[cfg(feature = "redis_store")] #[cfg_attr(docsrs, doc(cfg(feature = "redis_store")))] pub use crate::stores::redis::{ - RedisCache, RedisCacheBuildError, RedisCacheBuilder, RedisCacheError, + ConnectionString, RedisCache, RedisCacheBuildError, RedisCacheBuilder, RedisCacheError, }; pub use expiring::{ExpiringCache, ExpiringCacheBuilder}; pub use expiring_lru::{Expires, ExpiringLruCache, ExpiringLruCacheBuilder}; @@ -184,14 +322,14 @@ pub use lru_ttl::{HasEvict, LruTtlCache, LruTtlCacheBuilder, NoEvict}; pub use ttl::{TtlCache, TtlCacheBuilder}; #[cfg(feature = "time_stores")] #[cfg_attr(docsrs, doc(cfg(feature = "time_stores")))] -pub use ttl_sorted::{TtlSortedCache, TtlSortedCacheBuilder, TtlSortedCacheError}; +pub use ttl_sorted::{TtlSortedCache, TtlSortedCacheBuilder}; pub use unbound::{UnboundCache, UnboundCacheBuilder}; pub use sharded::{ - DefaultShardHasher, ShardHasher, ShardedCache, ShardedCacheBase, ShardedCacheBuilder, - ShardedExpiringCache, ShardedExpiringCacheBase, ShardedExpiringCacheBuilder, - ShardedExpiringLruCache, ShardedExpiringLruCacheBase, ShardedExpiringLruCacheBuilder, - ShardedLruCache, ShardedLruCacheBase, ShardedLruCacheBuilder, + DefaultShardHasher, ShardHasher, ShardedExpiringCache, ShardedExpiringCacheBase, + ShardedExpiringCacheBuilder, ShardedExpiringLruCache, ShardedExpiringLruCacheBase, + ShardedExpiringLruCacheBuilder, ShardedLruCache, ShardedLruCacheBase, ShardedLruCacheBuilder, + ShardedUnboundCache, ShardedUnboundCacheBase, ShardedUnboundCacheBuilder, }; #[cfg(feature = "time_stores")] #[cfg_attr(docsrs, doc(cfg(feature = "time_stores")))] @@ -203,14 +341,32 @@ pub use sharded::{ #[cfg(all( feature = "async_core", feature = "redis_store", - any(feature = "redis_smol", feature = "redis_tokio") + any( + feature = "redis_smol", + feature = "redis_smol_native_tls", + feature = "redis_smol_rustls", + feature = "redis_tokio", + feature = "redis_tokio_native_tls", + feature = "redis_tokio_rustls", + feature = "redis_async_cache", + feature = "redis_connection_manager" + ) ))] #[cfg_attr( docsrs, doc(cfg(all( feature = "async_core", feature = "redis_store", - any(feature = "redis_smol", feature = "redis_tokio") + any( + feature = "redis_smol", + feature = "redis_smol_native_tls", + feature = "redis_smol_rustls", + feature = "redis_tokio", + feature = "redis_tokio_native_tls", + feature = "redis_tokio_rustls", + feature = "redis_async_cache", + feature = "redis_connection_manager" + ) ))) )] pub use crate::stores::redis::{AsyncRedisCache, AsyncRedisCacheBuilder}; @@ -220,6 +376,8 @@ where K: Hash + Eq, S: std::hash::BuildHasher + Default, { + type Error = std::convert::Infallible; + fn cache_get(&mut self, k: &Q) -> Option<&V> where K: std::borrow::Borrow, @@ -237,10 +395,10 @@ where fn cache_set(&mut self, k: K, v: V) -> Option { HashMap::insert(self, k, v) } - fn cache_get_or_set_with V>(&mut self, key: K, f: F) -> &mut V { + fn cache_get_or_set_with_mut V>(&mut self, key: K, f: F) -> &mut V { self.entry(key).or_insert_with(f) } - fn cache_try_get_or_set_with Result, E>( + fn cache_try_get_or_set_with_mut Result, E>( &mut self, key: K, f: F, @@ -319,7 +477,7 @@ where K: Hash + Eq + Clone + Send, S: std::hash::BuildHasher + Send, { - fn async_get_or_set_with<'a, F, Fut>( + fn async_cache_get_or_set_with_mut<'a, F, Fut>( &'a mut self, k: K, f: F, @@ -338,7 +496,7 @@ where } } - fn async_try_get_or_set_with<'a, F, Fut, E>( + fn async_cache_try_get_or_set_with_mut<'a, F, Fut, E>( &'a mut self, k: K, f: F, @@ -365,20 +523,28 @@ where /// Implementors remove all entries that are past their expiry from the store and /// invoke the `on_evict` callback (if configured) for each removed entry. /// +/// `evict()` is the explicit way to physically remove expired entries, reclaim +/// memory, and obtain an accurate live count. After calling `evict()`, `len()` +/// reflects only live entries. Without it, `len()` may count expired-but-not-yet-swept +/// entries while `iter().count()` omits them - the two can differ on any lazy-eviction +/// store. +/// /// This trait is for in-memory stores with infallible expiration checks. IO-backed /// stores expose their own APIs because sweeping can fail: `RedbCache` uses /// `remove_expired_entries`, while Redis relies on server-side key expiry. pub trait CacheEvict { - /// Remove all expired entries from the cache, returning the number removed. + /// Physically remove all expired entries from the cache and return the count removed. /// - /// Fires the `on_evict` callback and increments `cache_evictions()` for each removed entry. - /// Hit/miss metrics are not affected; call [`cache_reset_metrics`](crate::Cached::cache_reset_metrics) - /// separately if needed. + /// After this call, `len()` reflects only live entries. Fires the `on_evict` callback + /// and increments `cache_evictions()` for each removed entry. Hit/miss metrics are not + /// affected; call [`cache_reset_metrics`](crate::Cached::cache_reset_metrics) separately + /// if needed. /// /// **Note for sharded in-memory stores**: these are internally synchronized and normally held /// behind an `Arc`/`static`, so they cannot offer `&mut self`. They implement /// [`ConcurrentCacheEvict`] (a `&self` counterpart of this trait) instead, and also expose an /// inherent `evict(&self)` method. + #[must_use] fn evict(&mut self) -> usize; } @@ -387,9 +553,18 @@ pub trait CacheEvict { /// Sharded in-memory stores are normally held behind an `Arc`/`static`, so they /// cannot offer the `&mut self` [`CacheEvict::evict`]. This trait provides the same /// operation through a shared reference. +/// +/// `evict()` is the explicit way to physically remove expired entries, reclaim +/// memory, and obtain an accurate live count on the sharded expiry-capable stores +/// (`ShardedTtlCache`, `ShardedLruTtlCache`, `ShardedExpiringCache`, +/// `ShardedExpiringLruCache`). After calling `evict()`, `len()` (the inherent method) +/// reflects only live entries. pub trait ConcurrentCacheEvict { - /// Remove all expired entries, returning the number removed. Fires `on_evict` - /// and increments `cache_evictions()` for each removed entry. + /// Physically remove all expired entries across all shards and return the count removed. + /// + /// After this call, `len()` (the inherent method on sharded stores) reflects only live + /// entries. Fires `on_evict` and increments `cache_evictions()` for each removed entry. + #[must_use] fn evict(&self) -> usize; } @@ -424,4 +599,162 @@ mod tests { "invalid value for field `max_size`: must be greater than zero" ); } + + #[test] + fn cache_set_error_is_clone_eq() { + // Parity with `SetMaxSizeError`/`SetTtlError`, which already derive these. + assert_eq!(CacheSetError::TimeBounds, CacheSetError::TimeBounds.clone()); + } + + // Compile-time assertion: BuildError must implement Clone + PartialEq + Eq. + fn _assert_build_error_bounds() {} + fn _check_build_error() { + _assert_build_error_bounds::(); + } + + #[test] + fn build_error_clone_partial_eq_eq() { + // Two equal MissingRequired values compare equal and survive a clone round-trip. + let a = BuildError::MissingRequired("ttl"); + let b = a.clone(); + assert_eq!(a, b); + + // Two equal InvalidValue values compare equal and survive a clone round-trip. + let c = BuildError::InvalidValue { + field: "max_size", + reason: "must be greater than zero", + }; + let d = c.clone(); + assert_eq!(c, d); + + // Different variants are not equal. + assert_ne!( + BuildError::MissingRequired("ttl"), + BuildError::InvalidValue { + field: "ttl", + reason: "must be greater than zero", + }, + ); + + // Different field names inside the same variant are not equal. + assert_ne!( + BuildError::MissingRequired("ttl"), + BuildError::MissingRequired("max_size"), + ); + } + + // The author's `build_error_clone_partial_eq_eq` exercises clone/eq on both + // variants, a cross-variant `assert_ne!`, and differing fields *within* + // `MissingRequired`. The remaining derived-PartialEq paths for the struct + // variant `InvalidValue` were not checked: equality must depend on BOTH the + // `field` and the `reason` field. These would not fail to compile if the + // derive were reverted (the wrapper enums prove `BuildError` already had a + // hand-rolled `Debug`), so each comparison below is value-level and bites if + // the `PartialEq`/`Eq`/`Clone` derive is removed. + #[test] + fn build_error_invalid_value_field_discriminates() { + // Same `reason`, different `field` => not equal. + assert_ne!( + BuildError::InvalidValue { + field: "max_size", + reason: "must be greater than zero", + }, + BuildError::InvalidValue { + field: "ttl", + reason: "must be greater than zero", + }, + ); + + // Same `field`, different `reason` => not equal. + assert_ne!( + BuildError::InvalidValue { + field: "max_size", + reason: "must be greater than zero", + }, + BuildError::InvalidValue { + field: "max_size", + reason: "allocation failed", + }, + ); + + // Fully equal struct variants compare equal (and the clone matches). + let a = BuildError::InvalidValue { + field: "max_size", + reason: "must be greater than zero", + }; + assert_eq!(a, a.clone()); + } + + // The consumer-facing point of the derive: a real builder failure can be + // compared with `assert_eq!`/matched, and cloned. `LruCacheBuilder::build` + // is feature-free and produces both `BuildError` variants directly. + #[test] + fn lru_build_error_is_comparable_and_cloneable() { + // Missing required field. + let missing = LruCache::::builder().build().unwrap_err(); + assert_eq!(missing, BuildError::MissingRequired("max_size")); + // assert_eq! over a clone of a real builder error. + assert_eq!(missing, missing.clone()); + + // Invalid value (zero capacity). + let invalid = LruCache::::builder() + .max_size(0) + .build() + .unwrap_err(); + assert_eq!( + invalid, + BuildError::InvalidValue { + field: "max_size", + reason: "must be greater than zero", + } + ); + + // The two real failures are distinct. + assert_ne!(missing, invalid); + } + + // Wrapper enums (RedisCacheBuildError / RedbCacheBuildError) INTENTIONALLY do + // NOT derive Clone/PartialEq/Eq (their other variants wrap non-Clone/Eq + // errors). Assert only that the config path still surfaces the embedded + // `BuildError` and that Debug/Display work. These tests need a live service + // only past the field validation, which the builders run first, so no + // network/disk is touched. + #[cfg(feature = "redis_store")] + #[test] + fn redis_build_error_wraps_build_error_without_clone_eq() { + // No prefix set => Build(BuildError::MissingRequired("prefix")) before any IO. + let err = RedisCacheBuilder::::new().build().unwrap_err(); + assert!( + matches!( + err, + RedisCacheBuildError::Build(BuildError::MissingRequired("prefix")) + ), + "expected Build(MissingRequired(\"prefix\")), got {err:?}" + ); + // Debug and Display are available on the wrapper (transparent to BuildError). + assert!(!format!("{err:?}").is_empty()); + assert_eq!( + err.to_string(), + BuildError::MissingRequired("prefix").to_string() + ); + } + + #[cfg(feature = "redb_store")] + #[test] + fn redb_build_error_wraps_build_error_without_clone_eq() { + // No name set => Build(BuildError::MissingRequired("name")) before any IO. + let err = RedbCacheBuilder::::new().build().unwrap_err(); + assert!( + matches!( + err, + RedbCacheBuildError::Build(BuildError::MissingRequired("name")) + ), + "expected Build(MissingRequired(\"name\")), got {err:?}" + ); + assert!(!format!("{err:?}").is_empty()); + assert_eq!( + err.to_string(), + BuildError::MissingRequired("name").to_string() + ); + } } diff --git a/src/stores/redb.rs b/src/stores/redb.rs index 6e09199a..4fbdee30 100644 --- a/src/stores/redb.rs +++ b/src/stores/redb.rs @@ -1,6 +1,6 @@ -use crate::ConcurrentCached; use crate::time::Duration; use crate::time::SystemTime; +use crate::{ConcurrentCacheBase, ConcurrentCacheTtl, ConcurrentCached}; use directories::BaseDirs; use parking_lot::Mutex; use redb::{Builder, Database, Durability, ReadableDatabase, ReadableTable, TableDefinition}; @@ -22,7 +22,7 @@ pub struct RedbCacheBuilder { refresh: bool, durable: bool, disk_dir: Option, - cache_name: String, + cache_name: Option, // fn-pointer phantom — see the rationale on `RedbCache::_phantom`; keeps the // type unconditionally `Send + Sync` regardless of `K`/`V`. _phantom: PhantomData (K, V)>, @@ -44,15 +44,40 @@ macro_rules! impl_from_redb { }; } +/// Error returned when building a [`RedbCache`]. +/// +/// Configuration problems (a missing `name`, or a zero `ttl`) surface as the transparent +/// [`Build`](Self::Build) variant wrapping a [`BuildError`](super::BuildError): +/// +/// ```ignore +/// match RedbCache::::builder().build() { +/// Err(RedbCacheBuildError::Build(BuildError::MissingRequired(field))) => { /* e.g. "name" */ } +/// Err(RedbCacheBuildError::Build(BuildError::InvalidValue { field, reason })) => { /* e.g. "ttl" */ } +/// _ => {} +/// } +/// ``` #[non_exhaustive] #[derive(Error, Debug)] pub enum RedbCacheBuildError { - #[error("Storage connection error")] - Connection(#[from] redb::Error), + #[error("Storage error")] + Storage { + #[from] + source: redb::Error, + }, #[error(transparent)] - InvalidTtl(#[from] super::BuildError), + Build(#[from] super::BuildError), #[error("I/O error preparing the disk cache directory")] Io(#[from] std::io::Error), + /// The `cache_name` passed to [`RedbCacheBuilder`] is invalid: it must not be empty, + /// must not contain a path separator (`/` or `\`), must not contain a NUL byte (`\0`), + /// and must not be `.` or `..`. + /// These characters would allow the name to escape the cache directory, embed a NUL + /// in the filename, or produce a meaningless filename when used as a filename component. + #[error( + "invalid cache_name: must not be empty, must not contain a path separator ('/' or '\\\\'), \ + must not contain a NUL byte, and must not be '.' or '..'; cache_name is used as a filename component" + )] + InvalidCacheName, } impl_from_redb!( @@ -68,31 +93,73 @@ static DISK_FILE_PREFIX: &str = "cached_disk_cache"; // per-entry `version` field), so an incompatible older file is never read. const DISK_FILE_VERSION: u64 = 3; +impl Default for RedbCacheBuilder +where + K: ToString, + V: Serialize + DeserializeOwned, +{ + fn default() -> Self { + Self::new() + } +} + impl RedbCacheBuilder where K: ToString, V: Serialize + DeserializeOwned, { - /// Initialize a `RedbCacheBuilder` + /// Initialize a `RedbCacheBuilder`. + /// + /// The cache name is required; set it with [`name`](Self::name) before calling + /// [`build`](Self::build). #[must_use] - pub fn new>(cache_name: S) -> RedbCacheBuilder { + pub fn new() -> RedbCacheBuilder { Self { ttl: None, refresh: false, durable: true, disk_dir: None, - cache_name: cache_name.as_ref().to_string(), + cache_name: None, _phantom: Default::default(), } } + /// Set the cache name (required). Used as a filename component for the on-disk + /// database file, so it must not be empty, contain a path separator (`/` or `\`), + /// contain a NUL byte, or be `.` or `..`. + #[must_use] + pub fn name(mut self, name: impl Into) -> Self { + self.cache_name = Some(name.into()); + self + } + /// Specify the cache TTL as a `Duration`. + /// + /// Overrides any previously set ttl/ttl_secs/ttl_millis on this builder. #[must_use] pub fn ttl(mut self, ttl: Duration) -> Self { self.ttl = Some(ttl); self } + /// Specify the cache TTL in whole seconds. Equivalent to + /// `ttl(Duration::from_secs(secs))`. + /// + /// Overrides any previously set ttl/ttl_secs/ttl_millis on this builder. + #[must_use] + pub fn ttl_secs(self, secs: u64) -> Self { + self.ttl(Duration::from_secs(secs)) + } + + /// Specify the cache TTL in milliseconds. Equivalent to + /// `ttl(Duration::from_millis(millis))`. + /// + /// Overrides any previously set ttl/ttl_secs/ttl_millis on this builder. + #[must_use] + pub fn ttl_millis(self, millis: u64) -> Self { + self.ttl(Duration::from_millis(millis)) + } + /// Specify whether cache hits refresh the TTL #[must_use] pub fn refresh_on_hit(mut self, refresh: bool) -> Self { @@ -168,11 +235,47 @@ where })) } + /// Build the `RedbCache`, validating configuration and opening (or creating) + /// the on-disk redb database file. + /// + /// # Errors + /// + /// - `Build(BuildError::MissingRequired("name"))`: no cache name was set. + /// - `InvalidCacheName`: `cache_name` is empty, contains a path separator + /// (`/` or `\`), contains a NUL byte, or is the path-traversal component `.` or `..`. + /// - `Build(BuildError::InvalidValue { field: "ttl", .. })`: the configured TTL is zero. + /// - `Io`: the cache directory could not be created. + /// - `Storage`: the redb database file could not be opened or initialized. pub fn build(self) -> Result, RedbCacheBuildError> { + let cache_name = self + .cache_name + .ok_or(super::BuildError::MissingRequired("name"))?; + // Validate cache_name before using it as a filename component. + // An empty name yields a meaningless filename. A name containing a path + // separator ('/' or '\\') or a NUL byte can silently escape the cache + // directory or create nested subdirectories; those are the checks that + // actually prevent traversal. The '.' and '..' checks are + // belt-and-suspenders: because the name is always suffixed with + // `_v.redb`, a bare '.' or '..' can never reach the filesystem + // as a traversal component, but they are rejected anyway as nonsensical + // names. (':' is allowed: it is established usage in + // module-path-derived names.) + { + let n = &cache_name; + if n.is_empty() + || n.contains('/') + || n.contains('\\') + || n.contains('\0') + || n == "." + || n == ".." + { + return Err(RedbCacheBuildError::InvalidCacheName); + } + } if let Some(ttl) = self.ttl { super::validate_ttl(ttl)?; } - let cache_dir_name = format!("{}_v{}", self.cache_name, DISK_FILE_VERSION); + let cache_dir_name = format!("{}_v{}", cache_name, DISK_FILE_VERSION); // redb stores a single file. Resolve the directory (explicit or // default), ensure it exists, then use `.redb` inside it @@ -217,7 +320,8 @@ where /// in its own write transaction, and write transactions on one `RedbCache` are serialized /// (only one commits at a time). Reads are MVCC: they run concurrently with each other and /// with a writer, so they never block. The async operations run the blocking redb work on a -/// `spawn_blocking` thread, so concurrent async writers also queue behind the single writer. +/// background thread (via [`blocking::unblock`]), so concurrent async writers also queue +/// behind the single writer. /// /// This suits read-heavy caching. If a single `RedbCache` is written from many threads at /// once, write throughput is bounded by the serialized writer. To reduce that cost, spread @@ -240,22 +344,31 @@ pub struct RedbCache { _phantom: PhantomData (K, V)>, } +impl std::fmt::Debug for RedbCache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RedbCache") + .field("disk_path", &self.disk_path) + .field("ttl", &*self.ttl.lock()) + .field("refresh", &self.refresh.load(Ordering::Relaxed)) + .field("durable", &self.durable) + .finish_non_exhaustive() + } +} + impl RedbCache where K: ToString, V: Serialize + DeserializeOwned, { - #[allow(clippy::new_ret_no_self)] - /// Initialize a `RedbCacheBuilder`. - #[must_use] - pub fn new(cache_name: &str) -> RedbCacheBuilder { - RedbCacheBuilder::new(cache_name) - } - /// Initialize a `RedbCacheBuilder`. + /// + /// The cache name is required; set it via [`RedbCacheBuilder::name`] before + /// calling [`build`](RedbCacheBuilder::build). If it is missing, `build` returns + /// `Err(`[`BuildError::MissingRequired`](super::BuildError::MissingRequired)`)` rather + /// than panicking. #[must_use] - pub fn builder(cache_name: &str) -> RedbCacheBuilder { - RedbCacheBuilder::new(cache_name) + pub fn builder() -> RedbCacheBuilder { + RedbCacheBuilder::new() } /// Return the path of the on-disk redb database file backing this cache. @@ -280,7 +393,14 @@ where let table = rtxn.open_table(TABLE)?; for item in table.iter()? { let (key, value) = item?; - let cached = rmp_serde::from_slice::>(value.value())?; + let raw = value.value(); + let cached = + rmp_serde::from_slice::>(raw).map_err(|source| { + RedbCacheError::CacheDeserialization { + source, + cached_value: raw.to_vec(), + } + })?; if let Some(ttl) = ttl && now .duration_since(cached.created_at) @@ -327,12 +447,11 @@ where #[cfg_attr(docsrs, doc(cfg(feature = "async")))] impl RedbCache { /// Async counterpart of [`flush`](RedbCache::flush): runs the durable (fsync) - /// commit on a `spawn_blocking` thread so it does not stall the async runtime. + /// commit on a background thread (via the [`blocking`] crate) so it does not + /// stall the async runtime. pub async fn async_flush(&self) -> Result<(), RedbCacheError> { let connection = self.connection.clone(); - tokio::task::spawn_blocking(move || redb_flush(&connection)) - .await - .map_err(|_| RedbCacheError::BackgroundTaskFailed)? + blocking::unblock(move || redb_flush(&connection)).await } } @@ -340,22 +459,21 @@ impl RedbCache { #[derive(Error, Debug)] pub enum RedbCacheError { #[error("Storage error")] - Storage(#[from] redb::Error), + Storage { + #[from] + source: redb::Error, + }, #[error("Error deserializing cached value")] - CacheDeserialization(#[from] rmp_serde::decode::Error), + CacheDeserialization { + #[source] + source: rmp_serde::decode::Error, + cached_value: Vec, + }, #[error("Error serializing cached value")] - CacheSerialization(#[from] rmp_serde::encode::Error), - /// The blocking task used to run `redb` I/O off the async runtime was - /// cancelled or panicked. Only produced by the async - /// (`ConcurrentCachedAsync`) path. - /// - /// Effectively unreachable in normal operation: the blocking work is itself - /// fallible and returns the variants above, so this surfaces only if the - /// Tokio runtime aborts/cancels the blocking task (e.g. runtime shutdown). - /// The underlying `JoinError` is intentionally not carried, to keep - /// `tokio` out of this (sync-shared) public error type. - #[error("disk cache background task failed")] - BackgroundTaskFailed, + CacheSerialization { + #[from] + source: rmp_serde::encode::Error, + }, } impl_from_redb!( @@ -367,26 +485,6 @@ impl_from_redb!( redb::SetDurabilityError, ); -// ── Back-compat aliases ────────────────────────────────────────────────────── -// -// `DiskCache` was renamed to `RedbCache` so the type names its backend -// explicitly (like `RedisCache`). These aliases are kept for convenience and -// back-compat so existing code keeps compiling; they are intentionally NOT -// deprecated. - -/// `DiskCache` was renamed to [`RedbCache`] to name its backend explicitly -/// (like `RedisCache`). This alias is kept for convenience/back-compat. -pub type DiskCache = RedbCache; -/// `DiskCacheBuilder` was renamed to [`RedbCacheBuilder`] to name its backend -/// explicitly (like `RedisCache`). This alias is kept for convenience/back-compat. -pub type DiskCacheBuilder = RedbCacheBuilder; -/// `DiskCacheError` was renamed to [`RedbCacheError`] to name its backend -/// explicitly (like `RedisCache`). This alias is kept for convenience/back-compat. -pub type DiskCacheError = RedbCacheError; -/// `DiskCacheBuildError` was renamed to [`RedbCacheBuildError`] to name its backend -/// explicitly (like `RedisCache`). This alias is kept for convenience/back-compat. -pub type DiskCacheBuildError = RedbCacheBuildError; - #[derive(serde::Serialize, serde::Deserialize)] struct CachedDiskValue { value: V, @@ -406,12 +504,31 @@ impl CachedDiskValue { } } +/// Borrowed counterpart of [`CachedDiskValue`] used by `cache_set_ref` to +/// serialize from a `&V` without cloning. It serializes to the same bytes as +/// `CachedDiskValue::new(value)` (same field names and order), so values written +/// through either path deserialize identically. +#[derive(serde::Serialize)] +struct CachedDiskValueRef<'a, V> { + value: &'a V, + created_at: SystemTime, +} + +impl<'a, V> CachedDiskValueRef<'a, V> { + fn new(value: &'a V) -> Self { + Self { + value, + created_at: SystemTime::now(), + } + } +} + // ── Connection-level disk operations ───────────────────────────────────────── // // These free functions hold the single source of truth for the on-disk // behavior (TTL/refresh handling, serialization-error propagation, durability). // The synchronous `ConcurrentCached` impl calls them directly; the async -// `ConcurrentCachedAsync` impl calls them inside `tokio::task::spawn_blocking` so +// `ConcurrentCachedAsync` impl calls them inside `blocking::unblock` so // the blocking `redb` I/O does not stall the async runtime. Keeping one // implementation guarantees the sync and async paths stay behaviorally // identical. @@ -451,7 +568,13 @@ where return Ok(None); }; // Deserialize before the guard/table/txn are dropped. - rmp_serde::from_slice::>(guard.value())? + let raw = guard.value(); + rmp_serde::from_slice::>(raw).map_err(|source| { + RedbCacheError::CacheDeserialization { + source, + cached_value: raw.to_vec(), + } + })? }; if let Some(ttl) = ttl { @@ -622,13 +745,47 @@ fn redb_flush(connection: &Database) -> Result<(), RedbCacheError> { /// `cache_get` can additionally surface a [`RedbCacheError::CacheSerialization`] when /// `refresh_on_hit` is enabled and re-serializing the just-read entry to rewrite its /// refreshed expiry fails. +impl ConcurrentCacheBase for RedbCache { + type Error = RedbCacheError; +} + +impl ConcurrentCacheTtl for RedbCache { + fn ttl(&self) -> Option { + *self.ttl.lock() + } + + /// Set the TTL applied to newly inserted entries, returning the previous TTL + /// (`None` if expiry was disabled). + /// + /// A zero `ttl` disables expiry, exactly equivalent to `unset_ttl`: subsequent writes + /// store entries with no expiry. Existing entries keep the expiry they were written with. + fn set_ttl(&self, ttl: Duration) -> Option { + let mut guard = self.ttl.lock(); + if ttl.is_zero() { + guard.take() + } else { + guard.replace(ttl) + } + } + + fn unset_ttl(&self) -> Option { + self.ttl.lock().take() + } + + fn refresh_on_hit(&self) -> bool { + self.refresh.load(Ordering::Relaxed) + } + + fn set_refresh_on_hit(&self, refresh: bool) -> bool { + self.refresh.swap(refresh, Ordering::Relaxed) + } +} + impl ConcurrentCached for RedbCache where K: ToString + Clone, V: Serialize + DeserializeOwned, { - type Error = RedbCacheError; - fn cache_get(&self, key: &K) -> Result, RedbCacheError> { let ttl = *self.ttl.lock(); let refresh = self.refresh.load(Ordering::Relaxed); @@ -678,47 +835,39 @@ where fn cache_reset(&self) -> Result<(), RedbCacheError> { disk_cache_clear(&self.connection, self.durable) } +} - fn ttl(&self) -> Option { - *self.ttl.lock() - } - - fn set_ttl(&self, ttl: Duration) -> Option { - self.ttl.lock().replace(ttl) - } - - fn set_refresh_on_hit(&self, refresh: bool) -> bool { - self.refresh.swap(refresh, Ordering::Relaxed) - } - - fn unset_ttl(&self) -> Option { - self.ttl.lock().take() +impl crate::SerializeCached for RedbCache +where + K: ToString + Clone, + V: Serialize + DeserializeOwned, +{ + /// Serializes from the borrowed `value` (no clone) and writes it under + /// `key.to_string()`, returning the previous value if any. Equivalent to + /// [`ConcurrentCached::cache_set`] but avoids taking ownership of `value`. + fn cache_set_ref(&self, key: &K, value: &V) -> Result, RedbCacheError> { + let serialized = rmp_serde::to_vec(&CachedDiskValueRef::new(value))?; + disk_cache_set(&self.connection, &key.to_string(), serialized, self.durable) } } /// Async disk cache. `redb` has no async API, so every operation is run on -/// `tokio`'s blocking thread pool via [`tokio::task::spawn_blocking`] to avoid -/// stalling the async runtime. Behavior is identical to the synchronous +/// a background thread via [`blocking::unblock`] to avoid stalling the async +/// runtime. This is runtime-agnostic: it works with any async executor (tokio, +/// async-std, smol, etc.). Behavior is identical to the synchronous /// [`ConcurrentCached`] impl (they share the `disk_cache_*` helpers). /// /// Values need only be `Send`, **not `Sync`**: they are serialized before the /// work moves onto the blocking pool, so no `V` is held across the `.await` -/// (only the owned serialized bytes / the `JoinHandle, _>>`). +/// (only the owned serialized bytes). /// Keys keep `Send + Sync` (the `&K` is borrowed across the await), consistent /// with the `RedisCache`/`AsyncRedisCache` async stores. /// /// Cancellation: dropping the returned future does **not** cancel the in-flight -/// `spawn_blocking` `redb` operation — it runs to completion on the blocking -/// pool (only the result is discarded). This is safe for a cache (`redb` +/// blocking `redb` operation — it runs to completion on the background thread +/// (only the result is discarded). This is safe for a cache (`redb` /// transactions are atomic, so no corruption), but a cancelled `cache_set`/ /// `cache_remove` may still have taken effect on disk. -/// -/// **Concurrency note:** each call spawns a new blocking task on tokio's blocking -/// thread pool (default limit: 512 threads). Under high concurrency this pool can -/// saturate, causing subsequent `spawn_blocking` calls to queue. If your workload -/// issues many concurrent disk-cache operations, tune the pool with -/// `tokio::runtime::Builder::max_blocking_threads` or consider an explicit -/// rate-limiting layer above the cache. #[cfg(feature = "async")] #[cfg_attr(docsrs, doc(cfg(feature = "async")))] impl crate::ConcurrentCachedAsync for RedbCache @@ -726,8 +875,6 @@ where K: ToString + Clone + Send + Sync, V: Serialize + DeserializeOwned + Send + 'static, { - type Error = RedbCacheError; - async fn async_cache_get(&self, key: &K) -> Result, RedbCacheError> { let connection = self.connection.clone(); let key = key.to_string(); @@ -736,11 +883,8 @@ where self.refresh.load(Ordering::Relaxed), self.durable, ); - tokio::task::spawn_blocking(move || { - disk_cache_get::(&connection, &key, ttl, refresh, durable) - }) - .await - .map_err(|_| RedbCacheError::BackgroundTaskFailed)? + blocking::unblock(move || disk_cache_get::(&connection, &key, ttl, refresh, durable)) + .await } async fn async_cache_set(&self, key: K, value: V) -> Result, RedbCacheError> { @@ -748,31 +892,23 @@ where let key = key.to_string(); let durable = self.durable; let serialized = rmp_serde::to_vec(&CachedDiskValue::new(value))?; - tokio::task::spawn_blocking(move || { - disk_cache_set::(&connection, &key, serialized, durable) - }) - .await - .map_err(|_| RedbCacheError::BackgroundTaskFailed)? + blocking::unblock(move || disk_cache_set::(&connection, &key, serialized, durable)).await } async fn async_cache_remove(&self, key: &K) -> Result, RedbCacheError> { let connection = self.connection.clone(); let key = key.to_string(); let (ttl, durable) = (*self.ttl.lock(), self.durable); - tokio::task::spawn_blocking(move || disk_cache_remove::(&connection, &key, ttl, durable)) - .await - .map_err(|_| RedbCacheError::BackgroundTaskFailed)? + blocking::unblock(move || disk_cache_remove::(&connection, &key, ttl, durable)).await } async fn async_cache_remove_entry(&self, key: &K) -> Result, Self::Error> { let connection = self.connection.clone(); let key_str = key.to_string(); let durable = self.durable; - let v: Option = tokio::task::spawn_blocking(move || { - disk_cache_remove_entry::(&connection, &key_str, durable) - }) - .await - .map_err(|_| RedbCacheError::BackgroundTaskFailed)??; + let v: Option = + blocking::unblock(move || disk_cache_remove_entry::(&connection, &key_str, durable)) + .await?; Ok(v.map(|v| (key.clone(), v))) } @@ -780,20 +916,16 @@ where let connection = self.connection.clone(); let key = key.to_string(); let durable = self.durable; - tokio::task::spawn_blocking(move || disk_cache_delete(&connection, &key, durable)) - .await - .map_err(|_| RedbCacheError::BackgroundTaskFailed)? + blocking::unblock(move || disk_cache_delete(&connection, &key, durable)).await } /// Async counterpart of [`ConcurrentCached::cache_clear`]: clears the - /// on-disk table off the async runtime via `spawn_blocking` (durability per - /// `durable`). + /// on-disk table off the async runtime via a background thread (durability + /// per `durable`). async fn async_cache_clear(&self) -> Result<(), RedbCacheError> { let connection = self.connection.clone(); let durable = self.durable; - tokio::task::spawn_blocking(move || disk_cache_clear(&connection, durable)) - .await - .map_err(|_| RedbCacheError::BackgroundTaskFailed)? + blocking::unblock(move || disk_cache_clear(&connection, durable)).await } /// Async counterpart of [`ConcurrentCached::cache_reset`]. `RedbCache` @@ -802,25 +934,40 @@ where async fn async_cache_reset(&self) -> Result<(), RedbCacheError> { let connection = self.connection.clone(); let durable = self.durable; - tokio::task::spawn_blocking(move || disk_cache_clear(&connection, durable)) - .await - .map_err(|_| RedbCacheError::BackgroundTaskFailed)? - } - - fn set_refresh_on_hit(&self, refresh: bool) -> bool { - self.refresh.swap(refresh, Ordering::Relaxed) - } - - fn ttl(&self) -> Option { - *self.ttl.lock() - } - - fn set_ttl(&self, ttl: Duration) -> Option { - self.ttl.lock().replace(ttl) + blocking::unblock(move || disk_cache_clear(&connection, durable)).await } +} - fn unset_ttl(&self) -> Option { - self.ttl.lock().take() +#[cfg(feature = "async")] +#[cfg_attr(docsrs, doc(cfg(feature = "async")))] +impl crate::SerializeCachedAsync for RedbCache +where + K: ToString + Clone + Send + Sync, + V: Serialize + DeserializeOwned + Send + 'static, +{ + /// Serializes from the borrowed `value` (no clone) before moving the bytes + /// onto the background thread. Async counterpart of + /// [`SerializeCached::cache_set_ref`](crate::SerializeCached::cache_set_ref). + /// + /// Serialization happens eagerly (before the returned future is awaited) so the + /// borrowed `&V` is never held across the `.await`. This keeps the `V: Send` + /// (not `Sync`) bound consistent with `async_cache_set`. + fn async_cache_set_ref( + &self, + key: &K, + value: &V, + ) -> impl std::future::Future, RedbCacheError>> + Send { + let connection = self.connection.clone(); + let key = key.to_string(); + let durable = self.durable; + // Serialize eagerly; defer any error into the future. + let serialized = rmp_serde::to_vec(&CachedDiskValueRef::new(value)) + .map_err(|source| RedbCacheError::CacheSerialization { source }); + async move { + let serialized = serialized?; + blocking::unblock(move || disk_cache_set::(&connection, &key, serialized, durable)) + .await + } } } @@ -842,6 +989,82 @@ mod tests { }; } + #[test] + fn ttl_secs_and_ttl_millis_set_duration() { + // No disk needed -- inspect the builder's ttl field without calling build(). + let b = RedbCache::::builder() + .name("ttl-secs-builder") + .ttl_secs(7); + assert_eq!(b.ttl, Some(Duration::from_secs(7))); + + let b = RedbCache::::builder() + .name("ttl-millis-builder") + .ttl_millis(250); + assert_eq!(b.ttl, Some(Duration::from_millis(250))); + } + + #[test] + fn ttl_setters_override_last_writer_wins() { + // ttl(secs=10) then ttl_secs(5) -> 5s + let b = RedbCache::::builder() + .name("ttl-override-a") + .ttl(Duration::from_secs(10)) + .ttl_secs(5); + assert_eq!(b.ttl, Some(Duration::from_secs(5))); + + // ttl_secs then ttl_millis -> the millis value + let b = RedbCache::::builder() + .name("ttl-override-b") + .ttl_secs(10) + .ttl_millis(500); + assert_eq!(b.ttl, Some(Duration::from_millis(500))); + + // ttl_millis then ttl -> the ttl value + let b = RedbCache::::builder() + .name("ttl-override-c") + .ttl_millis(500) + .ttl(Duration::from_secs(3)); + assert_eq!(b.ttl, Some(Duration::from_secs(3))); + } + + #[test] + fn new_returns_ready_cache_via_builder_with_ttl_secs() { + // RedbCache has no `new()` (builder-only); the ttl_secs convenience + // setter produces a working disk cache that respects the TTL. + let dir = temp_dir!(); + let cache: RedbCache = RedbCache::builder() + .name("ttl-secs-roundtrip") + .disk_directory(dir.path()) + .ttl_secs(60) + .build() + .expect("build must succeed"); + assert_eq!(cache.cache_set(1, 100).unwrap(), None); + assert_eq!(cache.cache_get(&1).unwrap(), Some(100)); + } + + #[test] + fn set_ttl_zero_disables_expiry() { + // `set_ttl(Duration::ZERO)` must disable expiry (== `unset_ttl`), not make + // entries expire immediately: an entry written under a short ttl survives well + // past it once expiry is disabled. + let dir = temp_dir!(); + let cache: RedbCache = RedbCache::builder() + .name("set-ttl-zero-disables") + .disk_directory(dir.path()) + .ttl_millis(20) + .build() + .expect("build must succeed"); + assert_eq!(cache.cache_set(1, 100).unwrap(), None); + // Disabling returns the prior ttl, and `ttl()` then reports `None`. + assert_eq!( + cache.set_ttl(Duration::ZERO), + Some(Duration::from_millis(20)) + ); + assert_eq!(cache.ttl(), None); + std::thread::sleep(Duration::from_millis(60)); + assert_eq!(cache.cache_get(&1).unwrap(), Some(100)); + } + // ── Test helpers for poking raw bytes into / out of the redb table ────── // // Used to plant corrupt/fixture bytes directly. They operate on the same @@ -921,13 +1144,13 @@ mod tests { #[test] fn cache_get_returns_serialize_error_when_refresh_fails() { let tmp_dir = temp_dir!(); - let cache: RedbCache = - RedbCache::new("serialize_error_on_refresh") - .disk_directory(tmp_dir.path()) - .ttl(Duration::from_secs(10)) - .refresh_on_hit(true) - .build() - .expect("error building disk cache"); + let cache: RedbCache = RedbCache::builder() + .name("serialize_error_on_refresh") + .disk_directory(tmp_dir.path()) + .ttl(Duration::from_secs(10)) + .refresh_on_hit(true) + .build() + .expect("error building disk cache"); let cached = CachedDiskValue::new(SerializeFailsAfterDeserialize { fail: false }); raw_insert( &cache, @@ -937,14 +1160,15 @@ mod tests { assert!(matches!( cache.cache_get(&TEST_KEY), - Err(RedbCacheError::CacheSerialization(_)) + Err(RedbCacheError::CacheSerialization { .. }) )); } #[test] fn cache_get_returns_decode_error_for_corrupted_value() { let tmp_dir = temp_dir!(); - let cache: RedbCache = RedbCache::new("corrupted-cache-get") + let cache: RedbCache = RedbCache::builder() + .name("corrupted-cache-get") .disk_directory(tmp_dir.path()) .build() .expect("error building disk cache"); @@ -952,7 +1176,7 @@ mod tests { assert!(matches!( cache.cache_get(&TEST_KEY), - Err(RedbCacheError::CacheDeserialization(_)) + Err(RedbCacheError::CacheDeserialization { .. }) )); assert!(raw_get(&cache, &TEST_KEY.to_string()).is_some()); } @@ -960,7 +1184,8 @@ mod tests { #[test] fn cache_delete_removes_corrupted_value_without_decoding() { let tmp_dir = temp_dir!(); - let cache: RedbCache = RedbCache::new("corrupted-cache-delete") + let cache: RedbCache = RedbCache::builder() + .name("corrupted-cache-delete") .disk_directory(tmp_dir.path()) .build() .expect("error building disk cache"); @@ -974,7 +1199,8 @@ mod tests { #[test] fn cache_set_overwrites_corrupted_value() { let tmp_dir = temp_dir!(); - let cache: RedbCache = RedbCache::new("corrupted-cache-set") + let cache: RedbCache = RedbCache::builder() + .name("corrupted-cache-set") .disk_directory(tmp_dir.path()) .build() .expect("error building disk cache"); @@ -989,7 +1215,8 @@ mod tests { #[test] fn cache_remove_removes_corrupted_value() { let tmp_dir = temp_dir!(); - let cache: RedbCache = RedbCache::new("corrupted-cache-remove") + let cache: RedbCache = RedbCache::builder() + .name("corrupted-cache-remove") .disk_directory(tmp_dir.path()) .build() .expect("error building disk cache"); @@ -1004,7 +1231,8 @@ mod tests { #[test] fn cache_remove_entry_round_trips_and_removes_corrupted_value() { let tmp_dir = temp_dir!(); - let cache: RedbCache = RedbCache::new("remove-entry-roundtrip") + let cache: RedbCache = RedbCache::builder() + .name("remove-entry-roundtrip") .disk_directory(tmp_dir.path()) .build() .expect("error building disk cache"); @@ -1029,7 +1257,8 @@ mod tests { #[test] fn cache_remove_entry_returns_expired_but_present_entry() { let tmp_dir = temp_dir!(); - let cache: RedbCache = RedbCache::new("remove-entry-expired") + let cache: RedbCache = RedbCache::builder() + .name("remove-entry-expired") .disk_directory(tmp_dir.path()) .ttl(LIFE_SPAN_1_SEC) .build() @@ -1051,7 +1280,8 @@ mod tests { fn flush_forces_durable_commit_and_preserves_data() { let tmp_dir = temp_dir!(); // Opt into Durability::None writes so flush() has buffered writes to persist. - let cache: RedbCache = RedbCache::new("flush-test") + let cache: RedbCache = RedbCache::builder() + .name("flush-test") .disk_directory(tmp_dir.path()) .durable(false) .build() @@ -1072,14 +1302,16 @@ mod tests { // writes are present. (The fsync itself is not observable from a graceful // in-process reopen, so this checks the round-trip, not crash durability.) drop(cache); - let reopened: RedbCache = RedbCache::new("flush-test") + let reopened: RedbCache = RedbCache::builder() + .name("flush-test") .disk_directory(tmp_dir.path()) .build() .expect("error re-opening cache"); assert_that!(reopened.cache_get(&TEST_KEY), ok(some(eq(&TEST_VAL)))); // flush is a safe no-op on an already-durable cache, and on an empty cache. - let durable: RedbCache = RedbCache::new("flush-test-durable") + let durable: RedbCache = RedbCache::builder() + .name("flush-test-durable") .disk_directory(tmp_dir.path()) .durable(true) .build() @@ -1102,7 +1334,8 @@ mod tests { let dir_a = temp_dir!(); let src = dir_a.path().join(&file_name); - let a: RedbCache = RedbCache::new(NAME) + let a: RedbCache = RedbCache::builder() + .name(NAME) .disk_directory(dir_a.path()) .durable(false) // opt into Durability::None writes .build() @@ -1112,7 +1345,8 @@ mod tests { // Snapshot before flush: a fresh instance on the copy must NOT see the entry. let dir_before = temp_dir!(); std::fs::copy(&src, dir_before.path().join(&file_name)).unwrap(); - let before: RedbCache = RedbCache::new(NAME) + let before: RedbCache = RedbCache::builder() + .name(NAME) .disk_directory(dir_before.path()) .build() .unwrap(); @@ -1126,7 +1360,8 @@ mod tests { a.flush().unwrap(); let dir_after = temp_dir!(); std::fs::copy(&src, dir_after.path().join(&file_name)).unwrap(); - let after: RedbCache = RedbCache::new(NAME) + let after: RedbCache = RedbCache::builder() + .name(NAME) .disk_directory(dir_after.path()) .build() .unwrap(); @@ -1140,7 +1375,8 @@ mod tests { #[test] fn remove_expired_entries_returns_decode_error_for_corrupted_value() { let tmp_dir = temp_dir!(); - let cache: RedbCache = RedbCache::new("corrupted-sweep") + let cache: RedbCache = RedbCache::builder() + .name("corrupted-sweep") .disk_directory(tmp_dir.path()) .ttl(Duration::from_secs(1)) .build() @@ -1149,14 +1385,15 @@ mod tests { assert!(matches!( cache.remove_expired_entries(), - Err(RedbCacheError::CacheDeserialization(_)) + Err(RedbCacheError::CacheDeserialization { .. }) )); } #[test] fn remove_expired_entries_returns_count_of_removed_entries() { let tmp_dir = temp_dir!(); - let cache: RedbCache = RedbCache::new("sweep-count") + let cache: RedbCache = RedbCache::builder() + .name("sweep-count") .disk_directory(tmp_dir.path()) .ttl(LIFE_SPAN_1_SEC) .build() @@ -1183,7 +1420,8 @@ mod tests { #[googletest::test] fn cache_get_after_cache_remove_returns_none() { let tmp_dir = temp_dir!(); - let cache: RedbCache = RedbCache::new("test-cache") + let cache: RedbCache = RedbCache::builder() + .name("test-cache") .disk_directory(tmp_dir.path()) .build() .unwrap(); @@ -1228,7 +1466,8 @@ mod tests { #[googletest::test] fn cache_clear_empties_the_table() { let tmp_dir = temp_dir!(); - let cache: RedbCache = RedbCache::new("test-cache-clear") + let cache: RedbCache = RedbCache::builder() + .name("test-cache-clear") .disk_directory(tmp_dir.path()) .build() .unwrap(); @@ -1253,7 +1492,8 @@ mod tests { #[googletest::test] fn values_expire_when_lifespan_elapses_returning_none() { let tmp_dir = temp_dir!(); - let cache: RedbCache = RedbCache::new("test-cache") + let cache: RedbCache = RedbCache::builder() + .name("test-cache") .disk_directory(tmp_dir.path()) .ttl(LIFE_SPAN_2_SECS) .build() @@ -1290,7 +1530,8 @@ mod tests { fn set_ttl_to_a_different_ttl_is_respected() { // COPY PASTE of [values_expire_when_lifespan_elapses_returning_none] let tmp_dir = temp_dir!(); - let cache: RedbCache = RedbCache::new("test-cache") + let cache: RedbCache = RedbCache::builder() + .name("test-cache") .disk_directory(tmp_dir.path()) .ttl(LIFE_SPAN_2_SECS) .build() @@ -1318,7 +1559,7 @@ mod tests { ); let old_from_setting_lifespan = - ConcurrentCached::set_ttl(&cache, LIFE_SPAN_1_SEC).expect("error setting new ttl"); + ConcurrentCacheTtl::set_ttl(&cache, LIFE_SPAN_1_SEC).expect("error setting new ttl"); assert_that!( old_from_setting_lifespan, eq(LIFE_SPAN_2_SECS), @@ -1344,7 +1585,7 @@ mod tests { "Getting an expired key-value should return None" ); - ConcurrentCached::set_ttl(&cache, Duration::from_secs(10)).expect("error setting ttl"); + ConcurrentCacheTtl::set_ttl(&cache, Duration::from_secs(10)).expect("error setting ttl"); assert_that!( cache.cache_set(TEST_KEY, TEST_VAL), ok(none()), @@ -1369,7 +1610,8 @@ mod tests { const LIFE_SPAN: Duration = LIFE_SPAN_2_SECS; const HALF_LIFE_SPAN: Duration = LIFE_SPAN_1_SEC; let tmp_dir = temp_dir!(); - let cache: RedbCache = RedbCache::new("test-cache") + let cache: RedbCache = RedbCache::builder() + .name("test-cache") .disk_directory(tmp_dir.path()) .ttl(LIFE_SPAN) .refresh_on_hit(true) // ENABLE REFRESH - this is what we're testing @@ -1409,11 +1651,11 @@ mod tests { // Smoke test for the default disk directory: a full get/set/remove // round-trip succeeds when `disk_directory` is left at its default. fn does_not_break_when_constructed_using_default_disk_directory() { - let cache: RedbCache = - RedbCache::new(&format!("{}:disk-cache-test-default-dir", now_millis())) - // use the default disk directory - .build() - .unwrap(); + let cache: RedbCache = RedbCache::builder() + .name(format!("{}:disk-cache-test-default-dir", now_millis())) + // use the default disk directory + .build() + .unwrap(); let cached = cache.cache_get(&TEST_KEY).unwrap(); assert_that!( @@ -1464,7 +1706,8 @@ mod tests { const CACHE_NAME: &str = "test-cache"; { - let cache: RedbCache = RedbCache::new(CACHE_NAME) + let cache: RedbCache = RedbCache::builder() + .name(CACHE_NAME) .disk_directory(cache_tmp_dir.path()) .durable(set_durable) // WHAT'S BEING TESTED .build() @@ -1475,7 +1718,8 @@ mod tests { // file is released before we re-open it below. } - let recovered_cache: RedbCache = RedbCache::new(CACHE_NAME) + let recovered_cache: RedbCache = RedbCache::builder() + .name(CACHE_NAME) .disk_directory(cache_tmp_dir.path()) .durable(set_durable) .build() @@ -1587,7 +1831,8 @@ mod tests { use crate::ConcurrentCachedAsync; let tmp_dir = temp_dir!(); - let cache: RedbCache = RedbCache::new("test-cache-async") + let cache: RedbCache = RedbCache::builder() + .name("test-cache-async") .disk_directory(tmp_dir.path()) .ttl(LIFE_SPAN_1_SEC) .build() @@ -1634,7 +1879,8 @@ mod tests { use crate::ConcurrentCachedAsync; let tmp_dir = temp_dir!(); - let cache: RedbCache = RedbCache::new("remove-entry-async") + let cache: RedbCache = RedbCache::builder() + .name("remove-entry-async") .disk_directory(tmp_dir.path()) .build() .unwrap(); @@ -1662,13 +1908,224 @@ mod tests { assert!(raw_get(&cache, &TEST_KEY.to_string()).is_none()); } + #[test] + fn cache_set_ref_round_trips() { + let tmp_dir = temp_dir!(); + let cache: RedbCache = RedbCache::builder() + .name("set-ref-roundtrip") + .disk_directory(tmp_dir.path()) + .build() + .expect("error building disk cache"); + + let key = TEST_KEY; + let value = TEST_VAL; + // cache_set_ref writes from a borrow; the previous value is None. + assert_that!( + crate::SerializeCached::cache_set_ref(&cache, &key, &value), + ok(none()), + "cache_set_ref on a new key should return None" + ); + // cache_get must return the value that was written via cache_set_ref. + assert_that!( + cache.cache_get(&key), + ok(some(eq(&value))), + "cache_get after cache_set_ref should return the written value" + ); + // A second cache_set_ref displaces the first and returns it. + let value2 = TEST_VAL_1; + assert_that!( + crate::SerializeCached::cache_set_ref(&cache, &key, &value2), + ok(some(eq(&value))), + "cache_set_ref over an existing entry should return the old value" + ); + assert_that!( + cache.cache_get(&key), + ok(some(eq(&value2))), + "cache_get should return the most recently set value" + ); + } + + #[test] + fn debug_smoke_exposes_non_secret_fields_only() { + let tmp_dir = temp_dir!(); + let cache: RedbCache = RedbCache::builder() + .name("debug-smoke") + .disk_directory(tmp_dir.path()) + .ttl_secs(60) + .refresh_on_hit(true) + .build() + .expect("error building disk cache"); + + let s = format!("{:?}", cache); + assert!(!s.is_empty(), "Debug output must be non-empty"); + // Type name and the non-secret config fields must be present. + assert!(s.contains("RedbCache"), "Debug must name the type: {s}"); + assert!(s.contains("ttl"), "Debug must show ttl: {s}"); + assert!(s.contains("refresh"), "Debug must show refresh: {s}"); + assert!(s.contains("durable"), "Debug must show durable: {s}"); + // finish_non_exhaustive renders a trailing `..`. + assert!( + s.contains(".."), + "Debug must be non-exhaustive (trailing ..): {s}" + ); + // The private `connection` (live `Database` handle) must not be named. + assert!( + !s.contains("connection"), + "Debug must not expose the connection handle: {s}" + ); + // Guard against a future regression that leaks a redis-style + // connection string from a disk cache that has none. + assert!( + !s.contains("redis://") && !s.contains("rediss://"), + "Debug must not contain a connection scheme: {s}" + ); + } + + #[test] + fn build_rejects_cache_name_with_path_separator_or_dot_components() { + let tmp_dir = temp_dir!(); + + assert!( + matches!( + RedbCache::::builder() + .name("") + .disk_directory(tmp_dir.path()) + .build(), + Err(RedbCacheBuildError::InvalidCacheName) + ), + "empty cache_name must return InvalidCacheName" + ); + + assert!( + matches!( + RedbCache::::builder() + .name("bad/name") + .disk_directory(tmp_dir.path()) + .build(), + Err(RedbCacheBuildError::InvalidCacheName) + ), + "cache_name containing '/' must return InvalidCacheName" + ); + + // ':' is allowed (established usage in module-path / timestamp-derived names). + assert!( + RedbCache::::builder() + .name("ok:name") + .disk_directory(tmp_dir.path()) + .build() + .is_ok(), + "cache_name containing ':' must be accepted" + ); + + assert!( + matches!( + RedbCache::::builder() + .name("bad\\name") + .disk_directory(tmp_dir.path()) + .build(), + Err(RedbCacheBuildError::InvalidCacheName) + ), + "cache_name containing '\\\\' must return InvalidCacheName" + ); + + assert!( + matches!( + RedbCache::::builder() + .name("..") + .disk_directory(tmp_dir.path()) + .build(), + Err(RedbCacheBuildError::InvalidCacheName) + ), + "cache_name '..' must return InvalidCacheName" + ); + + assert!( + matches!( + RedbCache::::builder() + .name(".") + .disk_directory(tmp_dir.path()) + .build(), + Err(RedbCacheBuildError::InvalidCacheName) + ), + "cache_name '.' must return InvalidCacheName" + ); + + // A valid name must still build successfully. + assert!( + RedbCache::::builder() + .name("valid-cache-name") + .disk_directory(tmp_dir.path()) + .build() + .is_ok(), + "a valid cache_name must build successfully" + ); + } + + #[test] + fn build_rejects_cache_name_with_nul_byte() { + let tmp_dir = temp_dir!(); + + assert!( + matches!( + RedbCache::::builder() + .name("bad\0name") + .disk_directory(tmp_dir.path()) + .build(), + Err(RedbCacheBuildError::InvalidCacheName) + ), + "cache_name containing a NUL byte must return InvalidCacheName" + ); + } + + #[cfg(feature = "async")] + #[tokio::test] + async fn async_cache_set_ref_round_trips() { + use crate::SerializeCachedAsync; + + let tmp_dir = temp_dir!(); + let cache: RedbCache = RedbCache::builder() + .name("set-ref-roundtrip-async") + .disk_directory(tmp_dir.path()) + .build() + .expect("error building disk cache"); + + let key = TEST_KEY; + let value = TEST_VAL; + // async_cache_set_ref writes from a borrow; the previous value is None. + assert_eq!( + cache.async_cache_set_ref(&key, &value).await.unwrap(), + None, + "async_cache_set_ref on a new key should return None" + ); + // async_cache_get must return the value that was written via async_cache_set_ref. + use crate::ConcurrentCachedAsync; + assert_eq!( + cache.async_cache_get(&key).await.unwrap(), + Some(value), + "async_cache_get after async_cache_set_ref should return the written value" + ); + // A second async_cache_set_ref displaces the first. + let value2 = TEST_VAL_1; + assert_eq!( + cache.async_cache_set_ref(&key, &value2).await.unwrap(), + Some(value), + "async_cache_set_ref over an existing entry should return the old value" + ); + assert_eq!( + cache.async_cache_get(&key).await.unwrap(), + Some(value2), + "async_cache_get should return the most recently set value" + ); + } + #[cfg(feature = "async")] #[tokio::test] async fn async_flush_succeeds_and_preserves_data() { use crate::ConcurrentCachedAsync; let tmp_dir = temp_dir!(); - let cache: RedbCache = RedbCache::new("flush-test-async") + let cache: RedbCache = RedbCache::builder() + .name("flush-test-async") .disk_directory(tmp_dir.path()) .build() .unwrap(); @@ -1683,4 +2140,190 @@ mod tests { Some(TEST_VAL) ); } + + /// Prove runtime-agnosticism: run async RedbCache operations under + /// `futures::executor::block_on` (a minimal single-threaded executor, no + /// tokio). The `blocking` crate uses its own thread pool, so the blocking + /// redb I/O executes correctly regardless of which async executor drives the + /// future. + #[cfg(feature = "async")] + #[test] + fn async_redb_cache_works_under_futures_block_on() { + use crate::ConcurrentCachedAsync; + use futures::executor::block_on; + + let tmp_dir = temp_dir!(); + let cache: RedbCache = RedbCache::builder() + .name("futures-block-on-test") + .disk_directory(tmp_dir.path()) + .build() + .unwrap(); + + // set then get via a non-tokio executor + let prev = block_on(cache.async_cache_set(TEST_KEY, TEST_VAL)).unwrap(); + assert_eq!(prev, None, "first set returns no prior value"); + let got = block_on(cache.async_cache_get(&TEST_KEY)).unwrap(); + assert_eq!(got, Some(TEST_VAL), "get returns the value that was set"); + + // remove via the non-tokio executor + let removed = block_on(cache.async_cache_remove(&TEST_KEY)).unwrap(); + assert_eq!( + removed, + Some(TEST_VAL), + "remove returns the previously set value" + ); + let after = block_on(cache.async_cache_get(&TEST_KEY)).unwrap(); + assert_eq!(after, None, "get after remove returns None"); + + // async_flush also works + block_on(cache.async_flush()).expect("async_flush under futures::block_on should succeed"); + } + + // ── Error variant shape and naming tests ───────────────────────────────── + // + // These tests assert the renamed/reshaped variants introduced in item 0005: + // - `RedbCacheBuildError::Storage` (renamed from `Connection`) + // - struct variants with named fields on both error enums + // - `CacheDeserialization::cached_value` carries the raw bytes that failed + // to decode + + /// `RedbCacheBuildError::Storage` (renamed from `Connection`) is produced + /// by build-time redb failures. Its Display no longer says "connection". + #[test] + fn build_error_storage_variant_name_and_display() { + // Construct the variant directly to verify the field name compiles. + let err = RedbCacheBuildError::Storage { + source: redb::Error::Io(std::io::Error::other("synthetic redb io error")), + }; + let display = err.to_string(); + // Must say "storage" (case-insensitive). + assert!( + display.to_lowercase().contains("storage"), + "Storage variant display must mention storage: {display}" + ); + // Must NOT say "connection" (the old, misleading word). + assert!( + !display.to_lowercase().contains("connection"), + "Storage variant display must not mention connection: {display}" + ); + } + + /// `RedbCacheError::CacheDeserialization` is a struct variant. The + /// `cached_value` field carries the exact bytes that failed to decode, + /// and `source` holds the underlying decode error. + #[test] + fn cache_get_decode_error_carries_raw_bytes() { + let tmp_dir = temp_dir!(); + let cache: RedbCache = RedbCache::builder() + .name("decode-error-carries-bytes") + .disk_directory(tmp_dir.path()) + .build() + .expect("error building disk cache"); + let corrupt: Vec = vec![0xc1, 0xc1, 0xc1]; + raw_insert(&cache, &TEST_KEY.to_string(), corrupt.clone()); + + match cache.cache_get(&TEST_KEY) { + Err(RedbCacheError::CacheDeserialization { + cached_value, + source: _, + }) => { + assert_eq!( + cached_value, corrupt, + "cached_value must carry the exact bytes that failed to decode" + ); + } + other => panic!("expected CacheDeserialization, got {other:?}"), + } + // Entry must still be present (cache_get does not remove on decode error). + assert!(raw_get(&cache, &TEST_KEY.to_string()).is_some()); + } + + /// `RedbCacheError::CacheDeserialization` from `remove_expired_entries` + /// also carries the raw bytes via the `cached_value` field. + #[test] + fn remove_expired_entries_decode_error_carries_raw_bytes() { + let tmp_dir = temp_dir!(); + let cache: RedbCache = RedbCache::builder() + .name("sweep-decode-error-bytes") + .disk_directory(tmp_dir.path()) + .ttl(Duration::from_secs(1)) + .build() + .expect("error building disk cache"); + let corrupt: Vec = vec![0xc1, 0xc1, 0xc1]; + raw_insert(&cache, &TEST_KEY.to_string(), corrupt.clone()); + + match cache.remove_expired_entries() { + Err(RedbCacheError::CacheDeserialization { + cached_value, + source: _, + }) => { + assert_eq!( + cached_value, corrupt, + "cached_value must carry the exact bytes that failed to decode" + ); + } + other => panic!("expected CacheDeserialization, got {other:?}"), + } + } + + /// `RedbCacheError::CacheSerialization` is a struct variant with a `source` + /// field. Verify that the variant can be constructed and matched with named + /// fields (not a bare tuple wildcard). + #[test] + fn cache_serialization_error_is_struct_variant() { + let tmp_dir = temp_dir!(); + let cache: RedbCache = RedbCache::builder() + .name("ser-error-struct-variant") + .disk_directory(tmp_dir.path()) + .ttl(Duration::from_secs(10)) + .refresh_on_hit(true) + .build() + .expect("error building disk cache"); + let fixture = CachedDiskValue::new(SerializeFailsAfterDeserialize { fail: false }); + raw_insert( + &cache, + &TEST_KEY.to_string(), + rmp_serde::to_vec(&fixture).expect("error serializing fixture"), + ); + + match cache.cache_get(&TEST_KEY) { + Err(RedbCacheError::CacheSerialization { source: _ }) => {} + other => panic!("expected CacheSerialization, got {other:?}"), + } + } + + /// `std::error::Error::source()` on `RedbCacheError::CacheDeserialization` + /// must return the underlying decode error. + #[test] + fn cache_deserialization_error_source_is_wired() { + use std::error::Error; + let tmp_dir = temp_dir!(); + let cache: RedbCache = RedbCache::builder() + .name("deser-source-wired") + .disk_directory(tmp_dir.path()) + .build() + .expect("error building disk cache"); + raw_insert(&cache, &TEST_KEY.to_string(), vec![0xc1, 0xc1, 0xc1]); + + let err = cache + .cache_get(&TEST_KEY) + .expect_err("expected a decode error"); + assert!( + err.source().is_some(), + "CacheDeserialization must expose its inner error via source()" + ); + } + + /// `RedbCacheBuildError::Storage`'s `source` field is wired as the + /// `std::error::Error::source()` of the wrapper. + #[test] + fn build_error_storage_source_is_wired() { + use std::error::Error; + let inner = redb::Error::Io(std::io::Error::other("synthetic redb io error")); + let err = RedbCacheBuildError::Storage { source: inner }; + assert!( + err.source().is_some(), + "RedbCacheBuildError::Storage must expose its inner error via source()" + ); + } } diff --git a/src/stores/redis.rs b/src/stores/redis.rs index 54974204..e11ec45e 100644 --- a/src/stores/redis.rs +++ b/src/stores/redis.rs @@ -1,5 +1,5 @@ -use crate::ConcurrentCached; use crate::time::Duration; +use crate::{ConcurrentCacheBase, ConcurrentCacheTtl, ConcurrentCached}; use parking_lot::Mutex; use serde::Serialize; use serde::de::DeserializeOwned; @@ -8,10 +8,10 @@ use std::marker::PhantomData; use std::sync::atomic::{AtomicBool, Ordering}; pub struct RedisCacheBuilder { - ttl: Duration, + ttl: Option, refresh: bool, namespace: String, - prefix: String, + prefix: Option, connection_string: Option, pool_max_size: Option, pool_min_idle: Option, @@ -24,7 +24,7 @@ pub struct RedisCacheBuilder { const ENV_KEY: &str = "CACHED_REDIS_CONNECTION_STRING"; const DEFAULT_NAMESPACE: &str = "cached-redis-store:"; -fn ttl_seconds(ttl: Duration) -> Result { +fn ttl_millis(ttl: Duration) -> Result { if ttl.is_zero() { return Err(redis::RedisError::from(( redis::ErrorKind::InvalidClientConfig, @@ -33,21 +33,21 @@ fn ttl_seconds(ttl: Duration) -> Result { )) .into()); } - // Redis only supports whole-second granularity. Round up so keys never - // expire earlier than the caller requested (any non-zero duration yields >= 1). - // Saturate rather than overflow on pathologically large durations, then clamp - // to Redis' supported TTL range (`i64::MAX` seconds). Clamping here means the - // same bounded value is used by both `SETEX` (cache_set) and `EXPIRE` - // (refresh); an out-of-range value would otherwise be rejected by Redis. - let secs = ttl - .as_secs() - .saturating_add(u64::from(ttl.subsec_nanos() > 0)); - Ok(secs.min(i64::MAX as u64)) + // Convert to milliseconds with saturating arithmetic so pathologically large + // durations do not overflow. Clamp to `i64::MAX` milliseconds so the same + // bounded value can be used for both `PSETEX` (u64) and `PEXPIRE` (i64) + // without a second clamp at the call site. Clamp the low end to 1ms: a + // non-zero sub-millisecond Duration truncates to 0ms, which `PSETEX`/`PEXPIRE` + // reject (or treat as immediate-delete). The `is_zero` guard above already + // rejects an actually-zero Duration, so the `.max(1)` only lifts a truncated + // (but non-zero) sub-millisecond value up to the minimum valid TTL. + let millis = ttl.as_millis(); + Ok(millis.min(i64::MAX as u128).max(1) as u64) } -fn ttl_seconds_i64(ttl: Duration) -> Result { - // `ttl_seconds` is already clamped to `i64::MAX`, so this cast is lossless. - Ok(ttl_seconds(ttl)? as i64) +fn ttl_millis_i64(ttl: Duration) -> Result { + // `ttl_millis` is already clamped to `i64::MAX`, so this cast is lossless. + Ok(ttl_millis(ttl)? as i64) } /// Build the Redis key: `{namespace}:{prefix}:{key}`, colon-joined with empty @@ -76,6 +76,48 @@ fn generate_redis_key(namespace: &str, prefix: &str, key: &str) -> String { out } +/// `SCAN MATCH` glob covering every key a cache with this `namespace`/`prefix` +/// writes: the [`generate_redis_key`] scope with a trailing `*`. Glob +/// metacharacters (`*`, `?`, `[`, `]`) and `\` in the namespace/prefix are +/// escaped so they match literally — otherwise a prefix like `cache[v2]` would +/// scan (and `cache_clear` would delete) keys outside this cache's scope. +/// Single source of truth shared by the sync and async stores. +fn clear_match_pattern(namespace: &str, prefix: &str) -> String { + fn escape_glob(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + if matches!(c, '*' | '?' | '[' | ']' | '\\') { + out.push('\\'); + } + out.push(c); + } + out + } + generate_redis_key(&escape_glob(namespace), &escape_glob(prefix), "*") +} + +#[cfg(test)] +mod clear_pattern_tests { + // No Redis server needed — pins the `SCAN MATCH` pattern used by `cache_clear`. + use super::clear_match_pattern; + + #[test] + fn plain_segments_get_scope_and_trailing_star() { + assert_eq!(clear_match_pattern("ns", "p"), "ns:p:*"); + assert_eq!(clear_match_pattern("", "p"), "p:*"); + assert_eq!(clear_match_pattern("ns", ""), "ns:*"); + } + + #[test] + fn glob_metacharacters_in_segments_are_escaped() { + // Unescaped, `cache[v2]` would glob-match keys outside this cache's + // scope (e.g. `cachev:...`) and `cache_clear` would delete them. + assert_eq!(clear_match_pattern("ns", "cache[v2]"), "ns:cache\\[v2\\]:*"); + assert_eq!(clear_match_pattern("n*s", "p?x"), "n\\*s:p\\?x:*"); + assert_eq!(clear_match_pattern("back\\slash", "p"), "back\\\\slash:p:*"); + } +} + #[cfg(test)] mod generate_key_tests { // No Redis server needed — pins the data-impacting 1.0 key format (§8). @@ -124,57 +166,188 @@ mod generate_key_tests { } #[cfg(test)] -mod ttl_seconds_tests { - // Pure-function coverage for the subtle Redis TTL normalization (reject - // zero, round any sub-second remainder up, clamp to `i64::MAX`). These need - // no Redis server and guard both the `SETEX` and `EXPIRE` paths, which - // share `ttl_seconds`/`ttl_seconds_i64`. - use super::{ttl_seconds, ttl_seconds_i64}; +mod ttl_millis_tests { + // Pure-function coverage for the Redis millisecond TTL helpers: reject zero, + // preserve sub-second precision, clamp to `i64::MAX` ms. These need no + // Redis server and guard both the `PSETEX` (cache_set) and `PEXPIRE` + // (refresh) paths, which share `ttl_millis`/`ttl_millis_i64`. + use super::{ttl_millis, ttl_millis_i64}; use crate::time::Duration; #[test] fn zero_is_rejected() { - assert!(ttl_seconds(Duration::ZERO).is_err()); - assert!(ttl_seconds_i64(Duration::ZERO).is_err()); + assert!(ttl_millis(Duration::ZERO).is_err()); + assert!(ttl_millis_i64(Duration::ZERO).is_err()); + } + + #[test] + fn whole_seconds_become_milliseconds() { + assert_eq!(ttl_millis(Duration::from_secs(1)).unwrap(), 1_000); + assert_eq!(ttl_millis(Duration::from_secs(60)).unwrap(), 60_000); + assert_eq!(ttl_millis_i64(Duration::from_secs(60)).unwrap(), 60_000); + } + + #[test] + fn subsecond_precision_is_preserved() { + // Unlike the old SETEX path, sub-second TTLs are NOT rounded up to 1s. + assert_eq!(ttl_millis(Duration::from_millis(1)).unwrap(), 1); + assert_eq!(ttl_millis(Duration::from_millis(250)).unwrap(), 250); + assert_eq!(ttl_millis(Duration::from_millis(999)).unwrap(), 999); } #[test] - fn whole_seconds_pass_through() { - assert_eq!(ttl_seconds(Duration::from_secs(1)).unwrap(), 1); - assert_eq!(ttl_seconds(Duration::from_secs(60)).unwrap(), 60); - assert_eq!(ttl_seconds_i64(Duration::from_secs(60)).unwrap(), 60); + fn nonzero_submillisecond_clamps_to_one() { + // A non-zero Duration under 1ms truncates to 0ms, which PSETEX/PEXPIRE + // reject (or treat as immediate-delete). The low-end clamp lifts it to 1ms. + let one_ns = Duration::from_nanos(1); + assert!(!one_ns.is_zero()); + // Truncation to milliseconds is still 0... + assert_eq!(one_ns.as_millis(), 0); + // ...but ttl_millis clamps a non-zero sub-ms Duration up to 1ms. + assert_eq!(ttl_millis(one_ns).unwrap(), 1); + assert_eq!(ttl_millis_i64(one_ns).unwrap(), 1); + // Other sub-millisecond, non-zero durations also clamp to 1. + assert_eq!(ttl_millis(Duration::from_nanos(999_999)).unwrap(), 1); + assert_eq!(ttl_millis(Duration::from_micros(500)).unwrap(), 1); } #[test] - fn subsecond_rounds_up_to_one() { - assert_eq!(ttl_seconds(Duration::from_nanos(1)).unwrap(), 1); - assert_eq!(ttl_seconds(Duration::from_millis(1)).unwrap(), 1); - assert_eq!(ttl_seconds(Duration::from_millis(999)).unwrap(), 1); + fn zero_never_reaches_the_clamp() { + // The is_zero guard rejects an actually-zero Duration before the + // low-end `.max(1)` clamp can lift it to 1ms. + assert!(ttl_millis(Duration::ZERO).is_err()); + assert!(ttl_millis_i64(Duration::ZERO).is_err()); } #[test] - fn fractional_rounds_up() { - assert_eq!(ttl_seconds(Duration::from_millis(1_500)).unwrap(), 2); - assert_eq!(ttl_seconds(Duration::new(5, 1)).unwrap(), 6); + fn subsecond_mixed_passes_through() { + assert_eq!(ttl_millis(Duration::from_millis(1_500)).unwrap(), 1_500); + assert_eq!(ttl_millis(Duration::new(5, 1_000_000)).unwrap(), 5_001); } #[test] fn very_large_clamps_to_i64_max() { + // A Duration that, in milliseconds, overflows i64 is clamped. let huge = Duration::from_secs(u64::MAX); - assert_eq!(ttl_seconds(huge).unwrap(), i64::MAX as u64); - assert_eq!(ttl_seconds_i64(huge).unwrap(), i64::MAX); + assert_eq!(ttl_millis(huge).unwrap(), i64::MAX as u64); + assert_eq!(ttl_millis_i64(huge).unwrap(), i64::MAX); + } +} + +#[cfg(test)] +mod builder_ttl_setter_tests { + // No Redis server needed -- these only inspect the builder's ttl field set by + // the convenience setters, without calling `build()`. + use super::RedisCacheBuilder; + use crate::time::Duration; + + #[test] + fn ttl_secs_and_ttl_millis_set_duration() { + let b = RedisCacheBuilder::::new() + .prefix("p") + .ttl_secs(7); + assert_eq!(b.ttl, Some(Duration::from_secs(7))); + + let b = RedisCacheBuilder::::new() + .prefix("p") + .ttl_millis(250); + assert_eq!(b.ttl, Some(Duration::from_millis(250))); + } + + #[test] + fn ttl_setters_override_last_writer_wins() { + // ttl(secs=10) then ttl_secs(5) -> 5s + let b = RedisCacheBuilder::::new() + .prefix("p") + .ttl(Duration::from_secs(10)) + .ttl_secs(5); + assert_eq!(b.ttl, Some(Duration::from_secs(5))); + + // ttl_secs then ttl_millis -> the millis value + let b = RedisCacheBuilder::::new() + .prefix("p") + .ttl_secs(10) + .ttl_millis(500); + assert_eq!(b.ttl, Some(Duration::from_millis(500))); + + // ttl_millis then ttl -> the ttl value + let b = RedisCacheBuilder::::new() + .prefix("p") + .ttl_millis(500) + .ttl(Duration::from_secs(3)); + assert_eq!(b.ttl, Some(Duration::from_secs(3))); + } +} + +#[cfg(test)] +mod builder_empty_scope_tests { + // No Redis server needed -- verifies the empty-scope guard in `build()`. + use super::{RedisCacheBuildError, RedisCacheBuilder}; + use crate::time::Duration; + + #[test] + fn empty_namespace_and_prefix_is_rejected() { + let result = RedisCacheBuilder::::new() + .prefix("") + .ttl(Duration::from_secs(1)) + .namespace("") + .build(); + assert!( + matches!(result, Err(RedisCacheBuildError::EmptyScope)), + "expected EmptyScope" + ); + } + + #[test] + fn namespace_all_colons_and_empty_prefix_is_rejected() { + // ":::" trims to "" so the effective namespace is also empty. + let result = RedisCacheBuilder::::new() + .prefix("") + .ttl(Duration::from_secs(1)) + .namespace(":::") + .build(); + assert!( + matches!(result, Err(RedisCacheBuildError::EmptyScope)), + "expected EmptyScope" + ); + } + + #[test] + fn non_empty_prefix_builds_ok() { + // Guard must not fire when the prefix is set -- no real Redis needed + // because the build error would come before the connection attempt. + let result = RedisCacheBuilder::::new() + .prefix("my-prefix") + .ttl(Duration::from_secs(1)) + .namespace("") + .build(); + // The only failure here would be a missing connection string, not EmptyScope. + assert!( + !matches!(result, Err(RedisCacheBuildError::EmptyScope)), + "EmptyScope must not fire when prefix is non-empty" + ); } } /// A Redis connection URL stored in memory with credentials redacted in `Debug`/`Display`. /// -/// The inner string (accessible via `.as_str()`) is the full URL including any password -/// and should not be logged or exposed in error messages. +/// Both [`Debug`](std::fmt::Debug) and [`Display`](std::fmt::Display) render the placeholder +/// `[REDACTED connection string]`, so the value is safe to log or include in error messages. +/// The raw URL (including any password) is available via [`reveal`](Self::reveal) and must not +/// be logged or exposed in error messages. #[derive(Clone)] -struct ConnectionString(String); +pub struct ConnectionString(String); impl ConnectionString { - fn as_str(&self) -> &str { + /// Return the raw connection URL, including any embedded credentials. + /// + /// **Warning:** the returned string may contain credentials + /// (e.g. `redis://:password@host`). Do not log or expose it in error messages. + /// The redacting [`Debug`](std::fmt::Debug)/[`Display`](std::fmt::Display) impls exist + /// precisely to keep this value out of logs; only call `reveal` when the full + /// credentials are genuinely required. + #[must_use] + pub fn reveal(&self) -> &str { &self.0 } } @@ -193,6 +366,18 @@ impl std::fmt::Display for ConnectionString { use thiserror::Error; +/// Error returned when building a [`RedisCache`]/[`AsyncRedisCache`]. +/// +/// Configuration problems (a missing `prefix`/`ttl`, or a zero `ttl`) surface as the +/// transparent [`Build`](Self::Build) variant wrapping a [`BuildError`](super::BuildError): +/// +/// ```ignore +/// match RedisCache::::builder().build() { +/// Err(RedisCacheBuildError::Build(BuildError::MissingRequired(field))) => { /* e.g. "prefix" */ } +/// Err(RedisCacheBuildError::Build(BuildError::InvalidValue { field, reason })) => { /* e.g. "ttl" */ } +/// _ => {} +/// } +/// ``` #[non_exhaustive] #[derive(Error, Debug)] pub enum RedisCacheBuildError { @@ -201,12 +386,29 @@ pub enum RedisCacheBuildError { #[error("redis pool error")] Pool(#[from] r2d2::Error), #[error(transparent)] - InvalidTtl(#[from] super::BuildError), - #[error("Connection string not specified or invalid in env var {env_key:?}: {error:?}")] + Build(#[from] super::BuildError), + #[error("Connection string not specified or invalid in env var {env_key:?}: {error}")] MissingConnectionString { env_key: String, + #[source] error: std::env::VarError, }, + #[error( + "empty scope: namespace (after trimming trailing colons) and prefix are both empty; \ + cache_clear would run SCAN MATCH * and delete every key in the Redis DB. \ + Set a non-empty namespace or prefix." + )] + EmptyScope, +} + +impl Default for RedisCacheBuilder +where + K: Display, + V: Serialize + DeserializeOwned, +{ + fn default() -> Self { + Self::new() + } } impl RedisCacheBuilder @@ -214,13 +416,18 @@ where K: Display, V: Serialize + DeserializeOwned, { - /// Initialize a `RedisCacheBuilder` - pub fn new>(prefix: S, ttl: Duration) -> RedisCacheBuilder { + /// Initialize a `RedisCacheBuilder`. + /// + /// Both the key `prefix` and the `ttl` are required; set them with + /// [`prefix`](Self::prefix) and [`ttl`](Self::ttl) (or [`ttl_secs`](Self::ttl_secs) / + /// [`ttl_millis`](Self::ttl_millis)) before calling [`build`](Self::build). + #[must_use] + pub fn new() -> RedisCacheBuilder { Self { - ttl, + ttl: None, refresh: false, namespace: DEFAULT_NAMESPACE.to_string(), - prefix: prefix.as_ref().to_string(), + prefix: None, connection_string: None, pool_max_size: None, pool_min_idle: None, @@ -230,14 +437,35 @@ where } } - /// Specify the cache TTL as a `Duration`. - /// Redis enforces whole-second granularity; sub-second non-zero TTLs round up to 1 second. + /// Specify the cache TTL as a `Duration` (required). + /// TTL is stored with millisecond precision via `PSETEX`/`PEXPIRE`. + /// + /// Overrides any previously set ttl/ttl_secs/ttl_millis on this builder. #[must_use] pub fn ttl(mut self, ttl: Duration) -> Self { - self.ttl = ttl; + self.ttl = Some(ttl); self } + /// Specify the cache TTL in whole seconds. Equivalent to + /// `ttl(Duration::from_secs(secs))`. + /// + /// Overrides any previously set ttl/ttl_secs/ttl_millis on this builder. + #[must_use] + pub fn ttl_secs(self, secs: u64) -> Self { + self.ttl(Duration::from_secs(secs)) + } + + /// Specify the cache TTL in milliseconds. Equivalent to + /// `ttl(Duration::from_millis(millis))`. + /// TTL is stored with millisecond precision via `PSETEX`/`PEXPIRE`. + /// + /// Overrides any previously set ttl/ttl_secs/ttl_millis on this builder. + #[must_use] + pub fn ttl_millis(self, millis: u64) -> Self { + self.ttl(Duration::from_millis(millis)) + } + /// Specify whether cache hits refresh the TTL #[must_use] pub fn refresh_on_hit(mut self, refresh: bool) -> Self { @@ -258,15 +486,20 @@ where self } - /// Set the prefix for cache keys. + /// Set the prefix for cache keys (required). /// Used to generate keys formatted as: `{namespace}:{prefix}:{key}`. /// Empty prefix values are omitted from the generated key. /// /// **Note:** colons in the prefix are not escaped and can cause key collisions /// with differently-split namespace/prefix combinations sharing the same segments. + /// + /// **Note:** the prefix is what scopes `cache_clear` to this logical cache. + /// With an empty prefix, `cache_clear` matches `:*` and will delete + /// entries belonging to every cache that shares the same namespace. Set a unique + /// prefix per logical cache to ensure `cache_clear` is scoped correctly. #[must_use] pub fn prefix>(mut self, prefix: S) -> Self { - self.prefix = prefix.as_ref().to_string(); + self.prefix = Some(prefix.as_ref().to_string()); self } @@ -358,16 +591,37 @@ where /// /// # Errors /// - /// Will return a `RedisCacheBuildError`, depending on the error + /// - `Build(BuildError::MissingRequired("prefix"))`: no key prefix was set. + /// - `Build(BuildError::MissingRequired("ttl"))`: no TTL was set. + /// - `Build(BuildError::InvalidValue { field: "ttl", .. })`: the configured TTL is zero. + /// - `EmptyScope`: both the namespace (after trimming trailing colons) and + /// the prefix are empty. `cache_clear` would otherwise issue `SCAN MATCH *` + /// and delete every key in the Redis database. + /// - `MissingConnectionString`: no connection string was set and the + /// `CACHED_REDIS_CONNECTION_STRING` env var is absent or invalid. + /// - `Connection` / `Pool`: the Redis client or connection pool could not + /// be created. pub fn build(self) -> Result, RedisCacheBuildError> { - super::validate_ttl(self.ttl)?; + // Validate required fields before any IO/connection attempt so the + // missing-required error is returned without needing a server. + if self.prefix.is_none() { + return Err(super::BuildError::MissingRequired("prefix").into()); + } + let ttl = self.ttl.ok_or(super::BuildError::MissingRequired("ttl"))?; + super::validate_ttl(ttl)?; + let prefix = self.prefix.as_deref().unwrap_or_default(); + if self.namespace.trim_end_matches(':').is_empty() && prefix.is_empty() { + return Err(RedisCacheBuildError::EmptyScope); + } + let connection_string = ConnectionString(self.resolve_connection_string()?); + let pool = self.create_pool()?; Ok(RedisCache { - ttl: Mutex::new(self.ttl), + ttl: Mutex::new(ttl), refresh: AtomicBool::new(self.refresh), - connection_string: ConnectionString(self.resolve_connection_string()?), - pool: self.create_pool()?, + connection_string, + pool, namespace: self.namespace, - prefix: self.prefix, + prefix: self.prefix.unwrap_or_default(), _phantom: PhantomData, }) } @@ -395,20 +649,49 @@ pub struct RedisCache { _phantom: PhantomData (K, V)>, } +impl std::fmt::Debug for RedisCache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RedisCache") + .field("namespace", &self.namespace) + .field("prefix", &self.prefix) + .field("ttl", &*self.ttl.lock()) + .field("refresh", &self.refresh.load(Ordering::Relaxed)) + .finish_non_exhaustive() + } +} + +impl Clone for RedisCache { + /// Shallow clone - both handles share the same r2d2 connection pool + /// (`r2d2::Pool` is `Arc`-backed). The `ttl` is snapshot into a fresh + /// `Mutex` so the two handles can independently update their TTL view. + fn clone(&self) -> Self { + Self { + ttl: Mutex::new(*self.ttl.lock()), + refresh: AtomicBool::new(self.refresh.load(Ordering::Relaxed)), + namespace: self.namespace.clone(), + prefix: self.prefix.clone(), + connection_string: self.connection_string.clone(), + pool: self.pool.clone(), + _phantom: PhantomData, + } + } +} + impl RedisCache where K: Display, V: Serialize + DeserializeOwned, { - #[allow(clippy::new_ret_no_self)] - /// Initialize a `RedisCacheBuilder`. - pub fn new>(prefix: S, ttl: Duration) -> RedisCacheBuilder { - RedisCacheBuilder::new(prefix, ttl) - } - /// Initialize a `RedisCacheBuilder`. - pub fn builder>(prefix: S, ttl: Duration) -> RedisCacheBuilder { - RedisCacheBuilder::new(prefix, ttl) + /// + /// The key `prefix` and `ttl` are required; set them via + /// [`RedisCacheBuilder::prefix`] and [`RedisCacheBuilder::ttl`] before calling + /// [`build`](RedisCacheBuilder::build). If either is missing, `build` returns + /// `Err(`[`BuildError::MissingRequired`](super::BuildError::MissingRequired)`)` rather + /// than panicking. + #[must_use] + pub fn builder() -> RedisCacheBuilder { + RedisCacheBuilder::new() } fn generate_key(&self, key: &K) -> String { @@ -418,13 +701,24 @@ where generate_redis_key(&self.namespace, &self.prefix, &key.to_string()) } - /// Return the redis connection string used. + /// `SCAN MATCH` glob covering every key this cache writes: the same + /// `{namespace}:{prefix}:` scope as [`generate_key`](Self::generate_key) with a + /// trailing `*`, with glob metacharacters in the segments escaped (see + /// [`clear_match_pattern`]). Used by `cache_clear` to delete only this + /// cache's entries. + fn clear_match_pattern(&self) -> String { + clear_match_pattern(&self.namespace, &self.prefix) + } + + /// Return the redis connection string as a [`ConnectionString`]. /// - /// **Note:** the returned string may contain credentials (e.g. `redis://:password@host`). - /// Do not log or expose it in error messages. + /// `ConnectionString`'s `Debug`/`Display` render `[REDACTED connection string]`, + /// so the returned value is safe to log or include in error messages. + /// Call [`ConnectionString::reveal`] to retrieve the raw URL when the full + /// credentials are required. #[must_use] - pub fn connection_string(&self) -> String { - self.connection_string.as_str().to_string() + pub fn connection_string(&self) -> ConnectionString { + self.connection_string.clone() } } @@ -435,36 +729,142 @@ pub enum RedisCacheError { Redis(#[from] redis::RedisError), #[error("redis pool error")] Pool(#[from] r2d2::Error), - #[error("Error deserializing cached value {cached_value:?}: {error:?}")] + #[error("Error deserializing cached value")] CacheDeserialization { - cached_value: String, - error: serde_json::Error, + #[source] + source: rmp_serde::decode::Error, + cached_value: Vec, + }, + #[error("Error serializing cached value")] + CacheSerialization { + #[from] + source: rmp_serde::encode::Error, }, - #[error("Error serializing cached value: {error:?}")] - CacheSerialization { error: serde_json::Error }, } +/// On-disk schema version stamped into every value written by this store. +/// Shared by [`CachedRedisValue::new`] and [`CachedRedisValueRef::new`] so the +/// two constructors cannot drift. The field type is `Option`. +const REDIS_VALUE_VERSION: Option = Some(1); + #[derive(serde::Serialize, serde::Deserialize)] struct CachedRedisValue { - pub(crate) value: V, - pub(crate) version: Option, + value: V, + version: Option, } impl CachedRedisValue { fn new(value: V) -> Self { Self { value, - version: Some(1), + version: REDIS_VALUE_VERSION, + } + } +} + +/// Borrowed counterpart of [`CachedRedisValue`] used by `cache_set_ref` to +/// serialize from a `&V` without cloning. Produces the same MessagePack bytes +/// as `CachedRedisValue::new(value)` (same field names and order). +#[derive(serde::Serialize)] +struct CachedRedisValueRef<'a, V> { + value: &'a V, + version: Option, +} +impl<'a, V> CachedRedisValueRef<'a, V> { + fn new(value: &'a V) -> Self { + Self { + value, + version: REDIS_VALUE_VERSION, } } } +/// Deserialize a stored [`CachedRedisValue`] from its raw Redis bytes, reading +/// both the current MessagePack format and the pre-3.0 JSON format. +/// +/// Single source of truth for every value-deserialize site (sync and async) so +/// the backward-read behavior cannot drift between them. +/// +/// Logic: +/// 1. Try MessagePack (`rmp_serde`) — the format written since 3.0. +/// 2. On failure, attempt the legacy pre-3.0 JSON encoding: parse the bytes as a +/// generic JSON value and, only if it carries a `version` key (the shape this +/// store always wrote), deserialize it into a [`CachedRedisValue`]. This +/// transparently reads entries written by cached 2.x. +/// 3. If neither path succeeds, return +/// [`RedisCacheError::CacheDeserialization`] preserving the *original* +/// MessagePack error as `source` and the raw bytes in `cached_value`. +fn deserialize_cached_redis_value( + bytes: &[u8], +) -> Result, RedisCacheError> { + match rmp_serde::from_slice::>(bytes) { + Ok(v) => Ok(v), + Err(msgpack_err) => { + // Fall back to the pre-3.0 JSON format. Only treat the bytes as the + // legacy format if they parse as JSON AND carry the `version` key the + // old store always wrote — otherwise this is genuinely corrupt data + // and we should surface the original MessagePack error. + if let Ok(json) = serde_json::from_slice::(bytes) + && json.get("version").is_some() + && let Ok(v) = serde_json::from_value::>(json) + { + return Ok(v); + } + Err(RedisCacheError::CacheDeserialization { + source: msgpack_err, + cached_value: bytes.to_vec(), + }) + } + } +} + +impl ConcurrentCacheBase for RedisCache { + type Error = RedisCacheError; +} + +impl ConcurrentCacheTtl for RedisCache { + fn ttl(&self) -> Option { + let ttl = *self.ttl.lock(); + if ttl.is_zero() { None } else { Some(ttl) } + } + + /// Set the TTL for newly inserted cache entries, returning the previous TTL (or `None` + /// if expiry was disabled). Existing Redis keys are not affected; they retain whatever + /// TTL was applied when they were originally inserted. + /// + /// A zero `ttl` disables expiry — exactly equivalent to `unset_ttl`. + /// Subsequent `cache_set` writes use a plain `SET` (no expiry), so the keys persist + /// until explicitly removed. Use [`try_set_ttl`](crate::ConcurrentCacheTtl::try_set_ttl) if you + /// want a zero TTL rejected instead. + fn set_ttl(&self, ttl: Duration) -> Option { + let mut guard = self.ttl.lock(); + let old = *guard; + *guard = ttl; + if old.is_zero() { None } else { Some(old) } + } + + /// Disable expiry: subsequent `cache_set` writes store keys without a TTL (plain `SET`). + /// Returns the previous TTL, or `None` if expiry was already disabled. + fn unset_ttl(&self) -> Option { + let mut guard = self.ttl.lock(); + let old = *guard; + *guard = Duration::ZERO; + if old.is_zero() { None } else { Some(old) } + } + + fn refresh_on_hit(&self) -> bool { + self.refresh.load(Ordering::Relaxed) + } + + fn set_refresh_on_hit(&self, refresh: bool) -> bool { + self.refresh.swap(refresh, Ordering::Relaxed) + } +} + impl ConcurrentCached for RedisCache where K: Display + Clone, V: Serialize + DeserializeOwned, { - type Error = RedisCacheError; - fn cache_get(&self, key: &K) -> Result, RedisCacheError> { let mut conn = self.pool.get()?; let mut pipe = redis::pipe(); @@ -473,19 +873,18 @@ where pipe.get(&key); if self.refresh.load(Ordering::Relaxed) { let ttl = *self.ttl.lock(); - pipe.expire(key, ttl_seconds_i64(ttl)?).ignore(); + // A zero (disabled) TTL means entries are stored without expiry; skip the + // refresh `PEXPIRE` so the key stays persistent (no TTL to renew). + if !ttl.is_zero() { + pipe.pexpire(key, ttl_millis_i64(ttl)?).ignore(); + } } // ugh: https://github.com/mitsuhiko/redis-rs/pull/388#issuecomment-910919137 - let res: (Option,) = pipe.query(&mut *conn)?; + let res: (Option>,) = pipe.query(&mut *conn)?; match res.0 { None => Ok(None), - Some(s) => { - let v: CachedRedisValue = serde_json::from_str(&s).map_err(|e| { - RedisCacheError::CacheDeserialization { - cached_value: s, - error: e, - } - })?; + Some(bytes) => { + let v: CachedRedisValue = deserialize_cached_redis_value(&bytes)?; Ok(Some(v.value)) } } @@ -496,28 +895,24 @@ where let mut pipe = redis::pipe(); let key = self.generate_key(&key); - let ttl_secs = ttl_seconds(*self.ttl.lock())?; + let ttl = *self.ttl.lock(); let val = CachedRedisValue::new(val); + let serialized = rmp_serde::to_vec(&val)?; pipe.get(&key); - pipe.set_ex::( - key, - serde_json::to_string(&val) - .map_err(|e| RedisCacheError::CacheSerialization { error: e })?, - ttl_secs, - ) - .ignore(); + if ttl.is_zero() { + // Disabled TTL: write the key without expiry (plain `SET`). + pipe.set::>(key, serialized).ignore(); + } else { + pipe.pset_ex::>(key, serialized, ttl_millis(ttl)?) + .ignore(); + } - let res: (Option,) = pipe.query(&mut *conn)?; + let res: (Option>,) = pipe.query(&mut *conn)?; match res.0 { None => Ok(None), - Some(s) => { - let v: CachedRedisValue = serde_json::from_str(&s).map_err(|e| { - RedisCacheError::CacheDeserialization { - cached_value: s, - error: e, - } - })?; + Some(bytes) => { + let v: CachedRedisValue = deserialize_cached_redis_value(&bytes)?; Ok(Some(v.value)) } } @@ -530,16 +925,11 @@ where pipe.get(&key); pipe.del::(key).ignore(); - let res: (Option,) = pipe.query(&mut *conn)?; + let res: (Option>,) = pipe.query(&mut *conn)?; match res.0 { None => Ok(None), - Some(s) => { - let v: CachedRedisValue = serde_json::from_str(&s).map_err(|e| { - RedisCacheError::CacheDeserialization { - cached_value: s, - error: e, - } - })?; + Some(bytes) => { + let v: CachedRedisValue = deserialize_cached_redis_value(&bytes)?; Ok(Some(v.value)) } } @@ -563,26 +953,87 @@ where Ok(removed > 0) } - fn ttl(&self) -> Option { - Some(*self.ttl.lock()) + /// Remove every entry written by this cache instance. + /// + /// Scoped to this cache's `{namespace}:{prefix}:*` keyspace via `SCAN` + + /// batched `DEL`. It is **not** a server `FLUSHDB`: keys outside this + /// namespace/prefix are untouched, and entries written by other caches + /// sharing the Redis server are preserved. + /// + /// Cost is **O(n)** in the number of matching keys (a cursored `SCAN`), so it + /// is heavier than the in-memory `cache_clear`. New keys inserted concurrently + /// during the scan may or may not be removed (standard `SCAN` semantics). + /// + /// **Note:** the `prefix` is what scopes a clear to a single logical cache. A + /// cache built with an empty prefix but a non-empty namespace will match every + /// key under that namespace on `cache_clear` (pattern `:*`), which + /// includes entries written by every other cache that shares the same namespace. + /// Set a unique prefix per logical cache to avoid this. + fn cache_clear(&self) -> Result<(), RedisCacheError> { + let mut conn = self.pool.get()?; + let pattern = self.clear_match_pattern(); + let mut cursor: u64 = 0; + loop { + let (next, keys): (u64, Vec) = redis::cmd("SCAN") + .arg(cursor) + .arg("MATCH") + .arg(&pattern) + .arg("COUNT") + .arg(100) + .query(&mut *conn)?; + if !keys.is_empty() { + redis::cmd("DEL").arg(keys).query::<()>(&mut *conn)?; + } + if next == 0 { + break; + } + cursor = next; + } + Ok(()) } - /// Set the TTL for newly inserted cache entries. Existing Redis keys are not affected; - /// they retain whatever TTL was applied when they were originally inserted. - fn set_ttl(&self, ttl: Duration) -> Option { - let mut guard = self.ttl.lock(); - let old = *guard; - *guard = ttl; - Some(old) + /// Delegates to [`cache_clear`](crate::ConcurrentCached::cache_clear): the redis + /// store tracks no in-memory metrics, so resetting is exactly clearing the + /// entries (matching `RedbCache`, which also overrides both). + fn cache_reset(&self) -> Result<(), RedisCacheError> { + self.cache_clear() } +} - fn set_refresh_on_hit(&self, refresh: bool) -> bool { - self.refresh.swap(refresh, Ordering::Relaxed) - } +impl crate::SerializeCached for RedisCache +where + K: Display + Clone, + V: Serialize + DeserializeOwned, +{ + /// Serializes from the borrowed `val` (no clone) and `SET`s it, returning the + /// previous value if any. Equivalent to [`ConcurrentCached::cache_set`] but + /// avoids taking ownership of `val`. + fn cache_set_ref(&self, key: &K, val: &V) -> Result, RedisCacheError> { + let mut conn = self.pool.get()?; + let mut pipe = redis::pipe(); + let key = self.generate_key(key); - /// Redis cache entries always require a TTL. This method is a no-op and always returns `None`. - fn unset_ttl(&self) -> Option { - None + let ttl = *self.ttl.lock(); + + let val = CachedRedisValueRef::new(val); + let serialized = rmp_serde::to_vec(&val)?; + pipe.get(&key); + if ttl.is_zero() { + // Disabled TTL: write the key without expiry (plain `SET`). + pipe.set::>(key, serialized).ignore(); + } else { + pipe.pset_ex::>(key, serialized, ttl_millis(ttl)?) + .ignore(); + } + + let res: (Option>,) = pipe.query(&mut *conn)?; + match res.0 { + None => Ok(None), + Some(bytes) => { + let v: CachedRedisValue = deserialize_cached_redis_value(&bytes)?; + Ok(Some(v.value)) + } + } } } @@ -590,7 +1041,12 @@ where feature = "async", any( feature = "redis_smol", + feature = "redis_smol_native_tls", + feature = "redis_smol_rustls", feature = "redis_tokio", + feature = "redis_tokio_native_tls", + feature = "redis_tokio_rustls", + feature = "redis_async_cache", feature = "redis_connection_manager" ) ))] @@ -600,18 +1056,19 @@ mod async_redis { use std::sync::atomic::{AtomicBool, Ordering}; use super::{ - CachedRedisValue, ConnectionString, DEFAULT_NAMESPACE, DeserializeOwned, Display, ENV_KEY, - PhantomData, RedisCacheBuildError, RedisCacheError, Serialize, + CachedRedisValue, CachedRedisValueRef, ConnectionString, DEFAULT_NAMESPACE, + DeserializeOwned, Display, ENV_KEY, PhantomData, RedisCacheBuildError, RedisCacheError, + Serialize, }; - use crate::ConcurrentCachedAsync; + use crate::{ConcurrentCacheBase, ConcurrentCacheTtl, ConcurrentCachedAsync}; #[cfg(feature = "redis_async_cache")] use redis::IntoConnectionInfo; pub struct AsyncRedisCacheBuilder { - ttl: Duration, + ttl: Option, refresh: bool, namespace: String, - prefix: String, + prefix: Option, connection_string: Option, #[cfg(feature = "redis_async_cache")] client_side_caching: bool, @@ -619,18 +1076,33 @@ mod async_redis { _phantom: PhantomData (K, V)>, } + impl Default for AsyncRedisCacheBuilder + where + K: Display, + V: Serialize + DeserializeOwned, + { + fn default() -> Self { + Self::new() + } + } + impl AsyncRedisCacheBuilder where K: Display, V: Serialize + DeserializeOwned, { - /// Initialize a `RedisCacheBuilder` - pub fn new>(prefix: S, ttl: Duration) -> AsyncRedisCacheBuilder { + /// Initialize an `AsyncRedisCacheBuilder`. + /// + /// Both the key `prefix` and the `ttl` are required; set them with + /// [`prefix`](Self::prefix) and [`ttl`](Self::ttl) (or [`ttl_secs`](Self::ttl_secs) / + /// [`ttl_millis`](Self::ttl_millis)) before calling [`build`](Self::build). + #[must_use] + pub fn new() -> AsyncRedisCacheBuilder { Self { - ttl, + ttl: None, refresh: false, namespace: DEFAULT_NAMESPACE.to_string(), - prefix: prefix.as_ref().to_string(), + prefix: None, connection_string: None, #[cfg(feature = "redis_async_cache")] client_side_caching: false, @@ -638,14 +1110,35 @@ mod async_redis { } } - /// Specify the cache TTL as a `Duration`. - /// Redis enforces whole-second granularity; sub-second non-zero TTLs round up to 1 second. + /// Specify the cache TTL as a `Duration` (required). + /// TTL is stored with millisecond precision via `PSETEX`/`PEXPIRE`. + /// + /// Overrides any previously set ttl/ttl_secs/ttl_millis on this builder. #[must_use] pub fn ttl(mut self, ttl: Duration) -> Self { - self.ttl = ttl; + self.ttl = Some(ttl); self } + /// Specify the cache TTL in whole seconds. Equivalent to + /// `ttl(Duration::from_secs(secs))`. + /// + /// Overrides any previously set ttl/ttl_secs/ttl_millis on this builder. + #[must_use] + pub fn ttl_secs(self, secs: u64) -> Self { + self.ttl(Duration::from_secs(secs)) + } + + /// Specify the cache TTL in milliseconds. Equivalent to + /// `ttl(Duration::from_millis(millis))`. + /// TTL is stored with millisecond precision via `PSETEX`/`PEXPIRE`. + /// + /// Overrides any previously set ttl/ttl_secs/ttl_millis on this builder. + #[must_use] + pub fn ttl_millis(self, millis: u64) -> Self { + self.ttl(Duration::from_millis(millis)) + } + /// Specify whether cache hits refresh the TTL #[must_use] pub fn refresh_on_hit(mut self, refresh: bool) -> Self { @@ -666,15 +1159,21 @@ mod async_redis { self } - /// Set the prefix for cache keys. + /// Set the prefix for cache keys (required). /// Used to generate keys formatted as: `{namespace}:{prefix}:{key}`. /// Empty prefix values are omitted from the generated key. /// /// **Note:** colons in the prefix are not escaped and can cause key collisions /// with differently-split namespace/prefix combinations sharing the same segments. + /// + /// **Note:** the prefix is what scopes `async_cache_clear` to this logical + /// cache. With an empty prefix, `async_cache_clear` matches `:*` + /// and will delete entries belonging to every cache that shares the same + /// namespace. Set a unique prefix per logical cache to ensure + /// `async_cache_clear` is scoped correctly. #[must_use] pub fn prefix>(mut self, prefix: S) -> Self { - self.prefix = prefix.as_ref().to_string(); + self.prefix = Some(prefix.as_ref().to_string()); self } @@ -769,23 +1268,46 @@ mod async_redis { Ok(conn) } - /// The last step in building a `RedisCache` is to call `build()` + /// The last step in building an `AsyncRedisCache` is to call `build()` /// /// # Errors /// - /// Will return a `RedisCacheBuildError`, depending on the error + /// - `Build(BuildError::MissingRequired("prefix"))`: no key prefix was set. + /// - `Build(BuildError::MissingRequired("ttl"))`: no TTL was set. + /// - `Build(BuildError::InvalidValue { field: "ttl", .. })`: the configured TTL is zero. + /// - `EmptyScope`: both the namespace (after trimming trailing colons) and + /// the prefix are empty. `async_cache_clear` would otherwise issue + /// `SCAN MATCH *` and delete every key in the Redis database. + /// - `MissingConnectionString`: no connection string was set and the + /// `CACHED_REDIS_CONNECTION_STRING` env var is absent or invalid. + /// - `Connection`: the Redis client or multiplexed connection could not + /// be created. pub async fn build(self) -> Result, RedisCacheBuildError> { - super::super::validate_ttl(self.ttl)?; + // Validate required fields before any IO/connection attempt so the + // missing-required error is returned without needing a server. + if self.prefix.is_none() { + return Err(super::super::BuildError::MissingRequired("prefix").into()); + } + let ttl = self + .ttl + .ok_or(super::super::BuildError::MissingRequired("ttl"))?; + super::super::validate_ttl(ttl)?; + let prefix = self.prefix.as_deref().unwrap_or_default(); + if self.namespace.trim_end_matches(':').is_empty() && prefix.is_empty() { + return Err(RedisCacheBuildError::EmptyScope); + } + let connection_string = ConnectionString(self.resolve_connection_string()?); + #[cfg(not(feature = "redis_connection_manager"))] + let connection = self.create_multiplexed_connection().await?; + #[cfg(feature = "redis_connection_manager")] + let connection = self.create_connection_manager().await?; Ok(AsyncRedisCache { - ttl: Mutex::new(self.ttl), + ttl: Mutex::new(ttl), refresh: AtomicBool::new(self.refresh), - connection_string: ConnectionString(self.resolve_connection_string()?), - #[cfg(not(feature = "redis_connection_manager"))] - connection: self.create_multiplexed_connection().await?, - #[cfg(feature = "redis_connection_manager")] - connection: self.create_connection_manager().await?, + connection_string, + connection, namespace: self.namespace, - prefix: self.prefix, + prefix: self.prefix.unwrap_or_default(), _phantom: PhantomData, }) } @@ -814,6 +1336,35 @@ mod async_redis { _phantom: PhantomData (K, V)>, } + impl std::fmt::Debug for AsyncRedisCache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AsyncRedisCache") + .field("namespace", &self.namespace) + .field("prefix", &self.prefix) + .field("ttl", &*self.ttl.lock()) + .field("refresh", &self.refresh.load(Ordering::Relaxed)) + .finish_non_exhaustive() + } + } + + impl Clone for AsyncRedisCache { + /// Shallow clone - the underlying multiplexed connection or connection + /// manager is `Clone` (internally `Arc`-backed). The `ttl` is snapshot + /// into a fresh `Mutex` so the two handles can independently update + /// their TTL view. + fn clone(&self) -> Self { + Self { + ttl: Mutex::new(*self.ttl.lock()), + refresh: AtomicBool::new(self.refresh.load(Ordering::Relaxed)), + namespace: self.namespace.clone(), + prefix: self.prefix.clone(), + connection_string: self.connection_string.clone(), + connection: self.connection.clone(), + _phantom: PhantomData, + } + } + } + impl AsyncRedisCache where // `V: Sync` is intentionally absent: `V` is sent across the async @@ -822,15 +1373,14 @@ mod async_redis { K: Display + Send + Sync, V: Serialize + DeserializeOwned + Send, { - #[allow(clippy::new_ret_no_self)] - /// Initialize an `AsyncRedisCacheBuilder` - pub fn new>(prefix: S, ttl: Duration) -> AsyncRedisCacheBuilder { - AsyncRedisCacheBuilder::new(prefix, ttl) - } - /// Initialize an `AsyncRedisCacheBuilder`. - pub fn builder>(prefix: S, ttl: Duration) -> AsyncRedisCacheBuilder { - AsyncRedisCacheBuilder::new(prefix, ttl) + /// + /// The key `prefix` and `ttl` are required; set them via + /// [`AsyncRedisCacheBuilder::prefix`] and [`AsyncRedisCacheBuilder::ttl`] + /// before calling [`build`](AsyncRedisCacheBuilder::build). + #[must_use] + pub fn builder() -> AsyncRedisCacheBuilder { + AsyncRedisCacheBuilder::new() } fn generate_key(&self, key: &K) -> String { @@ -838,13 +1388,69 @@ mod async_redis { super::generate_redis_key(&self.namespace, &self.prefix, &key.to_string()) } - /// Return the redis connection string used. + /// `SCAN MATCH` glob covering every key this cache writes — the same + /// `{namespace}:{prefix}:` scope with a trailing `*`, with glob + /// metacharacters in the segments escaped (see + /// [`clear_match_pattern`](super::clear_match_pattern)). Used by + /// `async_cache_clear`. + fn clear_match_pattern(&self) -> String { + super::clear_match_pattern(&self.namespace, &self.prefix) + } + + /// Return the redis connection string as a [`ConnectionString`]. /// - /// **Note:** the returned string may contain credentials (e.g. `redis://:password@host`). - /// Do not log or expose it in error messages. + /// `ConnectionString`'s `Debug`/`Display` render `[REDACTED connection string]`, + /// so the returned value is safe to log or include in error messages. + /// Call [`ConnectionString::reveal`](super::ConnectionString::reveal) to + /// retrieve the raw URL when the full credentials are required. #[must_use] - pub fn connection_string(&self) -> String { - self.connection_string.as_str().to_string() + pub fn connection_string(&self) -> ConnectionString { + self.connection_string.clone() + } + } + + impl ConcurrentCacheBase for AsyncRedisCache { + type Error = RedisCacheError; + } + + impl ConcurrentCacheTtl for AsyncRedisCache { + /// Return the ttl of cached values (time to eviction), or `None` if expiry is disabled. + fn ttl(&self) -> Option { + let ttl = *self.ttl.lock(); + if ttl.is_zero() { None } else { Some(ttl) } + } + + /// Set the TTL for newly inserted cache entries, returning the previous TTL (or `None` + /// if expiry was disabled). Existing Redis keys are not affected; they retain whatever + /// TTL was applied when they were originally inserted. + /// + /// A zero `ttl` disables expiry — exactly equivalent to `unset_ttl`. + /// Subsequent `async_cache_set` writes use a plain `SET` (no expiry), so the keys + /// persist until explicitly removed. Use + /// [`try_set_ttl`](crate::ConcurrentCacheTtl::try_set_ttl) if you want a zero TTL rejected. + fn set_ttl(&self, ttl: Duration) -> Option { + let mut guard = self.ttl.lock(); + let old = *guard; + *guard = ttl; + if old.is_zero() { None } else { Some(old) } + } + + /// Disable expiry: subsequent `async_cache_set` writes store keys without a TTL + /// (plain `SET`). Returns the previous TTL, or `None` if expiry was already disabled. + fn unset_ttl(&self) -> Option { + let mut guard = self.ttl.lock(); + let old = *guard; + *guard = Duration::ZERO; + if old.is_zero() { None } else { Some(old) } + } + + fn refresh_on_hit(&self) -> bool { + self.refresh.load(Ordering::Relaxed) + } + + /// Set whether cache hits refresh the ttl of cached values, returning the previous flag value. + fn set_refresh_on_hit(&self, refresh: bool) -> bool { + self.refresh.swap(refresh, Ordering::Relaxed) } } @@ -855,8 +1461,6 @@ mod async_redis { K: Display + Clone + Send + Sync, V: Serialize + DeserializeOwned + Send, { - type Error = RedisCacheError; - /// Get a cached value async fn async_cache_get(&self, key: &K) -> Result, Self::Error> { let mut conn = self.connection.clone(); @@ -866,18 +1470,17 @@ mod async_redis { pipe.get(&key); if self.refresh.load(Ordering::Relaxed) { let ttl = *self.ttl.lock(); - pipe.expire(key, super::ttl_seconds_i64(ttl)?).ignore(); + // A zero (disabled) TTL means entries are stored without expiry; skip the + // refresh `PEXPIRE` so the key stays persistent (no TTL to renew). + if !ttl.is_zero() { + pipe.pexpire(key, super::ttl_millis_i64(ttl)?).ignore(); + } } - let res: (Option,) = pipe.query_async(&mut conn).await?; + let res: (Option>,) = pipe.query_async(&mut conn).await?; match res.0 { None => Ok(None), - Some(s) => { - let v: CachedRedisValue = serde_json::from_str(&s).map_err(|e| { - RedisCacheError::CacheDeserialization { - cached_value: s, - error: e, - } - })?; + Some(bytes) => { + let v: CachedRedisValue = super::deserialize_cached_redis_value(&bytes)?; Ok(Some(v.value)) } } @@ -889,28 +1492,24 @@ mod async_redis { let mut pipe = redis::pipe(); let key = self.generate_key(&key); - let ttl_secs = super::ttl_seconds(*self.ttl.lock())?; + let ttl = *self.ttl.lock(); let val = CachedRedisValue::new(val); + let serialized = rmp_serde::to_vec(&val)?; pipe.get(&key); - pipe.set_ex::( - key, - serde_json::to_string(&val) - .map_err(|e| RedisCacheError::CacheSerialization { error: e })?, - ttl_secs, - ) - .ignore(); - - let res: (Option,) = pipe.query_async(&mut conn).await?; + if ttl.is_zero() { + // Disabled TTL: write the key without expiry (plain `SET`). + pipe.set::>(key, serialized).ignore(); + } else { + pipe.pset_ex::>(key, serialized, super::ttl_millis(ttl)?) + .ignore(); + } + + let res: (Option>,) = pipe.query_async(&mut conn).await?; match res.0 { None => Ok(None), - Some(s) => { - let v: CachedRedisValue = serde_json::from_str(&s).map_err(|e| { - RedisCacheError::CacheDeserialization { - cached_value: s, - error: e, - } - })?; + Some(bytes) => { + let v: CachedRedisValue = super::deserialize_cached_redis_value(&bytes)?; Ok(Some(v.value)) } } @@ -924,16 +1523,11 @@ mod async_redis { pipe.get(&key); pipe.del::(key).ignore(); - let res: (Option,) = pipe.query_async(&mut conn).await?; + let res: (Option>,) = pipe.query_async(&mut conn).await?; match res.0 { None => Ok(None), - Some(s) => { - let v: CachedRedisValue = serde_json::from_str(&s).map_err(|e| { - RedisCacheError::CacheDeserialization { - cached_value: s, - error: e, - } - })?; + Some(bytes) => { + let v: CachedRedisValue = super::deserialize_cached_redis_value(&bytes)?; Ok(Some(v.value)) } } @@ -958,28 +1552,107 @@ mod async_redis { Ok(removed > 0) } - /// Set whether cache hits refresh the ttl of cached values, returning the previous flag value. - fn set_refresh_on_hit(&self, refresh: bool) -> bool { - self.refresh.swap(refresh, Ordering::Relaxed) - } - - /// Return the ttl of cached values (time to eviction). - fn ttl(&self) -> Option { - Some(*self.ttl.lock()) + /// Remove every entry written by this cache instance. + /// + /// Async counterpart of [`ConcurrentCached::cache_clear`](crate::ConcurrentCached::cache_clear) + /// for `RedisCache`. Scoped to this cache's `{namespace}:{prefix}:*` keyspace + /// via `SCAN` + batched `DEL`; it is **not** a server `FLUSHDB` and leaves keys + /// outside this namespace/prefix untouched. Cost is **O(n)** in the number of + /// matching keys (a cursored `SCAN`). + /// + /// **Note:** the `prefix` is what scopes a clear to a single logical cache. A + /// cache built with an empty prefix but a non-empty namespace will match every + /// key under that namespace on `async_cache_clear` (pattern `:*`), + /// which includes entries written by every other cache that shares the same + /// namespace. Set a unique prefix per logical cache to avoid this. + async fn async_cache_clear(&self) -> Result<(), Self::Error> { + let mut conn = self.connection.clone(); + let pattern = self.clear_match_pattern(); + let mut cursor: u64 = 0; + loop { + let (next, keys): (u64, Vec) = redis::cmd("SCAN") + .arg(cursor) + .arg("MATCH") + .arg(&pattern) + .arg("COUNT") + .arg(100) + .query_async(&mut conn) + .await?; + if !keys.is_empty() { + redis::cmd("DEL") + .arg(keys) + .query_async::<()>(&mut conn) + .await?; + } + if next == 0 { + break; + } + cursor = next; + } + Ok(()) } - /// Set the TTL for newly inserted cache entries. Existing Redis keys are not affected; - /// they retain whatever TTL was applied when they were originally inserted. - fn set_ttl(&self, ttl: Duration) -> Option { - let mut guard = self.ttl.lock(); - let old = *guard; - *guard = ttl; - Some(old) + /// Delegates to + /// [`async_cache_clear`](crate::ConcurrentCachedAsync::async_cache_clear): the + /// redis store tracks no in-memory metrics, so resetting is exactly clearing + /// the entries (matching `RedbCache`, which also overrides both). + async fn async_cache_reset(&self) -> Result<(), Self::Error> { + self.async_cache_clear().await } + } - /// Redis cache entries always require a TTL. This method is a no-op and always returns `None`. - fn unset_ttl(&self) -> Option { - None + impl crate::SerializeCachedAsync for AsyncRedisCache + where + K: Display + Clone + Send + Sync, + V: Serialize + DeserializeOwned + Send, + { + /// Serializes from the borrowed `val` (no clone) and `SET`s it, returning + /// the previous value if any. Async counterpart of + /// [`SerializeCached::cache_set_ref`](crate::SerializeCached::cache_set_ref). + /// + /// Serialization happens eagerly (before the returned future is awaited) so + /// the borrowed `&V` is never held across the `.await`, keeping the `V: Send` + /// (not `Sync`) bound consistent with `async_cache_set`. + fn async_cache_set_ref( + &self, + key: &K, + val: &V, + ) -> impl std::future::Future, Self::Error>> + Send { + let mut conn = self.connection.clone(); + let key = self.generate_key(key); + let ttl = *self.ttl.lock(); + // Compute the milliseconds eagerly (only for a real, non-zero TTL) so any + // error is surfaced before the future is awaited, matching the eager + // serialization below. + let ttl_ms = if ttl.is_zero() { + Ok(None) + } else { + super::ttl_millis(ttl).map(Some) + }; + let serialized = rmp_serde::to_vec(&CachedRedisValueRef::new(val)) + .map_err(|source| RedisCacheError::CacheSerialization { source }); + async move { + let mut pipe = redis::pipe(); + let serialized: Vec = serialized?; + let ttl_ms = ttl_ms?; + pipe.get(&key); + match ttl_ms { + // Disabled TTL: write the key without expiry (plain `SET`). + None => pipe.set::>(key, serialized).ignore(), + Some(ttl_ms) => pipe + .pset_ex::>(key, serialized, ttl_ms) + .ignore(), + }; + + let res: (Option>,) = pipe.query_async(&mut conn).await?; + match res.0 { + None => Ok(None), + Some(bytes) => { + let v: CachedRedisValue = super::deserialize_cached_redis_value(&bytes)?; + Ok(Some(v.value)) + } + } + } } } @@ -996,15 +1669,29 @@ mod async_redis { .as_millis() } + // No Redis server needed -- verifies the empty-scope guard in async `build()`. + #[tokio::test] + async fn async_empty_namespace_and_prefix_is_rejected() { + let result = AsyncRedisCacheBuilder::::new() + .prefix("") + .ttl(Duration::from_secs(1)) + .namespace("") + .build() + .await; + assert!( + matches!(result, Err(RedisCacheBuildError::EmptyScope)), + "expected EmptyScope" + ); + } + #[tokio::test] async fn test_async_redis_cache() { - let c: AsyncRedisCache = AsyncRedisCache::new( - format!("{}:async-redis-cache-test", now_millis()), - Duration::from_secs(2), - ) - .build() - .await - .unwrap(); + let c: AsyncRedisCache = AsyncRedisCache::builder() + .prefix(format!("{}:async-redis-cache-test", now_millis())) + .ttl(Duration::from_secs(2)) + .build() + .await + .unwrap(); assert!(c.async_cache_get(&1).await.unwrap().is_none()); @@ -1014,7 +1701,7 @@ mod async_redis { sleep(Duration::new(2, 500_000)); assert!(c.async_cache_get(&1).await.unwrap().is_none()); - let old = ConcurrentCachedAsync::set_ttl(&c, Duration::from_secs(1)).unwrap(); + let old = ConcurrentCacheTtl::set_ttl(&c, Duration::from_secs(1)).unwrap(); assert_eq!(2, old.as_secs()); assert!(c.async_cache_set(1, 100).await.unwrap().is_none()); assert!(c.async_cache_get(&1).await.unwrap().is_some()); @@ -1022,20 +1709,63 @@ mod async_redis { sleep(Duration::new(1, 600_000)); assert!(c.async_cache_get(&1).await.unwrap().is_none()); - ConcurrentCachedAsync::set_ttl(&c, Duration::from_secs(10)).unwrap(); + ConcurrentCacheTtl::set_ttl(&c, Duration::from_secs(10)).unwrap(); assert!(c.async_cache_set(1, 100).await.unwrap().is_none()); assert!(c.async_cache_set(2, 100).await.unwrap().is_none()); assert_eq!(c.async_cache_get(&1).await.unwrap().unwrap(), 100); assert_eq!(c.async_cache_get(&1).await.unwrap().unwrap(), 100); } } + + #[cfg(test)] + mod async_builder_ttl_setter_tests { + // No Redis server needed -- these only inspect the builder's ttl field set by + // the convenience setters, without calling `build()`. + use super::AsyncRedisCacheBuilder; + use crate::time::Duration; + + #[test] + fn ttl_secs_and_ttl_millis_set_duration() { + let b = AsyncRedisCacheBuilder::::new() + .prefix("p") + .ttl_secs(7); + assert_eq!(b.ttl, Some(Duration::from_secs(7))); + + let b = AsyncRedisCacheBuilder::::new() + .prefix("p") + .ttl_millis(250); + assert_eq!(b.ttl, Some(Duration::from_millis(250))); + } + + #[test] + fn ttl_setters_override_last_writer_wins() { + // ttl_secs then ttl_millis -> the millis value + let b = AsyncRedisCacheBuilder::::new() + .prefix("p") + .ttl_secs(10) + .ttl_millis(500); + assert_eq!(b.ttl, Some(Duration::from_millis(500))); + + // ttl_millis then ttl_secs -> the secs value + let b = AsyncRedisCacheBuilder::::new() + .prefix("p") + .ttl_millis(500) + .ttl_secs(10); + assert_eq!(b.ttl, Some(Duration::from_secs(10))); + } + } } #[cfg(all( feature = "async", any( feature = "redis_smol", + feature = "redis_smol_native_tls", + feature = "redis_smol_rustls", feature = "redis_tokio", + feature = "redis_tokio_native_tls", + feature = "redis_tokio_rustls", + feature = "redis_async_cache", feature = "redis_connection_manager" ) ))] @@ -1045,13 +1775,346 @@ mod async_redis { feature = "async", any( feature = "redis_smol", + feature = "redis_smol_native_tls", + feature = "redis_smol_rustls", feature = "redis_tokio", + feature = "redis_tokio_native_tls", + feature = "redis_tokio_rustls", + feature = "redis_async_cache", feature = "redis_connection_manager" ) ))) )] pub use async_redis::{AsyncRedisCache, AsyncRedisCacheBuilder}; +#[cfg(test)] +mod error_source_tests { + use std::error::Error; + + use super::{RedisCacheBuildError, RedisCacheError}; + + /// `RedisCacheBuildError::MissingConnectionString` must expose its inner + /// `VarError` via `Error::source()`. + #[test] + fn missing_connection_string_has_source() { + let inner = std::env::VarError::NotPresent; + let err = RedisCacheBuildError::MissingConnectionString { + env_key: "TEST_KEY".to_string(), + error: inner, + }; + let source = err + .source() + .expect("MissingConnectionString must expose its inner VarError as source()"); + // Non-tautological: the source must be the actual inner VarError, whose + // Display is the std message - not some other wrapped error. + assert_eq!( + source.to_string(), + std::env::VarError::NotPresent.to_string(), + "source() must be the inner VarError" + ); + // The source must downcast to VarError, proving the #[source] wiring + // points at the real inner field and not a re-stringified copy. + assert!( + source.downcast_ref::().is_some(), + "source() must downcast to std::env::VarError" + ); + } + + /// `MissingConnectionString`'s Display must read cleanly: env key and the + /// VarError's human message, with no `VarError { .. }` / `NotPresent` + /// debug noise. + #[test] + fn missing_connection_string_display_is_clean() { + let err = RedisCacheBuildError::MissingConnectionString { + env_key: "CACHED_REDIS_CONNECTION_STRING".to_string(), + error: std::env::VarError::NotPresent, + }; + let rendered = err.to_string(); + + // The env key is surfaced (it is formatted with {env_key:?}, so quoted). + assert!( + rendered.contains("CACHED_REDIS_CONNECTION_STRING"), + "Display must name the env var; got: {rendered}" + ); + // The inner error's *Display* message is present. + assert!( + rendered.contains(&std::env::VarError::NotPresent.to_string()), + "Display must include the VarError's human message; got: {rendered}" + ); + // No Debug-form noise. + assert!( + !rendered.contains("NotPresent"), + "Display must not leak the Debug variant name `NotPresent`; got: {rendered}" + ); + assert!( + !rendered.contains("VarError"), + "Display must not leak the `VarError` type name; got: {rendered}" + ); + } + + /// `RedisCacheError::CacheDeserialization` must expose its inner + /// `rmp_serde::decode::Error` via `Error::source()`. + #[test] + fn cache_deserialization_has_source() { + // Construct a decode error by trying to decode garbage bytes. + let bad_bytes: Vec = vec![0xc1]; // 0xc1 is an unused msgpack byte + let inner: rmp_serde::decode::Error = rmp_serde::from_slice::(&bad_bytes).unwrap_err(); + let inner_display = inner.to_string(); + let err = RedisCacheError::CacheDeserialization { + source: inner, + cached_value: bad_bytes.clone(), + }; + let source = err + .source() + .expect("CacheDeserialization must expose its inner decode::Error as source()"); + assert!( + source.downcast_ref::().is_some(), + "source() must downcast to rmp_serde::decode::Error" + ); + let rendered = err.to_string(); + assert!( + !rendered.is_empty(), + "Display must produce a non-empty string; got: {rendered}" + ); + // The source error's message is reachable via source(). + assert_eq!( + source.to_string(), + inner_display, + "source() display must match the original decode error" + ); + // The cached_value field is accessible on the variant. + if let RedisCacheError::CacheDeserialization { cached_value, .. } = &err { + assert_eq!(cached_value, &bad_bytes); + } else { + panic!("expected CacheDeserialization"); + } + } + + /// `RedisCacheError::CacheSerialization` must expose its inner + /// `rmp_serde::encode::Error` via `Error::source()`. + #[test] + fn cache_serialization_has_source() { + // Construct an encode error via a type that fails to serialize. + #[derive(Debug)] + struct Unserializable; + impl serde::Serialize for Unserializable { + fn serialize(&self, _: S) -> Result { + Err(serde::ser::Error::custom("intentional failure")) + } + } + let inner: rmp_serde::encode::Error = rmp_serde::to_vec(&Unserializable).unwrap_err(); + let inner_display = inner.to_string(); + let err = RedisCacheError::CacheSerialization { source: inner }; + let source = err + .source() + .expect("CacheSerialization must expose its inner encode::Error as source()"); + assert!( + source.downcast_ref::().is_some(), + "source() must downcast to rmp_serde::encode::Error" + ); + // The inner serde error message is reachable. + assert_eq!( + source.to_string(), + inner_display, + "source() display must match the original encode error" + ); + } + + /// MessagePack round-trip: a value serialized with rmp_serde can be + /// deserialized back to the same value without going through Redis. + /// This verifies the codec chosen for the redis store works end-to-end. + #[test] + fn msgpack_round_trip_via_cached_redis_value() { + use super::CachedRedisValue; + + let original: u64 = 42; + let wrapped = CachedRedisValue::new(original); + let bytes = rmp_serde::to_vec(&wrapped).expect("serialize must succeed"); + + // Bytes must be non-empty and not UTF-8 text (they are binary msgpack). + assert!(!bytes.is_empty()); + // The msgpack encoding of a struct is not the same as JSON text. + assert!( + std::str::from_utf8(&bytes).is_err() || !bytes.starts_with(b"{"), + "msgpack output should not look like JSON" + ); + + let recovered: CachedRedisValue = + rmp_serde::from_slice(&bytes).expect("deserialize must succeed"); + assert_eq!(recovered.value, original); + assert_eq!(recovered.version, Some(1)); + } + + /// MessagePack round-trip for a string value. + #[test] + fn msgpack_round_trip_string_value() { + use super::CachedRedisValue; + + let original = "hello, msgpack!".to_string(); + let wrapped = CachedRedisValue::new(original.clone()); + let bytes = rmp_serde::to_vec(&wrapped).expect("serialize must succeed"); + let recovered: CachedRedisValue = + rmp_serde::from_slice(&bytes).expect("deserialize must succeed"); + assert_eq!(recovered.value, original); + } + + /// The shared backward-read helper round-trips the current MessagePack format. + #[test] + fn deserialize_helper_reads_msgpack() { + use super::{CachedRedisValue, deserialize_cached_redis_value}; + + let bytes = rmp_serde::to_vec(&CachedRedisValue::new(7u64)).expect("serialize"); + let recovered: CachedRedisValue = + deserialize_cached_redis_value(&bytes).expect("msgpack must deserialize"); + assert_eq!(recovered.value, 7u64); + assert_eq!(recovered.version, Some(1)); + } + + /// The helper transparently reads the legacy pre-3.0 JSON format: a + /// `CachedRedisValue` serialized with `serde_json` (the cached 2.x on-disk + /// shape, carrying a `version` key) must deserialize via the helper. + #[test] + fn deserialize_helper_reads_legacy_json() { + use super::{CachedRedisValue, deserialize_cached_redis_value}; + + // Old format: JSON object with `value` and `version` keys. + let json = serde_json::to_vec(&CachedRedisValue::new("legacy".to_string())) + .expect("json serialize"); + // Sanity: this is JSON text, not msgpack, so the msgpack path must fail + // first and the helper must fall through to the JSON path. + assert!(json.starts_with(b"{")); + assert!(rmp_serde::from_slice::>(&json).is_err()); + + let recovered: CachedRedisValue = + deserialize_cached_redis_value(&json).expect("legacy JSON must deserialize"); + assert_eq!(recovered.value, "legacy"); + assert_eq!(recovered.version, Some(1)); + } + + /// A legacy JSON object that lacks the `version` key is NOT treated as the + /// old format: the helper must surface a `CacheDeserialization` error + /// (preserving the original msgpack error) rather than silently coercing. + #[test] + fn deserialize_helper_rejects_json_without_version() { + use super::{RedisCacheError, deserialize_cached_redis_value}; + + // `{"value": 1}` parses as JSON but has no `version` key. + let bytes = br#"{"value": 1}"#.to_vec(); + match deserialize_cached_redis_value::(&bytes) { + Ok(_) => panic!("JSON without a version key must not be accepted"), + Err(RedisCacheError::CacheDeserialization { cached_value, .. }) => { + assert_eq!(cached_value, bytes, "raw bytes must be preserved"); + } + Err(other) => panic!("expected CacheDeserialization, got: {other:?}"), + } + } + + /// Corrupt bytes (neither valid msgpack nor legacy JSON) yield a + /// `CacheDeserialization` error that preserves the original raw bytes. + #[test] + fn deserialize_helper_corrupt_bytes_preserve_value() { + use super::{RedisCacheError, deserialize_cached_redis_value}; + + // 0xc1 is an unused/reserved msgpack byte and is not valid JSON either. + let bytes: Vec = vec![0xc1, 0x00, 0xff]; + match deserialize_cached_redis_value::(&bytes) { + Ok(_) => panic!("corrupt bytes must not deserialize"), + Err(RedisCacheError::CacheDeserialization { cached_value, .. }) => { + assert_eq!( + cached_value, bytes, + "the original corrupt bytes must be preserved in the error" + ); + } + Err(other) => panic!("expected CacheDeserialization, got: {other:?}"), + } + } + + /// `RedisCache` is `Clone` - compile-time bound check. + #[allow(dead_code)] + fn assert_clone() {} + #[allow(dead_code)] + fn check_redis_cache_is_clone() { + assert_clone::>(); + } + /// `AsyncRedisCache` is `Clone` - compile-time bound check. + #[cfg(all( + feature = "async", + any( + feature = "redis_smol", + feature = "redis_smol_native_tls", + feature = "redis_smol_rustls", + feature = "redis_tokio", + feature = "redis_tokio_native_tls", + feature = "redis_tokio_rustls", + feature = "redis_async_cache", + feature = "redis_connection_manager" + ) + ))] + #[allow(dead_code)] + fn check_async_redis_cache_is_clone() { + assert_clone::>(); + } +} + +#[cfg(test)] +mod connection_string_tests { + // No Redis server needed -- verifies the redaction behavior of + // `ConnectionString` (returned by `connection_string()`) and that `reveal()` + // exposes the raw URL. + use super::ConnectionString; + + /// `Display` of `ConnectionString` returns the redacted placeholder, not the raw URL. + #[test] + fn display_is_redacted() { + let cs = ConnectionString("redis://:secret@127.0.0.1:6379".to_string()); + let displayed = cs.to_string(); + assert_eq!( + displayed, "[REDACTED connection string]", + "Display must return the redacted placeholder, got: {displayed}" + ); + assert!( + !displayed.contains("secret"), + "Display must not expose the password; got: {displayed}" + ); + } + + /// `Debug` of `ConnectionString` also returns the redacted placeholder. + #[test] + fn debug_is_redacted() { + let cs = ConnectionString("redis://:secret@127.0.0.1:6379".to_string()); + let debugged = format!("{cs:?}"); + assert_eq!( + debugged, "[REDACTED connection string]", + "Debug must return the redacted placeholder, got: {debugged}" + ); + assert!( + !debugged.contains("secret"), + "Debug must not expose the password; got: {debugged}" + ); + } + + /// `reveal()` returns the raw URL, including credentials. + #[test] + fn reveal_returns_raw() { + let raw = "redis://:secret@127.0.0.1:6379"; + let cs = ConnectionString(raw.to_string()); + assert_eq!(cs.reveal(), raw); + assert!(cs.reveal().contains("secret")); + } + + /// Both `Debug` and `Display` redact while `reveal()` still exposes the raw value. + #[test] + fn debug_and_display_redact_but_reveal_does_not() { + let cs = ConnectionString("redis://:s3cr3t@localhost:6379/0".to_string()); + assert_eq!(cs.to_string(), "[REDACTED connection string]"); + assert_eq!(format!("{cs:?}"), "[REDACTED connection string]"); + assert!(!cs.to_string().contains("s3cr3t")); + assert!(!format!("{cs:?}").contains("s3cr3t")); + // The raw value is still recoverable via reveal(). + assert!(cs.reveal().contains("s3cr3t")); + } +} + #[cfg(test)] /// Cache store tests mod tests { @@ -1069,13 +2132,12 @@ mod tests { #[test] fn redis_cache() { - let c: RedisCache = RedisCache::new( - format!("{}:redis-cache-test", now_millis()), - Duration::from_secs(2), - ) - .namespace("in-tests:") - .build() - .unwrap(); + let c: RedisCache = RedisCache::builder() + .prefix(format!("{}:redis-cache-test", now_millis())) + .ttl(Duration::from_secs(2)) + .namespace("in-tests:") + .build() + .unwrap(); assert!(c.cache_get(&1).unwrap().is_none()); @@ -1085,7 +2147,7 @@ mod tests { sleep(Duration::new(2, 500_000)); assert!(c.cache_get(&1).unwrap().is_none()); - let old = ConcurrentCached::set_ttl(&c, Duration::from_secs(1)).unwrap(); + let old = ConcurrentCacheTtl::set_ttl(&c, Duration::from_secs(1)).unwrap(); assert_eq!(2, old.as_secs()); assert!(c.cache_set(1, 100).unwrap().is_none()); assert!(c.cache_get(&1).unwrap().is_some()); @@ -1093,7 +2155,7 @@ mod tests { sleep(Duration::new(1, 600_000)); assert!(c.cache_get(&1).unwrap().is_none()); - ConcurrentCached::set_ttl(&c, Duration::from_secs(10)).unwrap(); + ConcurrentCacheTtl::set_ttl(&c, Duration::from_secs(10)).unwrap(); assert!(c.cache_set(1, 100).unwrap().is_none()); assert!(c.cache_set(2, 100).unwrap().is_none()); assert_eq!(c.cache_get(&1).unwrap().unwrap(), 100); @@ -1102,12 +2164,11 @@ mod tests { #[test] fn remove() { - let c: RedisCache = RedisCache::new( - format!("{}:redis-cache-test-remove", now_millis()), - Duration::from_secs(3600), - ) - .build() - .unwrap(); + let c: RedisCache = RedisCache::builder() + .prefix(format!("{}:redis-cache-test-remove", now_millis())) + .ttl(Duration::from_secs(3600)) + .build() + .unwrap(); assert!(c.cache_set(1, 100).unwrap().is_none()); assert!(c.cache_set(2, 200).unwrap().is_none()); diff --git a/src/stores/sharded/expiring.rs b/src/stores/sharded/expiring.rs index 1f1d9955..2325e24f 100644 --- a/src/stores/sharded/expiring.rs +++ b/src/stores/sharded/expiring.rs @@ -11,7 +11,7 @@ use std::collections::HashMap; #[cfg(feature = "async_core")] use crate::ConcurrentCachedAsync; -use crate::{CacheMetrics, ConcurrentCached, ConcurrentCloneCached, Expires}; +use crate::{CacheMetrics, ConcurrentCacheBase, ConcurrentCached, ConcurrentCloneCached, Expires}; use super::{ CachePadded, DefaultShardHasher, Shard, ShardHasher, checked_shard_count, shard_index, @@ -45,9 +45,16 @@ struct ExpiringInner { /// **Note**: reads return owned values cloned from under the shard lock, so `V` must /// implement `Clone` (in addition to `Expires`). /// +/// **`len` / `evict` contract**: `len()` (the inherent method) returns the raw stored entry +/// count across all shards and may include expired-but-not-yet-swept entries. Call `evict()` +/// (via [`ConcurrentCacheEvict`](crate::ConcurrentCacheEvict)) to physically remove expired +/// entries, reclaim memory, and obtain an accurate live count. Sharded stores do not implement +/// `CachedIter`. +/// /// This is a type alias for `ShardedExpiringCacheBase`. -/// To use a custom shard hasher, construct a [`ShardedExpiringCacheBase`] directly via -/// [`ShardedExpiringCacheBase::builder()`]. +/// To use a custom shard hasher, call [`ShardedExpiringCache::builder()`] and then +/// [`hasher`](ShardedExpiringCacheBuilder::hasher), which yields a +/// `ShardedExpiringCacheBase` over your hasher. pub type ShardedExpiringCache = ShardedExpiringCacheBase; /// Backing type for [`ShardedExpiringCache`] with a generic shard hasher `H`. @@ -73,20 +80,42 @@ impl std::fmt::Debug for ShardedExpiringCacheBase { } } -impl ShardedExpiringCacheBase +impl ShardedExpiringCacheBase where K: Hash + Eq, V: Expires, - H: ShardHasher, { - /// Return a builder for constructing a [`ShardedExpiringCacheBase`]. + /// Construct a ready-to-use [`ShardedExpiringCache`] with the [`DefaultShardHasher`] + /// and a default shard count. + /// + /// `ShardedExpiringCache` has no required configuration, so this never fails. For a + /// custom hasher, shard count, or `on_evict`, use [`builder`](Self::builder). + #[must_use] + pub fn new() -> ShardedExpiringCache { + Self::builder() + .build() + .expect("ShardedExpiringCache default build is infallible") + } + + /// Return a builder for constructing a [`ShardedExpiringCache`]. /// - /// Always returns a builder with [`DefaultShardHasher`], regardless of the `H` type parameter - /// on `Self`. Call `.hasher(h)` on the builder to use a custom hasher. + /// The builder starts with the [`DefaultShardHasher`]. To use a custom hasher, call + /// [`hasher`](ShardedExpiringCacheBuilder::hasher) on the returned builder; it switches the + /// builder's hasher type and `build` then yields a `ShardedExpiringCacheBase` over that + /// hasher. `new` and `builder` exist only on the default-hasher alias, so a custom hasher is + /// always introduced via `hasher`, never a `ShardedExpiringCacheBase::<_, _, H>` turbofish. + #[must_use] pub fn builder() -> ShardedExpiringCacheBuilder { ShardedExpiringCacheBuilder::default() } +} +impl ShardedExpiringCacheBase +where + K: Hash + Eq, + V: Expires, + H: ShardHasher, +{ #[inline] fn shard_of(&self, k: &K) -> &CachePadded>> { let h = self.inner.hasher.shard_hash(k); @@ -106,7 +135,7 @@ where } } -impl + Clone> +impl> ShardedExpiringCacheBase { /// Return an independent deep copy of this cache — entries and metrics are @@ -143,6 +172,58 @@ impl + Clone> } } +impl> ShardedExpiringCacheBase +where + K: Hash + Eq, + V: Clone + Expires, +{ + /// Retrieve a cached value, returning `None` on a miss or if the entry has expired. + /// + /// This is the infallible ergonomic API for the concrete type. Generic code over + /// [`ConcurrentCached`] should use the `Result`-returning trait methods (`cache_get` or the + /// trait's `get` alias), callable as `ConcurrentCached::get(&store, k)` when this inherent + /// method is in scope. + #[must_use] + pub fn get(&self, k: &K) -> Option { + ConcurrentCached::cache_get(self, k).unwrap() + } + + /// Insert a key-value pair and return the previous value, if any. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn set(&self, k: K, v: V) -> Option { + ConcurrentCached::cache_set(self, k, v).unwrap() + } + + /// Remove a cached value and return it if the entry was live. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn remove(&self, k: &K) -> Option { + ConcurrentCached::cache_remove(self, k).unwrap() + } + + /// Remove a cached entry and return the stored key and value, if present. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn remove_entry(&self, k: &K) -> Option<(K, V)> { + ConcurrentCached::cache_remove_entry(self, k).unwrap() + } + + /// Delete a cached entry without returning the value. Returns `true` if an entry was removed. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn delete(&self, k: &K) -> bool { + ConcurrentCached::cache_delete(self, k).unwrap() + } + + /// Remove all entries from every shard and reset metrics. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn reset(&self) { + ConcurrentCached::cache_reset(self).unwrap() + } +} + impl> ShardedExpiringCacheBase where K: Hash + Eq, @@ -272,7 +353,7 @@ where } } -impl ConcurrentCached for ShardedExpiringCacheBase +impl ConcurrentCacheBase for ShardedExpiringCacheBase where K: Hash + Eq, V: Clone + Expires, @@ -280,6 +361,41 @@ where { type Error = std::convert::Infallible; + fn cache_size(&self) -> Result, Self::Error> { + Ok(Some(self.len())) + } + + fn cache_hits(&self) -> Option { + Some( + self.inner + .shards + .iter() + .map(|s| s.hits.load(Ordering::Relaxed)) + .sum(), + ) + } + + fn cache_misses(&self) -> Option { + Some( + self.inner + .shards + .iter() + .map(|s| s.misses.load(Ordering::Relaxed)) + .sum(), + ) + } + + fn cache_evictions(&self) -> Option { + Some(self.inner.evictions.load(Ordering::Relaxed)) + } +} + +impl ConcurrentCached for ShardedExpiringCacheBase +where + K: Hash + Eq, + V: Clone + Expires, + H: ShardHasher, +{ fn cache_get(&self, k: &K) -> Result, Self::Error> { let shard = self.shard_of(k); // Expiry check — try with a read lock first to allow read concurrency on hits. @@ -364,10 +480,6 @@ where Ok(removed) } - fn cache_size(&self) -> Result, Self::Error> { - Ok(Some(self.len())) - } - fn cache_clear(&self) -> Result<(), Self::Error> { self.clear(); Ok(()) @@ -386,11 +498,6 @@ where self.inner.evictions.store(0, Ordering::Relaxed); Ok(()) } - - /// No-op: this store uses value-defined expiry, not a refreshable TTL. Always returns `false`. - fn set_refresh_on_hit(&self, _refresh: bool) -> bool { - false - } } #[cfg(feature = "async_core")] @@ -400,8 +507,6 @@ where V: Clone + Expires + Send + Sync, H: ShardHasher, { - type Error = std::convert::Infallible; - async fn async_cache_get(&self, k: &K) -> Result, Self::Error> { ConcurrentCached::cache_get(self, k) } @@ -429,14 +534,6 @@ where async fn async_cache_reset_metrics(&self) -> Result<(), Self::Error> { ConcurrentCached::cache_reset_metrics(self) } - - fn cache_size(&self) -> Result, Self::Error> { - Ok(Some(self.len())) - } - - fn set_refresh_on_hit(&self, b: bool) -> bool { - >::set_refresh_on_hit(self, b) - } } impl ConcurrentCacheEvict for ShardedExpiringCacheBase @@ -494,6 +591,7 @@ impl ShardedExpiringCacheBuilder { /// distribute keys across those high bits to avoid lopsided shards; a hasher that only /// varies the low 32 bits will pile every key into one shard. See [`ShardHasher`] for the /// distribution contract and a worked example. Defaults to [`DefaultShardHasher`]. + #[doc(alias = "with_hasher")] #[must_use] pub fn hasher>(self, hasher: H2) -> ShardedExpiringCacheBuilder { ShardedExpiringCacheBuilder { @@ -570,6 +668,7 @@ impl ShardedExpiringCacheBuilder { /// /// Returns [`BuildError`] if the `shards` count is zero or overflows when rounded /// up to the next power of two. + #[must_use = "the Result from build() must be used"] pub fn build(self) -> Result, BuildError> where K: Hash + Eq, @@ -626,12 +725,29 @@ where } } } + + /// Non-renewing read: takes only a read lock, never touches the hits/misses counters or + /// removes the entry. Returns `(Some(v), expired)` for a present entry (expired or not) or + /// `(None, false)` when absent. + fn cache_peek_with_expiry_status(&self, k: &K) -> (Option, bool) { + let shard = self.shard_of(k); + let guard = shard.lock.read(); + match guard.get(k) { + None => (None, false), + Some(v) => { + let expired = v.is_expired(); + (Some(v.clone()), expired) + } + } + } } #[cfg(test)] mod tests { use super::*; + use crate::ConcurrentCached; use crate::ConcurrentCached as SyncConcurrentCached; + use crate::ConcurrentCachedExt as SyncConcurrentCachedExt; use crate::ConcurrentCloneCached; #[derive(Clone)] @@ -645,6 +761,39 @@ mod tests { } } + #[test] + fn new_returns_ready_cache() { + let c = ShardedExpiringCache::::new(); + assert_eq!( + SyncConcurrentCachedExt::set( + &c, + 1, + Val { + v: 10, + expired: false + } + ) + .unwrap() + .map(|v| v.v), + None + ); + assert_eq!( + SyncConcurrentCachedExt::get(&c, &1).unwrap().map(|v| v.v), + Some(10) + ); + // Expired values are not returned. + SyncConcurrentCachedExt::set( + &c, + 2, + Val { + v: 20, + expired: true, + }, + ) + .unwrap(); + assert!(SyncConcurrentCachedExt::get(&c, &2).unwrap().is_none()); + } + #[test] fn copy_from_skips_expired() { let old = ShardedExpiringCache::::builder().build().unwrap(); @@ -1098,4 +1247,238 @@ mod tests { "entry must still be expired on second expiry-status call" ); } + + #[test] + fn peek_with_expiry_status_no_side_effects() { + // shards(1) makes counter captures exact. + let c = ShardedExpiringCacheBase::::builder() + .shards(1) + .build() + .unwrap(); + + SyncConcurrentCached::cache_set( + &c, + 1u32, + Val { + v: 42, + expired: false, + }, + ) + .expect("insert must succeed"); + + // Capture counters before any peek. + let before = c.metrics(); + + // Live key: expect (Some(v), false). + let (val, expired) = ConcurrentCloneCached::cache_peek_with_expiry_status(&c, &1u32); + assert_eq!( + val.map(|x| x.v), + Some(42), + "live peek must return the value" + ); + assert!(!expired, "live peek must report expired=false"); + + // Absent key: expect (None, false). + let (val2, expired2) = ConcurrentCloneCached::cache_peek_with_expiry_status(&c, &999u32); + assert!(val2.is_none(), "absent peek must return None"); + assert!(!expired2, "absent peek must report expired=false"); + + // Counters must be unchanged. + let after = c.metrics(); + assert_eq!(after.hits, before.hits, "peek must not increment hits"); + assert_eq!( + after.misses, before.misses, + "peek must not increment misses" + ); + assert_eq!( + after.evictions, before.evictions, + "peek must not increment evictions" + ); + + // Entry must still be present. + assert!( + SyncConcurrentCached::cache_get(&c, &1u32) + .expect("cache_get must succeed") + .is_some(), + "entry must still be present after peek" + ); + } + + #[test] + fn peek_with_expiry_status_stale_entry_no_side_effects() { + // Use Val with expired=true to simulate a stale entry without sleeping. + let c = ShardedExpiringCacheBase::::builder() + .shards(1) + .build() + .unwrap(); + + SyncConcurrentCached::cache_set( + &c, + 1u32, + Val { + v: 77, + expired: true, + }, + ) + .expect("insert must succeed"); + + let before = c.metrics(); + + let (val, expired) = ConcurrentCloneCached::cache_peek_with_expiry_status(&c, &1u32); + assert_eq!( + val.map(|x| x.v), + Some(77), + "expired peek must return the stale value" + ); + assert!(expired, "expired peek must report expired=true"); + + // Counters must be unchanged. + let after = c.metrics(); + assert_eq!( + after.hits, before.hits, + "expired peek must not increment hits" + ); + assert_eq!( + after.misses, before.misses, + "expired peek must not increment misses" + ); + assert_eq!( + after.evictions, before.evictions, + "expired peek must not increment evictions" + ); + + // Entry must NOT have been removed by the peek. + let (val2, expired2) = ConcurrentCloneCached::cache_peek_with_expiry_status(&c, &1u32); + assert_eq!( + val2.map(|x| x.v), + Some(77), + "entry must still be present after expired peek" + ); + assert!(expired2, "entry must still be expired after peek"); + } + + // --- Inherent infallible method tests --- + + #[test] + fn inherent_get_returns_option_not_result() { + let c = ShardedExpiringCache::::new(); + let v: Option = c.get(&1); + assert!(v.is_none()); + c.set( + 1, + Val { + v: 42, + expired: false, + }, + ); + let v: Option = c.get(&1); + assert_eq!(v.map(|x| x.v), Some(42)); + } + + #[test] + fn inherent_get_returns_none_for_expired() { + let c = ShardedExpiringCache::::new(); + c.set( + 1, + Val { + v: 99, + expired: true, + }, + ); + // Expired entries are filtered out by get. + let v: Option = c.get(&1); + assert!( + v.is_none(), + "expired entry must return None from inherent get" + ); + } + + #[test] + fn inherent_set_returns_previous_value() { + let c = ShardedExpiringCache::::new(); + let prev: Option = c.set( + 1, + Val { + v: 10, + expired: false, + }, + ); + assert!(prev.is_none()); + let prev: Option = c.set( + 1, + Val { + v: 20, + expired: false, + }, + ); + assert_eq!(prev.map(|x| x.v), Some(10)); + assert_eq!(c.get(&1).map(|x| x.v), Some(20)); + } + + #[test] + fn inherent_remove_returns_prior_live_value() { + let c = ShardedExpiringCache::::new(); + c.set( + 1, + Val { + v: 99, + expired: false, + }, + ); + let v: Option = c.remove(&1); + assert_eq!(v.map(|x| x.v), Some(99)); + assert!(c.remove(&1).is_none()); + } + + #[test] + fn inherent_remove_entry_returns_key_and_value() { + let c = ShardedExpiringCache::::new(); + c.set( + 7, + Val { + v: 77, + expired: false, + }, + ); + let pair: Option<(u32, Val)> = c.remove_entry(&7); + assert_eq!(pair.map(|(k, v)| (k, v.v)), Some((7, 77))); + assert!(c.remove_entry(&7).is_none()); + } + + #[test] + fn inherent_delete_returns_bool() { + let c = ShardedExpiringCache::::new(); + c.set( + 1, + Val { + v: 10, + expired: false, + }, + ); + let removed: bool = c.delete(&1); + assert!(removed); + let removed: bool = c.delete(&1); + assert!(!removed); + } + + #[test] + fn inherent_and_trait_methods_coexist_via_fully_qualified_path() { + fn use_trait(cache: &C, k: u32, v: Val) + where + C: SyncConcurrentCached, + { + let _: Result, _> = ConcurrentCached::cache_set(cache, k, v); + let _: Result, _> = ConcurrentCached::cache_get(cache, &k); + let _: Result, _> = ConcurrentCached::cache_remove(cache, &k); + } + let c = ShardedExpiringCache::::new(); + use_trait( + &c, + 1, + Val { + v: 42, + expired: false, + }, + ); + } } diff --git a/src/stores/sharded/expiring_lru.rs b/src/stores/sharded/expiring_lru.rs index 26e47460..5d3c89a4 100644 --- a/src/stores/sharded/expiring_lru.rs +++ b/src/stores/sharded/expiring_lru.rs @@ -5,7 +5,8 @@ use std::sync::atomic::{AtomicU64, Ordering}; #[cfg(feature = "async_core")] use crate::ConcurrentCachedAsync; use crate::{ - CacheMetrics, CachedIter, CachedPeek, ConcurrentCached, ConcurrentCloneCached, Expires, + CacheMetrics, CachedIter, CachedPeek, ConcurrentCacheBase, ConcurrentCached, + ConcurrentCloneCached, Expires, }; use super::{ @@ -40,12 +41,18 @@ struct ExpiringLruInner { /// return owned values cloned from under the shard lock, in addition to `V: Expires`). /// /// This is a type alias for `ShardedExpiringLruCacheBase`. -/// To use a custom shard hasher, construct a [`ShardedExpiringLruCacheBase`] directly via -/// [`ShardedExpiringLruCacheBase::builder()`]. +/// To use a custom shard hasher, call [`ShardedExpiringLruCache::builder()`] and then +/// [`hasher`](ShardedExpiringLruCacheBuilder::hasher), which yields a +/// `ShardedExpiringLruCacheBase` over your hasher. /// /// **Note**: Setting an `on_evict` callback requires the callback itself to be `'static` because /// the cache stores it behind an `Arc`. This does not add `'static` /// bounds to `K` or `V`. +/// +/// **`len` / `evict` contract**: `len()` (the inherent method) returns the raw stored entry +/// count across all shards and may include expired-but-not-yet-swept entries. Call `evict()` +/// (via [`ConcurrentCacheEvict`](crate::ConcurrentCacheEvict)) to physically remove expired +/// entries and obtain an accurate live count. Sharded stores do not implement `CachedIter`. pub type ShardedExpiringLruCache = ShardedExpiringLruCacheBase; /// Backing type for [`ShardedExpiringLruCache`] with a generic shard hasher `H`. @@ -72,20 +79,51 @@ impl std::fmt::Debug for ShardedExpiringLruCacheBase { } } -impl ShardedExpiringLruCacheBase +impl ShardedExpiringLruCacheBase where K: Hash + Eq + Clone, V: Expires, - H: ShardHasher, { - /// Return a builder for constructing a [`ShardedExpiringLruCacheBase`]. + /// Construct a ready-to-use [`ShardedExpiringLruCache`] holding up to roughly `max_size` + /// entries total, with the [`DefaultShardHasher`] and a default shard count. + /// + /// Note that the effective total capacity can exceed `max_size` for small values + /// because each shard reserves a minimum capacity (see + /// [`max_size`](ShardedExpiringLruCacheBuilder::max_size)). For a custom hasher, shard + /// count, per-shard cap, or `on_evict`, use [`builder`](Self::builder). + /// + /// # Panics + /// + /// Panics if `max_size` is `0`, or if the effective sharded capacity overflows + /// `usize` / a per-shard allocation fails. Use [`builder`](Self::builder) with + /// [`build`](ShardedExpiringLruCacheBuilder::build) to handle those cases without panicking. + #[must_use] + pub fn new(max_size: usize) -> ShardedExpiringLruCache { + Self::builder().max_size(max_size).build().expect( + "ShardedExpiringLruCache::new requires a non-zero max_size with a valid allocation", + ) + } + + /// Return a builder for constructing a [`ShardedExpiringLruCache`]. /// - /// Always returns a builder with [`DefaultShardHasher`], regardless of the `H` type parameter - /// on `Self`. Call `.hasher(h)` on the builder to use a custom hasher. + /// The builder starts with the [`DefaultShardHasher`]. To use a custom hasher, call + /// [`hasher`](ShardedExpiringLruCacheBuilder::hasher) on the returned builder; it switches + /// the builder's hasher type and `build` then yields a `ShardedExpiringLruCacheBase` over + /// that hasher. `new` and `builder` exist only on the default-hasher alias, so a custom + /// hasher is always introduced via `hasher`, never a + /// `ShardedExpiringLruCacheBase::<_, _, H>` turbofish. + #[must_use] pub fn builder() -> ShardedExpiringLruCacheBuilder { ShardedExpiringLruCacheBuilder::default() } +} +impl ShardedExpiringLruCacheBase +where + K: Hash + Eq + Clone, + V: Expires, + H: ShardHasher, +{ #[inline] fn shard_of(&self, k: &K) -> &CachePadded>> { let h = self.inner.hasher.shard_hash(k); @@ -93,7 +131,7 @@ where } } -impl + Clone> +impl> ShardedExpiringLruCacheBase { /// Return an independent deep copy of this cache — entries and metrics are @@ -131,6 +169,58 @@ impl + Clone> } } +impl> ShardedExpiringLruCacheBase +where + K: Hash + Eq + Clone, + V: Clone + Expires, +{ + /// Retrieve a cached value, returning `None` on a miss or if the entry has expired. + /// + /// This is the infallible ergonomic API for the concrete type. Generic code over + /// [`ConcurrentCached`] should use the `Result`-returning trait methods (`cache_get` or the + /// trait's `get` alias), callable as `ConcurrentCached::get(&store, k)` when this inherent + /// method is in scope. + #[must_use] + pub fn get(&self, k: &K) -> Option { + ConcurrentCached::cache_get(self, k).unwrap() + } + + /// Insert a key-value pair and return the previous value, if any. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn set(&self, k: K, v: V) -> Option { + ConcurrentCached::cache_set(self, k, v).unwrap() + } + + /// Remove a cached value and return it if the entry was live. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn remove(&self, k: &K) -> Option { + ConcurrentCached::cache_remove(self, k).unwrap() + } + + /// Remove a cached entry and return the stored key and value, if present. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn remove_entry(&self, k: &K) -> Option<(K, V)> { + ConcurrentCached::cache_remove_entry(self, k).unwrap() + } + + /// Delete a cached entry without returning the value. Returns `true` if an entry was removed. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn delete(&self, k: &K) -> bool { + ConcurrentCached::cache_delete(self, k).unwrap() + } + + /// Remove all entries from every shard and reset metrics. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn reset(&self) { + ConcurrentCached::cache_reset(self).unwrap() + } +} + impl> ShardedExpiringLruCacheBase where K: Hash + Eq + Clone, @@ -263,7 +353,7 @@ where } } -impl ConcurrentCached for ShardedExpiringLruCacheBase +impl ConcurrentCacheBase for ShardedExpiringLruCacheBase where K: Hash + Eq + Clone, V: Clone + Expires, @@ -271,6 +361,52 @@ where { type Error = std::convert::Infallible; + fn cache_size(&self) -> Result, Self::Error> { + Ok(Some(self.len())) + } + + fn cache_hits(&self) -> Option { + Some( + self.inner + .shards + .iter() + .map(|s| s.hits.load(Ordering::Relaxed)) + .sum(), + ) + } + + fn cache_misses(&self) -> Option { + Some( + self.inner + .shards + .iter() + .map(|s| s.misses.load(Ordering::Relaxed)) + .sum(), + ) + } + + fn cache_capacity(&self) -> Option { + Some(self.inner.total_capacity) + } + + fn cache_evictions(&self) -> Option { + let mut inner_evictions = 0u64; + for shard in self.inner.shards.iter() { + let guard = shard.lock.read(); + if let Some(e) = Cached::cache_evictions(&*guard) { + inner_evictions += e; + } + } + Some(inner_evictions + self.inner.evictions.load(Ordering::Relaxed)) + } +} + +impl ConcurrentCached for ShardedExpiringLruCacheBase +where + K: Hash + Eq + Clone, + V: Clone + Expires, + H: ShardHasher, +{ fn cache_get(&self, k: &K) -> Result, Self::Error> { let shard = self.shard_of(k); let mut guard = shard.lock.write(); @@ -352,10 +488,6 @@ where Ok(Some((key, val))) } - fn cache_size(&self) -> Result, Self::Error> { - Ok(Some(self.len())) - } - fn cache_clear(&self) -> Result<(), Self::Error> { self.clear(); Ok(()) @@ -376,11 +508,6 @@ where self.inner.evictions.store(0, Ordering::Relaxed); Ok(()) } - - /// No-op: this store uses value-defined expiry, not a refreshable TTL. Always returns `false`. - fn set_refresh_on_hit(&self, _refresh: bool) -> bool { - false - } } #[cfg(feature = "async_core")] @@ -390,8 +517,6 @@ where V: Clone + Expires + Send + Sync, H: ShardHasher, { - type Error = std::convert::Infallible; - async fn async_cache_get(&self, k: &K) -> Result, Self::Error> { ConcurrentCached::cache_get(self, k) } @@ -419,14 +544,6 @@ where async fn async_cache_reset_metrics(&self) -> Result<(), Self::Error> { ConcurrentCached::cache_reset_metrics(self) } - - fn cache_size(&self) -> Result, Self::Error> { - Ok(Some(self.len())) - } - - fn set_refresh_on_hit(&self, b: bool) -> bool { - >::set_refresh_on_hit(self, b) - } } impl ShardedExpiringLruCacheBase @@ -564,6 +681,7 @@ impl ShardedExpiringLruCacheBuilder { /// distribute keys across those high bits to avoid lopsided shards; a hasher that only /// varies the low 32 bits will pile every key into one shard. See [`ShardHasher`] for the /// distribution contract and a worked example. Defaults to [`DefaultShardHasher`]. + #[doc(alias = "with_hasher")] #[must_use] pub fn hasher>( self, @@ -710,6 +828,7 @@ impl ShardedExpiringLruCacheBuilder { /// Returns [`BuildError`] if `size` (or `per_shard_max_size`) was not set, is `0`, /// or if both `max_size` and `per_shard_max_size` are set simultaneously, /// or if the shard count overflows. + #[must_use = "the Result from build() must be used"] pub fn build(self) -> Result, BuildError> where K: Hash + Eq + Clone, @@ -780,11 +899,27 @@ where (value, false) } } + + /// Non-renewing read: takes only a read lock, does not promote LRU recency, does not touch + /// the hits/misses counters, and does not remove the entry. Returns `(Some(v), expired)` for + /// a present entry (expired or not) or `(None, false)` when absent. + fn cache_peek_with_expiry_status(&self, k: &K) -> (Option, bool) { + let shard = self.shard_of(k); + let guard = shard.lock.read(); + match guard.cache_peek(k) { + None => (None, false), + Some(v) => { + let expired = v.is_expired(); + (Some(v.clone()), expired) + } + } + } } #[cfg(test)] mod tests { use super::*; + use crate::ConcurrentCached; use crate::ConcurrentCached as SyncConcurrentCached; use crate::ConcurrentCloneCached; @@ -799,6 +934,85 @@ mod tests { } } + #[test] + fn new_returns_ready_cache_respecting_max_size() { + // shards(1) gives an exact eviction bound. + let c = ShardedExpiringLruCache::::builder() + .shards(1) + .max_size(2) + .build() + .unwrap(); + SyncConcurrentCached::cache_set( + &c, + 1, + Val { + v: 10, + expired: false, + }, + ) + .unwrap(); + assert_eq!( + SyncConcurrentCached::cache_get(&c, &1) + .unwrap() + .map(|v| v.v), + Some(10) + ); + SyncConcurrentCached::cache_set( + &c, + 2, + Val { + v: 20, + expired: false, + }, + ) + .unwrap(); + SyncConcurrentCached::cache_set( + &c, + 3, + Val { + v: 30, + expired: false, + }, + ) + .unwrap(); // evicts LRU (1) + assert_eq!(c.len(), 2); + assert!(SyncConcurrentCached::cache_get(&c, &1).unwrap().is_none()); + + // Inherent `new` returns a ready cache too. + let c2 = ShardedExpiringLruCache::::new(64); + SyncConcurrentCached::cache_set( + &c2, + 1, + Val { + v: 1, + expired: false, + }, + ) + .unwrap(); + assert_eq!( + SyncConcurrentCached::cache_get(&c2, &1) + .unwrap() + .map(|v| v.v), + Some(1) + ); + + // `new(N)` must forward N to the builder — capacity must equal the builder path. + assert_eq!( + ShardedExpiringLruCache::::new(1024).capacity(), + ShardedExpiringLruCache::::builder() + .max_size(1024) + .build() + .unwrap() + .capacity() + ); + } + + #[test] + #[should_panic(expected = "non-zero max_size")] + fn new_zero_max_size_panics() { + let _c = ShardedExpiringLruCache::::new(0); + } + #[test] fn copy_from_skips_expired() { let old = ShardedExpiringLruCache::::builder() @@ -1300,4 +1514,332 @@ mod tests { "entry must still be expired on second expiry-status call" ); } + + #[test] + fn peek_with_expiry_status_no_side_effects() { + // shards(1) makes counter captures exact. + let c = ShardedExpiringLruCacheBase::::builder() + .max_size(64) + .shards(1) + .build() + .unwrap(); + + SyncConcurrentCached::cache_set( + &c, + 1u32, + Val { + v: 42, + expired: false, + }, + ) + .expect("insert must succeed"); + + // Capture counters before any peek. + let before = c.metrics(); + + // Live key: expect (Some(v), false). + let (val, expired) = ConcurrentCloneCached::cache_peek_with_expiry_status(&c, &1u32); + assert_eq!( + val.map(|x| x.v), + Some(42), + "live peek must return the value" + ); + assert!(!expired, "live peek must report expired=false"); + + // Absent key: expect (None, false). + let (val2, expired2) = ConcurrentCloneCached::cache_peek_with_expiry_status(&c, &999u32); + assert!(val2.is_none(), "absent peek must return None"); + assert!(!expired2, "absent peek must report expired=false"); + + // Counters must be unchanged. + let after = c.metrics(); + assert_eq!(after.hits, before.hits, "peek must not increment hits"); + assert_eq!( + after.misses, before.misses, + "peek must not increment misses" + ); + assert_eq!( + after.evictions, before.evictions, + "peek must not increment evictions" + ); + + // Entry must still be present. + assert!( + SyncConcurrentCached::cache_get(&c, &1u32) + .expect("cache_get must succeed") + .is_some(), + "entry must still be present after peek" + ); + } + + #[test] + fn peek_with_expiry_status_does_not_promote_lru() { + // max_size(2) + shards(1): a single shard with 2 slots. If peek promoted + // recency, inserting a third entry would evict key 2 (MRU before peek); + // if it does not promote, key 1 remains LRU and is evicted instead. + let c = ShardedExpiringLruCacheBase::::builder() + .max_size(2) + .shards(1) + .build() + .unwrap(); + + // Insert order: key 1, then key 2. LRU is key 1. + SyncConcurrentCached::cache_set( + &c, + 1u32, + Val { + v: 10, + expired: false, + }, + ) + .expect("insert must succeed"); + SyncConcurrentCached::cache_set( + &c, + 2u32, + Val { + v: 20, + expired: false, + }, + ) + .expect("insert must succeed"); + + // Peek key 1 — must NOT promote it to MRU. + let (val, expired) = ConcurrentCloneCached::cache_peek_with_expiry_status(&c, &1u32); + assert_eq!(val.map(|x| x.v), Some(10), "peek must return the value"); + assert!(!expired, "peek must report expired=false"); + + // Counters unchanged: no hits, no misses. + let m = c.metrics(); + assert_eq!(m.hits, Some(0), "peek must not increment hits"); + assert_eq!(m.misses, Some(0), "peek must not increment misses"); + + // Inserting key 3 must evict key 1 (still LRU), not key 2. + SyncConcurrentCached::cache_set( + &c, + 3u32, + Val { + v: 30, + expired: false, + }, + ) + .expect("insert must succeed"); + + assert!( + SyncConcurrentCached::cache_get(&c, &1u32) + .expect("cache_get must succeed") + .is_none(), + "key 1 must be evicted as LRU (peek must not have promoted it)" + ); + assert!( + SyncConcurrentCached::cache_get(&c, &2u32) + .expect("cache_get must succeed") + .is_some(), + "key 2 must survive" + ); + assert!( + SyncConcurrentCached::cache_get(&c, &3u32) + .expect("cache_get must succeed") + .is_some(), + "key 3 must survive" + ); + } + + #[test] + fn peek_with_expiry_status_stale_entry_no_side_effects() { + let c = ShardedExpiringLruCacheBase::::builder() + .max_size(64) + .shards(1) + .build() + .unwrap(); + + SyncConcurrentCached::cache_set( + &c, + 1u32, + Val { + v: 77, + expired: true, + }, + ) + .expect("insert must succeed"); + + let before = c.metrics(); + + let (val, expired) = ConcurrentCloneCached::cache_peek_with_expiry_status(&c, &1u32); + assert_eq!( + val.map(|x| x.v), + Some(77), + "expired peek must return the stale value" + ); + assert!(expired, "expired peek must report expired=true"); + + // Counters must be unchanged. + let after = c.metrics(); + assert_eq!( + after.hits, before.hits, + "expired peek must not increment hits" + ); + assert_eq!( + after.misses, before.misses, + "expired peek must not increment misses" + ); + assert_eq!( + after.evictions, before.evictions, + "expired peek must not increment evictions" + ); + + // Entry must NOT have been removed by the peek. + let (val2, expired2) = ConcurrentCloneCached::cache_peek_with_expiry_status(&c, &1u32); + assert_eq!( + val2.map(|x| x.v), + Some(77), + "entry must still be present after expired peek" + ); + assert!(expired2, "entry must still be expired after peek"); + } + + // --- Inherent infallible method tests --- + + #[test] + fn inherent_get_returns_option_not_result() { + let c = ShardedExpiringLruCache::::builder() + .max_size(64) + .build() + .unwrap(); + let v: Option = c.get(&1); + assert!(v.is_none()); + c.set( + 1, + Val { + v: 42, + expired: false, + }, + ); + let v: Option = c.get(&1); + assert_eq!(v.map(|x| x.v), Some(42)); + } + + #[test] + fn inherent_get_returns_none_for_expired() { + let c = ShardedExpiringLruCache::::builder() + .max_size(64) + .build() + .unwrap(); + c.set( + 1, + Val { + v: 99, + expired: true, + }, + ); + let v: Option = c.get(&1); + assert!( + v.is_none(), + "expired entry must return None from inherent get" + ); + } + + #[test] + fn inherent_set_returns_previous_value() { + let c = ShardedExpiringLruCache::::builder() + .max_size(64) + .build() + .unwrap(); + let prev: Option = c.set( + 1, + Val { + v: 10, + expired: false, + }, + ); + assert!(prev.is_none()); + let prev: Option = c.set( + 1, + Val { + v: 20, + expired: false, + }, + ); + assert_eq!(prev.map(|x| x.v), Some(10)); + assert_eq!(c.get(&1).map(|x| x.v), Some(20)); + } + + #[test] + fn inherent_remove_returns_prior_live_value() { + let c = ShardedExpiringLruCache::::builder() + .max_size(64) + .build() + .unwrap(); + c.set( + 1, + Val { + v: 99, + expired: false, + }, + ); + let v: Option = c.remove(&1); + assert_eq!(v.map(|x| x.v), Some(99)); + assert!(c.remove(&1).is_none()); + } + + #[test] + fn inherent_remove_entry_returns_key_and_value() { + let c = ShardedExpiringLruCache::::builder() + .shards(1) + .max_size(64) + .build() + .unwrap(); + c.set( + 7, + Val { + v: 77, + expired: false, + }, + ); + let pair: Option<(u32, Val)> = c.remove_entry(&7); + assert_eq!(pair.map(|(k, v)| (k, v.v)), Some((7, 77))); + assert!(c.remove_entry(&7).is_none()); + } + + #[test] + fn inherent_delete_returns_bool() { + let c = ShardedExpiringLruCache::::builder() + .max_size(64) + .build() + .unwrap(); + c.set( + 1, + Val { + v: 10, + expired: false, + }, + ); + let removed: bool = c.delete(&1); + assert!(removed); + let removed: bool = c.delete(&1); + assert!(!removed); + } + + #[test] + fn inherent_and_trait_methods_coexist_via_fully_qualified_path() { + fn use_trait(cache: &C, k: u32, v: Val) + where + C: SyncConcurrentCached, + { + let _: Result, _> = ConcurrentCached::cache_set(cache, k, v); + let _: Result, _> = ConcurrentCached::cache_get(cache, &k); + let _: Result, _> = ConcurrentCached::cache_remove(cache, &k); + } + let c = ShardedExpiringLruCache::::builder() + .max_size(64) + .build() + .unwrap(); + use_trait( + &c, + 1, + Val { + v: 42, + expired: false, + }, + ); + } } diff --git a/src/stores/sharded/lru.rs b/src/stores/sharded/lru.rs index 8f63ed0a..2e0198f7 100644 --- a/src/stores/sharded/lru.rs +++ b/src/stores/sharded/lru.rs @@ -4,7 +4,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; #[cfg(feature = "async_core")] use crate::ConcurrentCachedAsync; -use crate::{CacheMetrics, CachedIter, ConcurrentCached}; +use crate::{CacheMetrics, CachedIter, ConcurrentCacheBase, ConcurrentCached}; use super::{ CachePadded, DefaultShardHasher, Shard, ShardHasher, checked_shard_count, shard_index, @@ -29,15 +29,18 @@ struct LruInner { /// Use [`deep_clone`](ShardedLruCacheBase::deep_clone) to get an independent copy. /// /// This is a type alias for `ShardedLruCacheBase`. -/// To use a custom shard hasher, construct a [`ShardedLruCacheBase`] directly via -/// [`ShardedLruCacheBase::builder()`]. +/// To use a custom shard hasher, call [`ShardedLruCache::builder()`] and then +/// [`hasher`](ShardedLruCacheBuilder::hasher), which yields a `ShardedLruCacheBase` +/// over your hasher. /// /// **Note**: LRU promotion requires mutable access to the per-shard store, so -/// `cache_get` acquires a **write** lock (unlike `ShardedCache` which only needs a read lock). -/// Under many concurrent readers this can be a bottleneck; consider `ShardedCache` if you do -/// not need capacity bounding. +/// `cache_get` acquires a **write** lock (unlike `ShardedUnboundCache` which only needs a read lock). +/// Under many concurrent readers this can be a bottleneck; consider `ShardedUnboundCache` if you do +/// not need capacity bounding. This write-lock-on-read behavior is a known limitation of the +/// strict-LRU sharded stores. A future read-optimized variant that relaxes strict recency ordering +/// will ship as a separate store type; the existing stores will not change semantics. /// -/// **Note**: `K` must implement `Clone` (needed for LRU key tracking). `ShardedCache` +/// **Note**: `K` must implement `Clone` (needed for LRU key tracking). `ShardedUnboundCache` /// requires only `K: Hash + Eq`. `V` must also implement `Clone`, because reads return owned /// values cloned from under the shard lock. /// @@ -69,19 +72,49 @@ impl std::fmt::Debug for ShardedLruCacheBase { } } -impl ShardedLruCacheBase +impl ShardedLruCacheBase where K: Hash + Eq + Clone, - H: ShardHasher, { - /// Return a builder for constructing a [`ShardedLruCacheBase`]. + /// Construct a ready-to-use [`ShardedLruCache`] holding up to roughly `max_size` + /// entries total, with the [`DefaultShardHasher`] and a default shard count. + /// + /// Note that the effective total capacity can exceed `max_size` for small values + /// because each shard reserves a minimum capacity (see + /// [`max_size`](ShardedLruCacheBuilder::max_size)). For a custom hasher, shard count, + /// per-shard cap, or `on_evict`, use [`builder`](Self::builder). + /// + /// # Panics + /// + /// Panics if `max_size` is `0`, or if the effective sharded capacity overflows + /// `usize` / a per-shard allocation fails. Use [`builder`](Self::builder) with + /// [`build`](ShardedLruCacheBuilder::build) to handle those cases without panicking. + #[must_use] + pub fn new(max_size: usize) -> ShardedLruCache { + Self::builder() + .max_size(max_size) + .build() + .expect("ShardedLruCache::new requires a non-zero max_size with a valid allocation") + } + + /// Return a builder for constructing a [`ShardedLruCache`]. /// - /// Always returns a builder with the [`DefaultShardHasher`], regardless of the `H` type - /// parameter on `Self`. Call `.hasher(h)` on the builder to use a custom hasher. + /// The builder starts with the [`DefaultShardHasher`]. To use a custom hasher, call + /// [`hasher`](ShardedLruCacheBuilder::hasher) on the returned builder; it switches the + /// builder's hasher type and `build` then yields a `ShardedLruCacheBase` over that hasher. + /// `new` and `builder` exist only on the default-hasher alias, so a custom hasher is always + /// introduced via `hasher`, never a `ShardedLruCacheBase::<_, _, H>` turbofish. + #[must_use] pub fn builder() -> ShardedLruCacheBuilder { ShardedLruCacheBuilder::default() } +} +impl ShardedLruCacheBase +where + K: Hash + Eq + Clone, + H: ShardHasher, +{ #[inline] fn shard_of(&self, k: &K) -> &CachePadded>> { let h = self.inner.hasher.shard_hash(k); @@ -89,7 +122,7 @@ where } } -impl + Clone> ShardedLruCacheBase { +impl> ShardedLruCacheBase { /// Return an independent deep copy of this cache — entries and metrics are /// duplicated, not shared. In most cases [`Clone::clone`] (Arc-share) is /// what you want. @@ -124,6 +157,58 @@ impl + Clone> ShardedLruCacheB } } +impl> ShardedLruCacheBase +where + K: Hash + Eq + Clone, + V: Clone, +{ + /// Retrieve a cached value, returning `None` on a miss. + /// + /// This is the infallible ergonomic API for the concrete type. Generic code over + /// [`ConcurrentCached`] should use the `Result`-returning trait methods (`cache_get` or the + /// trait's `get` alias), callable as `ConcurrentCached::get(&store, k)` when this inherent + /// method is in scope. + #[must_use] + pub fn get(&self, k: &K) -> Option { + ConcurrentCached::cache_get(self, k).unwrap() + } + + /// Insert a key-value pair and return the previous value, if any. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn set(&self, k: K, v: V) -> Option { + ConcurrentCached::cache_set(self, k, v).unwrap() + } + + /// Remove a cached value and return it if the entry was live. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn remove(&self, k: &K) -> Option { + ConcurrentCached::cache_remove(self, k).unwrap() + } + + /// Remove a cached entry and return the stored key and value, if present. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn remove_entry(&self, k: &K) -> Option<(K, V)> { + ConcurrentCached::cache_remove_entry(self, k).unwrap() + } + + /// Delete a cached entry without returning the value. Returns `true` if an entry was removed. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn delete(&self, k: &K) -> bool { + ConcurrentCached::cache_delete(self, k).unwrap() + } + + /// Remove all entries from every shard and reset metrics. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn reset(&self) { + ConcurrentCached::cache_reset(self).unwrap() + } +} + impl> ShardedLruCacheBase where K: Hash + Eq + Clone, @@ -249,7 +334,7 @@ where use crate::Cached; -impl ConcurrentCached for ShardedLruCacheBase +impl ConcurrentCacheBase for ShardedLruCacheBase where K: Hash + Eq + Clone, V: Clone, @@ -257,6 +342,52 @@ where { type Error = std::convert::Infallible; + fn cache_size(&self) -> Result, Self::Error> { + Ok(Some(self.len())) + } + + fn cache_hits(&self) -> Option { + Some( + self.inner + .shards + .iter() + .map(|s| s.hits.load(Ordering::Relaxed)) + .sum(), + ) + } + + fn cache_misses(&self) -> Option { + Some( + self.inner + .shards + .iter() + .map(|s| s.misses.load(Ordering::Relaxed)) + .sum(), + ) + } + + fn cache_capacity(&self) -> Option { + Some(self.inner.total_capacity) + } + + fn cache_evictions(&self) -> Option { + let mut evictions = 0u64; + for shard in self.inner.shards.iter() { + let guard = shard.lock.read(); + if let Some(e) = guard.cache_evictions() { + evictions += e; + } + } + Some(evictions) + } +} + +impl ConcurrentCached for ShardedLruCacheBase +where + K: Hash + Eq + Clone, + V: Clone, + H: ShardHasher, +{ fn cache_get(&self, k: &K) -> Result, Self::Error> { let shard = self.shard_of(k); let mut guard = shard.lock.write(); @@ -300,10 +431,6 @@ where Ok(removed) } - fn cache_size(&self) -> Result, Self::Error> { - Ok(Some(self.len())) - } - fn cache_clear(&self) -> Result<(), Self::Error> { self.clear(); Ok(()) @@ -323,11 +450,6 @@ where } Ok(()) } - - /// No-op: this store has no TTL to refresh on hit. Always returns `false`. - fn set_refresh_on_hit(&self, _refresh: bool) -> bool { - false - } } #[cfg(feature = "async_core")] @@ -337,8 +459,6 @@ where V: Clone + Send + Sync, H: ShardHasher, { - type Error = std::convert::Infallible; - async fn async_cache_get(&self, k: &K) -> Result, Self::Error> { ConcurrentCached::cache_get(self, k) } @@ -366,14 +486,6 @@ where async fn async_cache_reset_metrics(&self) -> Result<(), Self::Error> { ConcurrentCached::cache_reset_metrics(self) } - - fn cache_size(&self) -> Result, Self::Error> { - Ok(Some(self.len())) - } - - fn set_refresh_on_hit(&self, b: bool) -> bool { - >::set_refresh_on_hit(self, b) - } } /// Builder for [`ShardedLruCacheBase`]. @@ -450,6 +562,7 @@ impl ShardedLruCacheBuilder { /// distribute keys across those high bits to avoid lopsided shards; a hasher that only /// varies the low 32 bits will pile every key into one shard. See [`ShardHasher`] for the /// distribution contract and a worked example. Defaults to [`DefaultShardHasher`]. + #[doc(alias = "with_hasher")] #[must_use] pub fn hasher>(self, hasher: H2) -> ShardedLruCacheBuilder { ShardedLruCacheBuilder { @@ -541,6 +654,7 @@ impl ShardedLruCacheBuilder { /// Returns [`BuildError`] if `max_size` (or `per_shard_max_size`) was not set, is `0`, /// or if both `max_size` and `per_shard_max_size` are set simultaneously, or if the /// effective sharded capacity overflows `usize`. + #[must_use = "the Result from build() must be used"] pub fn build(self) -> Result, BuildError> where K: Hash + Eq + Clone, @@ -617,8 +731,46 @@ impl ShardedLruCacheBuilder { #[cfg(test)] mod tests { use super::*; + use crate::ConcurrentCached; use crate::ConcurrentCached as SyncConcurrentCached; + #[test] + fn new_returns_ready_cache_respecting_max_size() { + // shards(1) gives an exact cap so the eviction bound is deterministic. + let c = ShardedLruCache::::builder() + .shards(1) + .max_size(2) + .build() + .unwrap(); + assert_eq!(SyncConcurrentCached::cache_set(&c, 1, 10).unwrap(), None); + assert_eq!(SyncConcurrentCached::cache_get(&c, &1).unwrap(), Some(10)); + SyncConcurrentCached::cache_set(&c, 2, 20).unwrap(); + SyncConcurrentCached::cache_set(&c, 3, 30).unwrap(); // evicts LRU (1) + assert_eq!(c.len(), 2); + assert_eq!(SyncConcurrentCached::cache_get(&c, &1).unwrap(), None); + + // The inherent `new` constructor returns a ready cache too. + let c2 = ShardedLruCache::::new(64); + assert_eq!(SyncConcurrentCached::cache_set(&c2, 1, 100).unwrap(), None); + assert_eq!(SyncConcurrentCached::cache_get(&c2, &1).unwrap(), Some(100)); + + // `new(N)` must forward N to the builder — capacity must equal the builder path. + assert_eq!( + ShardedLruCache::::new(1024).capacity(), + ShardedLruCache::::builder() + .max_size(1024) + .build() + .unwrap() + .capacity() + ); + } + + #[test] + #[should_panic(expected = "non-zero max_size")] + fn new_zero_max_size_panics() { + let _c = ShardedLruCache::::new(0); + } + #[test] fn basic_get_set_remove() { let c = ShardedLruCache::::builder() @@ -974,4 +1126,105 @@ mod tests { assert!(SyncConcurrentCached::cache_delete(&c, &1u32).expect("cache_delete must succeed")); assert!(!SyncConcurrentCached::cache_delete(&c, &1u32).expect("cache_delete must succeed")); } + + // --- Inherent infallible method tests --- + + #[test] + fn inherent_get_returns_option_not_result() { + let c = ShardedLruCache::::builder() + .max_size(64) + .build() + .unwrap(); + let v: Option = c.get(&1); + assert_eq!(v, None); + c.set(1, 42); + let v: Option = c.get(&1); + assert_eq!(v, Some(42)); + } + + #[test] + fn inherent_set_returns_previous_value() { + let c = ShardedLruCache::::builder() + .max_size(64) + .build() + .unwrap(); + let prev: Option = c.set(1, 10); + assert_eq!(prev, None); + let prev: Option = c.set(1, 20); + assert_eq!(prev, Some(10)); + assert_eq!(c.get(&1), Some(20)); + } + + #[test] + fn inherent_remove_returns_prior_value() { + let c = ShardedLruCache::::builder() + .max_size(64) + .build() + .unwrap(); + c.set(1, 99); + let v: Option = c.remove(&1); + assert_eq!(v, Some(99)); + assert_eq!(c.remove(&1), None); + assert_eq!(c.get(&1), None); + } + + #[test] + fn inherent_remove_entry_returns_key_and_value() { + let c = ShardedLruCache::::builder() + .shards(1) + .max_size(64) + .build() + .unwrap(); + c.set(7, 77); + let pair: Option<(u32, u32)> = c.remove_entry(&7); + assert_eq!(pair, Some((7, 77))); + assert_eq!(c.remove_entry(&7), None); + } + + #[test] + fn inherent_delete_returns_bool() { + let c = ShardedLruCache::::builder() + .max_size(64) + .build() + .unwrap(); + c.set(1, 10); + let removed: bool = c.delete(&1); + assert!(removed); + let removed: bool = c.delete(&1); + assert!(!removed); + } + + #[test] + fn inherent_reset_clears_and_resets_metrics() { + let c = ShardedLruCache::::builder() + .max_size(64) + .build() + .unwrap(); + c.set(1, 1); + c.set(2, 2); + let _ = c.get(&1); + assert_eq!(c.len(), 2); + assert_eq!(c.metrics().hits, Some(1)); + c.reset(); + assert_eq!(c.len(), 0); + assert!(c.is_empty()); + assert_eq!(c.metrics().hits, Some(0)); + } + + #[test] + fn inherent_and_trait_methods_coexist_via_fully_qualified_path() { + fn use_trait(cache: &C, k: u32, v: u32) + where + C: SyncConcurrentCached, + { + let _: Result, _> = ConcurrentCached::cache_set(cache, k, v); + let _: Result, _> = ConcurrentCached::cache_get(cache, &k); + let _: Result, _> = ConcurrentCached::cache_remove(cache, &k); + } + let c = ShardedLruCache::::builder() + .max_size(64) + .build() + .unwrap(); + use_trait(&c, 1, 100); + } } diff --git a/src/stores/sharded/lru_ttl.rs b/src/stores/sharded/lru_ttl.rs index d12c5d60..9f908dab 100644 --- a/src/stores/sharded/lru_ttl.rs +++ b/src/stores/sharded/lru_ttl.rs @@ -3,10 +3,31 @@ use std::marker::PhantomData; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +/// Encode a TTL into the `ttl_nanos` atomic. A zero duration encodes as `0` +/// (expiry disabled / no expiry). +#[inline] +fn encode_ttl(ttl: Duration) -> u64 { + ttl.as_nanos().min(u64::MAX as u128) as u64 +} + +/// Decode the `ttl_nanos` atomic into an optional TTL. `0` means expiry is +/// disabled (entries never expire), so it decodes to `None`. +#[inline] +fn decode_ttl(nanos: u64) -> Option { + if nanos == 0 { + None + } else { + Some(Duration::from_nanos(nanos)) + } +} + #[cfg(feature = "async_core")] use crate::ConcurrentCachedAsync; use crate::time::{Duration, Instant}; -use crate::{CacheMetrics, ConcurrentCacheEvict, ConcurrentCached, ConcurrentCloneCached}; +use crate::{ + CacheMetrics, ConcurrentCacheBase, ConcurrentCacheEvict, ConcurrentCacheTtl, ConcurrentCached, + ConcurrentCloneCached, +}; use super::{ CachePadded, DefaultShardHasher, Shard, ShardHasher, checked_shard_count, shard_index, @@ -22,7 +43,9 @@ struct LruTtlInner { shard_mask: usize, hasher: H, on_evict: Option>, - /// TTL in nanoseconds. `0` means TTL is currently disabled (set via `unset_ttl()`); cannot be `0` at build time. + /// TTL in nanoseconds, or `0` to mean expiry is disabled (entries never expire). + /// A zero stored value is the single sentinel for "no expiry"; there is no separate + /// `ttl_set` flag. `unset_ttl`/`set_ttl(0)` store `0`; `set_ttl(nonzero)` stores the ttl. ttl_nanos: AtomicU64, refresh: AtomicBool, /// Evictions not driven by LRU capacity pressure: TTL expiry (via [`evict`](ShardedLruTtlCacheBase::evict)), @@ -43,8 +66,9 @@ struct LruTtlInner { /// return owned values cloned from under the shard lock). /// /// This is a type alias for `ShardedLruTtlCacheBase`. -/// To use a custom shard hasher, construct a [`ShardedLruTtlCacheBase`] directly via -/// [`ShardedLruTtlCacheBase::builder()`]. +/// To use a custom shard hasher, call [`ShardedLruTtlCache::builder()`] and then +/// [`hasher`](ShardedLruTtlCacheBuilder::hasher), which yields a +/// `ShardedLruTtlCacheBase` over your hasher. /// /// **Note**: LRU promotion requires mutable access to the per-shard store, so /// `cache_get` acquires a **write** lock (unlike `ShardedTtlCache` which only needs a read lock @@ -57,6 +81,11 @@ struct LruTtlInner { /// **Note**: Setting an `on_evict` callback transitions the builder to requiring `'static` bounds /// on `K` and `V` due to internal closure wrapping. If you have non-`'static` keys or values, /// do not configure an `on_evict` callback. +/// +/// **`len` / `evict` contract**: `len()` (the inherent method) returns the raw stored entry +/// count across all shards and may include expired-but-not-yet-swept entries. Call `evict()` +/// (via [`ConcurrentCacheEvict`](crate::ConcurrentCacheEvict)) to physically remove expired +/// entries and obtain an accurate live count. Sharded stores do not implement `CachedIter`. pub type ShardedLruTtlCache = ShardedLruTtlCacheBase; /// Backing type for [`ShardedLruTtlCache`] with a generic shard hasher `H`. @@ -73,14 +102,20 @@ impl Clone for ShardedLruTtlCacheBase { } } +impl ShardedLruTtlCacheBase { + /// Resolve the currently configured TTL, independent of hasher bounds. + /// + /// Returns `None` when expiry is disabled (entries never expire), otherwise + /// `Some(ttl)`. + #[inline] + fn ttl_duration_impl(&self) -> Option { + decode_ttl(self.inner.ttl_nanos.load(Ordering::Relaxed)) + } +} + impl std::fmt::Debug for ShardedLruTtlCacheBase { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let nanos = self.inner.ttl_nanos.load(Ordering::Relaxed); - let ttl = if nanos == 0 { - None - } else { - Some(Duration::from_nanos(nanos)) - }; + let ttl = self.ttl_duration_impl(); f.debug_struct("ShardedLruTtlCache") .field("shards", &self.inner.shards.len()) .field("capacity", &self.inner.total_capacity) @@ -89,19 +124,51 @@ impl std::fmt::Debug for ShardedLruTtlCacheBase { } } -impl ShardedLruTtlCacheBase +impl ShardedLruTtlCacheBase where K: Hash + Eq + Clone, - H: ShardHasher, { - /// Return a builder for constructing a [`ShardedLruTtlCacheBase`]. + /// Construct a ready-to-use [`ShardedLruTtlCache`] holding up to roughly `max_size` + /// entries total with the given `ttl`, the [`DefaultShardHasher`], and a default shard + /// count. + /// + /// Note that the effective total capacity can exceed `max_size` for small values + /// because each shard reserves a minimum capacity (see + /// [`max_size`](ShardedLruTtlCacheBuilder::max_size)). For a custom hasher, shard count, + /// per-shard cap, `refresh_on_hit`, or `on_evict`, use [`builder`](Self::builder). /// - /// Always returns a builder with the [`DefaultShardHasher`], regardless of the `H` type - /// parameter on `Self`. Call `.hasher(h)` on the builder to use a custom hasher. + /// # Panics + /// + /// Panics if `max_size` is `0`, if `ttl` is zero, or if the effective sharded capacity + /// overflows `usize` / a per-shard allocation fails. Use [`builder`](Self::builder) with + /// [`build`](ShardedLruTtlCacheBuilder::build) to handle those cases without panicking. + #[must_use] + pub fn new(max_size: usize, ttl: Duration) -> ShardedLruTtlCache { + Self::builder() + .max_size(max_size) + .ttl(ttl) + .build() + .expect("ShardedLruTtlCache::new requires a non-zero max_size and non-zero ttl") + } + + /// Return a builder for constructing a [`ShardedLruTtlCache`]. + /// + /// The builder starts with the [`DefaultShardHasher`]. To use a custom hasher, call + /// [`hasher`](ShardedLruTtlCacheBuilder::hasher) on the returned builder; it switches the + /// builder's hasher type and `build` then yields a `ShardedLruTtlCacheBase` over that + /// hasher. `new` and `builder` exist only on the default-hasher alias, so a custom hasher + /// is always introduced via `hasher`, never a `ShardedLruTtlCacheBase::<_, _, H>` turbofish. + #[must_use] pub fn builder() -> ShardedLruTtlCacheBuilder { ShardedLruTtlCacheBuilder::default() } +} +impl ShardedLruTtlCacheBase +where + K: Hash + Eq + Clone, + H: ShardHasher, +{ #[inline] fn shard_of(&self, k: &K) -> &CachePadded>>> { let h = self.inner.hasher.shard_hash(k); @@ -110,16 +177,24 @@ where #[inline] fn ttl_duration(&self) -> Option { + self.ttl_duration_impl() + } + + /// Compute the expiry instant for a new or refreshed entry given the current TTL. + /// TTL is clamped to u64::MAX nanos (~584 years), so `checked_add` overflow is + /// practically unreachable; if it does overflow, the entry becomes never-expires (`None`). + fn compute_expires_at(&self, now: Instant) -> Option { let nanos = self.inner.ttl_nanos.load(Ordering::Relaxed); if nanos == 0 { None } else { - Some(Duration::from_nanos(nanos)) + let ttl = Duration::from_nanos(nanos); + now.checked_add(ttl) } } } -impl + Clone> ShardedLruTtlCacheBase { +impl> ShardedLruTtlCacheBase { /// Return an independent deep copy of this cache — entries and metrics are /// duplicated, not shared. In most cases [`Clone::clone`] (Arc-share) is /// what you want. @@ -159,6 +234,58 @@ impl + Clone> ShardedLruTtlCac } } +impl> ShardedLruTtlCacheBase +where + K: Hash + Eq + Clone, + V: Clone, +{ + /// Retrieve a cached value, returning `None` on a miss or if the entry has expired. + /// + /// This is the infallible ergonomic API for the concrete type. Generic code over + /// [`ConcurrentCached`] should use the `Result`-returning trait methods (`cache_get` or the + /// trait's `get` alias), callable as `ConcurrentCached::get(&store, k)` when this inherent + /// method is in scope. + #[must_use] + pub fn get(&self, k: &K) -> Option { + ConcurrentCached::cache_get(self, k).unwrap() + } + + /// Insert a key-value pair and return the previous value, if any. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn set(&self, k: K, v: V) -> Option { + ConcurrentCached::cache_set(self, k, v).unwrap() + } + + /// Remove a cached value and return it if the entry was live. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn remove(&self, k: &K) -> Option { + ConcurrentCached::cache_remove(self, k).unwrap() + } + + /// Remove a cached entry and return the stored key and value, if present. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn remove_entry(&self, k: &K) -> Option<(K, V)> { + ConcurrentCached::cache_remove_entry(self, k).unwrap() + } + + /// Delete a cached entry without returning the value. Returns `true` if an entry was removed. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn delete(&self, k: &K) -> bool { + ConcurrentCached::cache_delete(self, k).unwrap() + } + + /// Remove all entries from every shard and reset metrics. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn reset(&self) { + ConcurrentCached::cache_reset(self).unwrap() + } +} + impl> ShardedLruTtlCacheBase where K: Hash + Eq + Clone, @@ -288,10 +415,6 @@ where /// (if set) for each, and return the total count of removed entries. #[must_use] pub fn evict(&self) -> usize { - let ttl = match self.ttl_duration() { - None => return 0, - Some(t) => t, - }; let mut total = 0; let now = Instant::now(); for shard in self.inner.shards.iter() { @@ -299,7 +422,9 @@ where let mut guard = shard.lock.write(); let expired: Vec = guard .iter() - .filter(|(_, e)| now.saturating_duration_since(e.instant) >= ttl) + // An entry is expired when expires_at is Some(t) and now >= t. + // None means never-expires. + .filter(|(_, e)| e.expires_at.is_some_and(|t| now >= t)) .map(|(k, _)| k.clone()) .collect(); let mut removed = Vec::new(); @@ -341,33 +466,20 @@ where /// TTL values longer than approximately 584 years are silently clamped to `u64::MAX` /// nanoseconds (~584 years). In practice this limit is never reached. /// - /// # Panics - /// - /// Panics if `ttl` is zero — use [`unset_ttl`](Self::unset_ttl) to disable expiry. + /// A zero `ttl` disables expiry — it is exactly equivalent to + /// [`unset_ttl`](Self::unset_ttl), and subsequently inserted entries never expire. pub fn set_ttl(&self, ttl: Duration) -> Option { - assert!( - !ttl.is_zero(), - "TTL must be non-zero; use unset_ttl() to disable expiry" - ); - let prev = self.inner.ttl_nanos.swap( - ttl.as_nanos().min(u64::MAX as u128) as u64, - Ordering::Relaxed, - ); - if prev == 0 { - None - } else { - Some(Duration::from_nanos(prev)) - } + let prev = self + .inner + .ttl_nanos + .swap(encode_ttl(ttl), Ordering::Relaxed); + decode_ttl(prev) } /// Remove the TTL (entries never expire after this point). pub fn unset_ttl(&self) -> Option { let prev = self.inner.ttl_nanos.swap(0, Ordering::Relaxed); - if prev == 0 { - None - } else { - Some(Duration::from_nanos(prev)) - } + decode_ttl(prev) } /// Set whether cache hits refresh the TTL of the accessed entry, @@ -393,7 +505,7 @@ where } } -impl ConcurrentCached for ShardedLruTtlCacheBase +impl ConcurrentCacheBase for ShardedLruTtlCacheBase where K: Hash + Eq + Clone, V: Clone, @@ -401,9 +513,81 @@ where { type Error = std::convert::Infallible; + fn cache_size(&self) -> Result, Self::Error> { + Ok(Some(self.len())) + } + + fn cache_hits(&self) -> Option { + Some( + self.inner + .shards + .iter() + .map(|s| s.hits.load(Ordering::Relaxed)) + .sum(), + ) + } + + fn cache_misses(&self) -> Option { + Some( + self.inner + .shards + .iter() + .map(|s| s.misses.load(Ordering::Relaxed)) + .sum(), + ) + } + + fn cache_capacity(&self) -> Option { + Some(self.inner.total_capacity) + } + + fn cache_evictions(&self) -> Option { + let mut lru_evictions = 0u64; + for shard in self.inner.shards.iter() { + let guard = shard.lock.read(); + if let Some(e) = Cached::cache_evictions(&*guard) { + lru_evictions += e; + } + } + Some(lru_evictions + self.inner.non_capacity_evictions.load(Ordering::Relaxed)) + } +} + +impl ConcurrentCacheTtl for ShardedLruTtlCacheBase +where + K: Hash + Eq + Clone, + V: Clone, + H: ShardHasher, +{ + fn ttl(&self) -> Option { + self.ttl_duration() + } + + fn set_ttl(&self, ttl: Duration) -> Option { + ShardedLruTtlCacheBase::set_ttl(self, ttl) + } + + fn unset_ttl(&self) -> Option { + ShardedLruTtlCacheBase::unset_ttl(self) + } + + fn refresh_on_hit(&self) -> bool { + self.inner.refresh.load(Ordering::Relaxed) + } + + fn set_refresh_on_hit(&self, refresh: bool) -> bool { + self.inner.refresh.swap(refresh, Ordering::Relaxed) + } +} + +impl ConcurrentCached for ShardedLruTtlCacheBase +where + K: Hash + Eq + Clone, + V: Clone, + H: ShardHasher, +{ fn cache_get(&self, k: &K) -> Result, Self::Error> { let shard = self.shard_of(k); - let ttl = self.ttl_duration(); let refresh = self.inner.refresh.load(Ordering::Relaxed); let mut guard = shard.lock.write(); @@ -415,10 +599,8 @@ where shard.misses.fetch_add(1, Ordering::Relaxed); return Ok(None); } - Some(entry) => match &ttl { - None => false, - Some(t) => entry.instant.elapsed() >= *t, - }, + // expired = None (never-expires) -> false; Some(t) -> expired if now >= t + Some(entry) => entry.expires_at.is_some_and(|t| Instant::now() >= t), }; if expired { @@ -443,7 +625,8 @@ where // LRU promotion and double-incrementing LruCache's internal hit counter. let value = if refresh { guard.cache_get_mut(k).map(|e| { - e.instant = Instant::now(); + let now = Instant::now(); + e.expires_at = self.compute_expires_at(now).or(e.expires_at); e.value.clone() }) } else { @@ -455,8 +638,10 @@ where fn cache_set(&self, k: K, v: V) -> Result, Self::Error> { let shard = self.shard_of(&k); + let now = Instant::now(); + let expires_at = self.compute_expires_at(now); let new_entry = TimedEntry { - instant: Instant::now(), + expires_at, value: v, }; let old = shard.lock.write().cache_set(k, new_entry); @@ -473,11 +658,7 @@ where if let Some(on_evict) = &self.inner.on_evict { on_evict(&key, &entry.value); } - let expired = match self.ttl_duration() { - None => false, - Some(ttl) => entry.instant.elapsed() >= ttl, - }; - if expired { + if entry.expires_at.is_some_and(|t| Instant::now() >= t) { Ok(None) } else { Ok(Some(entry.value)) @@ -501,10 +682,6 @@ where Ok(removed.map(|(k, entry)| (k, entry.value))) } - fn cache_size(&self) -> Result, Self::Error> { - Ok(Some(self.len())) - } - fn cache_clear(&self) -> Result<(), Self::Error> { self.clear(); Ok(()) @@ -527,22 +704,6 @@ where .store(0, Ordering::Relaxed); Ok(()) } - - fn set_refresh_on_hit(&self, refresh: bool) -> bool { - self.inner.refresh.swap(refresh, Ordering::Relaxed) - } - - fn ttl(&self) -> Option { - self.ttl_duration() - } - - fn set_ttl(&self, ttl: Duration) -> Option { - ShardedLruTtlCacheBase::set_ttl(self, ttl) - } - - fn unset_ttl(&self) -> Option { - ShardedLruTtlCacheBase::unset_ttl(self) - } } #[cfg(feature = "async_core")] @@ -552,8 +713,6 @@ where V: Clone + Send + Sync, H: ShardHasher, { - type Error = std::convert::Infallible; - async fn async_cache_get(&self, k: &K) -> Result, Self::Error> { ConcurrentCached::cache_get(self, k) } @@ -581,26 +740,6 @@ where async fn async_cache_reset_metrics(&self) -> Result<(), Self::Error> { ConcurrentCached::cache_reset_metrics(self) } - - fn cache_size(&self) -> Result, Self::Error> { - Ok(Some(self.len())) - } - - fn set_refresh_on_hit(&self, b: bool) -> bool { - >::set_refresh_on_hit(self, b) - } - - fn ttl(&self) -> Option { - self.ttl_duration() - } - - fn set_ttl(&self, ttl: Duration) -> Option { - ShardedLruTtlCacheBase::set_ttl(self, ttl) - } - - fn unset_ttl(&self) -> Option { - ShardedLruTtlCacheBase::unset_ttl(self) - } } /// Builder for [`ShardedLruTtlCacheBase`]. @@ -674,12 +813,32 @@ impl ShardedLruTtlCacheBuilder { } /// Set the TTL for cache entries. Required. + /// + /// Overrides any previously set ttl/ttl_secs/ttl_millis on this builder. #[must_use] pub fn ttl(mut self, ttl: Duration) -> Self { self.ttl = Some(ttl); self } + /// Set the TTL for cache entries in whole seconds. Equivalent to + /// `ttl(Duration::from_secs(secs))`. + /// + /// Overrides any previously set ttl/ttl_secs/ttl_millis on this builder. + #[must_use] + pub fn ttl_secs(self, secs: u64) -> Self { + self.ttl(Duration::from_secs(secs)) + } + + /// Set the TTL for cache entries in milliseconds. Equivalent to + /// `ttl(Duration::from_millis(millis))`. + /// + /// Overrides any previously set ttl/ttl_secs/ttl_millis on this builder. + #[must_use] + pub fn ttl_millis(self, millis: u64) -> Self { + self.ttl(Duration::from_millis(millis)) + } + /// Set the number of shards (rounded up to the next power of two). #[must_use] pub fn shards(mut self, shards: usize) -> Self { @@ -702,6 +861,7 @@ impl ShardedLruTtlCacheBuilder { /// distribute keys across those high bits to avoid lopsided shards; a hasher that only /// varies the low 32 bits will pile every key into one shard. See [`ShardHasher`] for the /// distribution contract and a worked example. Defaults to [`DefaultShardHasher`]. + #[doc(alias = "with_hasher")] #[must_use] pub fn hasher>(self, hasher: H2) -> ShardedLruTtlCacheBuilder { ShardedLruTtlCacheBuilder { @@ -826,6 +986,7 @@ impl ShardedLruTtlCacheBuilder { /// or if both `max_size` and `per_shard_max_size` are set simultaneously. May also return /// [`BuildError::InvalidValue`] if the effective sharded capacity overflows `usize` or a /// per-shard allocation fails. + #[must_use = "the Result from build() must be used"] pub fn build(self) -> Result, BuildError> where K: Hash + Eq + Clone, @@ -852,7 +1013,7 @@ impl ShardedLruTtlCacheBuilder { .hasher .expect("hasher is always initialized via Default or .hasher()"), on_evict: None, - ttl_nanos: AtomicU64::new(ttl.as_nanos().min(u64::MAX as u128) as u64), + ttl_nanos: AtomicU64::new(encode_ttl(ttl)), refresh: AtomicBool::new(self.refresh), non_capacity_evictions: AtomicU64::new(0), total_capacity: total_cap, @@ -911,6 +1072,7 @@ impl ShardedLruTtlCacheBuilder { /// or if both `max_size` and `per_shard_max_size` are set simultaneously. May also return /// [`BuildError::InvalidValue`] if the effective sharded capacity overflows `usize` or a /// per-shard allocation fails. + #[must_use = "the Result from build() must be used"] pub fn build(self) -> Result, BuildError> where K: Hash + Eq + Clone + 'static, @@ -948,7 +1110,7 @@ impl ShardedLruTtlCacheBuilder { .hasher .expect("hasher is always initialized via Default or .hasher()"), on_evict: self.on_evict, - ttl_nanos: AtomicU64::new(ttl.as_nanos().min(u64::MAX as u128) as u64), + ttl_nanos: AtomicU64::new(encode_ttl(ttl)), refresh: AtomicBool::new(self.refresh), non_capacity_evictions: AtomicU64::new(0), total_capacity: total_cap, @@ -1004,17 +1166,15 @@ where H: ShardHasher, H2: ShardHasher, { - let existing_ttl = existing.ttl_duration(); - + let now = Instant::now(); for shard in existing.inner.shards.iter() { let entries: Vec<(K, TimedEntry)> = { let guard = shard.lock.read(); guard.iter_order() }; for (k, entry) in entries.into_iter().rev() { - if let Some(ttl) = existing_ttl - && entry.instant.elapsed() >= ttl - { + // Skip entries already expired per their per-entry expires_at. + if entry.expires_at.is_some_and(|t| now >= t) { continue; } let new_shard = new_cache.shard_of(&k); @@ -1035,7 +1195,6 @@ where /// `(None, false)` when absent (miss). fn cache_get_with_expiry_status(&self, k: &K) -> (Option, bool) { let shard = self.shard_of(k); - let ttl = self.ttl_duration(); let refresh = self.inner.refresh.load(Ordering::Relaxed); let mut guard = shard.lock.write(); // Common case (live hit) in a single lookup: `get_if`/`get_mut_if` promote LRU @@ -1044,14 +1203,15 @@ where // case then takes one extra peek to recover the stale value without removing it. let live = if refresh { guard - .get_mut_if(k, |e| ttl.is_none_or(|t| e.instant.elapsed() < t)) + .get_mut_if(k, |e| e.expires_at.is_none_or(|t| Instant::now() < t)) .map(|e| { - e.instant = Instant::now(); + let now = Instant::now(); + e.expires_at = self.compute_expires_at(now).or(e.expires_at); e.value.clone() }) } else { guard - .get_if(k, |e| ttl.is_none_or(|t| e.instant.elapsed() < t)) + .get_if(k, |e| e.expires_at.is_none_or(|t| Instant::now() < t)) .map(|e| e.value.clone()) }; if let Some(value) = live { @@ -1069,14 +1229,119 @@ where None => (None, false), } } + + /// Non-renewing read: takes only a read lock, does not promote LRU recency, does not update + /// the TTL timestamp, does not touch the hits/misses counters, and does not remove the entry. + /// Returns `(Some(v), expired)` for a present entry (expired or not) or `(None, false)` when + /// absent. + fn cache_peek_with_expiry_status(&self, k: &K) -> (Option, bool) { + let shard = self.shard_of(k); + let guard = shard.lock.read(); + match guard.cache_peek(k) { + None => (None, false), + Some(entry) => { + let expired = entry.expires_at.is_some_and(|t| Instant::now() >= t); + (Some(entry.value.clone()), expired) + } + } + } } #[cfg(test)] mod tests { use super::*; + use crate::ConcurrentCached; use crate::ConcurrentCached as SyncConcurrentCached; use crate::ConcurrentCloneCached; + #[test] + fn new_returns_ready_cache_respecting_max_size_and_ttl() { + // shards(1) gives an exact eviction bound. + let c = ShardedLruTtlCache::::builder() + .shards(1) + .max_size(2) + .ttl(Duration::from_millis(10)) + .build() + .unwrap(); + assert_eq!(c.ttl(), Some(Duration::from_millis(10))); + SyncConcurrentCached::cache_set(&c, 1, 10).unwrap(); + SyncConcurrentCached::cache_set(&c, 2, 20).unwrap(); + SyncConcurrentCached::cache_set(&c, 3, 30).unwrap(); // evicts LRU (1) + assert_eq!(c.len(), 2); + assert_eq!(SyncConcurrentCached::cache_get(&c, &1).unwrap(), None); + std::thread::sleep(std::time::Duration::from_millis(50)); + assert_eq!( + SyncConcurrentCached::cache_get(&c, &2).unwrap(), + None, + "entry must expire after ttl" + ); + + // Inherent `new` returns a ready cache too. + let c2 = ShardedLruTtlCache::::new(64, Duration::from_secs(60)); + assert_eq!(SyncConcurrentCached::cache_set(&c2, 1, 100).unwrap(), None); + assert_eq!(SyncConcurrentCached::cache_get(&c2, &1).unwrap(), Some(100)); + + // `new(N, ttl)` must forward N to the builder — capacity must equal the builder path. + let ttl = Duration::from_secs(60); + assert_eq!( + ShardedLruTtlCache::::new(1024, ttl).capacity(), + ShardedLruTtlCache::::builder() + .max_size(1024) + .ttl(ttl) + .build() + .unwrap() + .capacity() + ); + } + + #[test] + #[should_panic(expected = "non-zero max_size and non-zero ttl")] + fn new_zero_max_size_panics() { + let _c = ShardedLruTtlCache::::new(0, Duration::from_secs(1)); + } + + #[test] + #[should_panic(expected = "non-zero max_size and non-zero ttl")] + fn new_zero_ttl_panics() { + let _c = ShardedLruTtlCache::::new(2, Duration::ZERO); + } + + #[test] + fn ttl_secs_and_ttl_millis_set_duration() { + let c = ShardedLruTtlCache::::builder() + .max_size(64) + .ttl_secs(7) + .build() + .unwrap(); + assert_eq!(c.ttl(), Some(Duration::from_secs(7))); + + let c = ShardedLruTtlCache::::builder() + .max_size(64) + .ttl_millis(250) + .build() + .unwrap(); + assert_eq!(c.ttl(), Some(Duration::from_millis(250))); + } + + #[test] + fn ttl_setters_override_last_writer_wins() { + let c = ShardedLruTtlCache::::builder() + .max_size(64) + .ttl(Duration::from_secs(10)) + .ttl_secs(5) + .build() + .unwrap(); + assert_eq!(c.ttl(), Some(Duration::from_secs(5))); + + let c = ShardedLruTtlCache::::builder() + .max_size(64) + .ttl_secs(10) + .ttl_millis(500) + .build() + .unwrap(); + assert_eq!(c.ttl(), Some(Duration::from_millis(500))); + } + #[test] fn basic_get_set_remove() { let c = ShardedLruTtlCache::::builder() @@ -1363,7 +1628,10 @@ mod tests { .max_size(1) .ttl(Duration::from_nanos(0)) .build(); - assert!(matches!(err, Err(BuildError::InvalidTtl { .. }))); + assert!(matches!( + err, + Err(BuildError::InvalidValue { field: "ttl", .. }) + )); } #[test] @@ -1379,8 +1647,11 @@ mod tests { .ttl(Duration::from_nanos(0)) .build(); assert!( - matches!(err, Err(crate::stores::BuildError::InvalidTtl { .. })), - "expected InvalidTtl, got {err:?}", + matches!( + err, + Err(crate::stores::BuildError::InvalidValue { field: "ttl", .. }) + ), + "expected InvalidValue, got {err:?}", ); } @@ -1680,4 +1951,282 @@ mod tests { "key 2 must be evicted as the least-recently-used entry" ); } + + #[test] + fn peek_with_expiry_status_no_side_effects() { + // shards(1) makes counter captures exact (no cross-shard aggregation noise). + let c = ShardedLruTtlCacheBase::::builder() + .max_size(64) + .ttl(Duration::from_secs(60)) + .shards(1) + .build() + .unwrap(); + + SyncConcurrentCached::cache_set(&c, 1u32, 42u32).expect("insert must succeed"); + + // Capture counters before any peek. + let before = c.metrics(); + + // Live key: expect (Some(42), false). + let (val, expired) = ConcurrentCloneCached::cache_peek_with_expiry_status(&c, &1u32); + assert_eq!(val, Some(42), "live peek must return the value"); + assert!(!expired, "live peek must report expired=false"); + + // Absent key: expect (None, false). + let (val2, expired2) = ConcurrentCloneCached::cache_peek_with_expiry_status(&c, &999u32); + assert!(val2.is_none(), "absent peek must return None"); + assert!(!expired2, "absent peek must report expired=false"); + + // Counters must be unchanged. + let after = c.metrics(); + assert_eq!(after.hits, before.hits, "peek must not increment hits"); + assert_eq!( + after.misses, before.misses, + "peek must not increment misses" + ); + assert_eq!( + after.evictions, before.evictions, + "peek must not increment evictions" + ); + + // Entry must still be present. + assert_eq!( + SyncConcurrentCached::cache_get(&c, &1u32).expect("cache_get must succeed"), + Some(42), + "entry must still be present after peek" + ); + } + + #[test] + fn peek_with_expiry_status_does_not_promote_lru() { + // max_size(2) + shards(1): with only 2 slots, inserting a third entry + // evicts the LRU entry. If peek promoted recency, it would change which + // entry survives; if it does not promote, the pre-peek LRU order holds. + let c = ShardedLruTtlCacheBase::::builder() + .max_size(2) + .ttl(Duration::from_secs(60)) + .shards(1) + .build() + .unwrap(); + + // Insert order: key 1, then key 2. LRU is key 1 (oldest access). + SyncConcurrentCached::cache_set(&c, 1u32, 10u32).expect("insert must succeed"); + SyncConcurrentCached::cache_set(&c, 2u32, 20u32).expect("insert must succeed"); + + // Peek key 1 — must NOT promote it to MRU. + let (val, expired) = ConcurrentCloneCached::cache_peek_with_expiry_status(&c, &1u32); + assert_eq!(val, Some(10), "peek must return the value"); + assert!(!expired, "peek must report expired=false for a live entry"); + + // Counters unchanged: no hits, no misses. + let m = c.metrics(); + assert_eq!(m.hits, Some(0), "peek must not increment hits"); + assert_eq!(m.misses, Some(0), "peek must not increment misses"); + + // Inserting key 3 must evict key 1 (still LRU because peek did not + // promote it), not key 2. + SyncConcurrentCached::cache_set(&c, 3u32, 30u32).expect("insert must succeed"); + + // key 1 evicted (LRU), key 2 and key 3 survive. + assert!( + SyncConcurrentCached::cache_get(&c, &1u32) + .expect("cache_get must succeed") + .is_none(), + "key 1 must be evicted as LRU (peek must not have promoted it)" + ); + assert_eq!( + SyncConcurrentCached::cache_get(&c, &2u32).expect("cache_get must succeed"), + Some(20), + "key 2 must survive" + ); + assert_eq!( + SyncConcurrentCached::cache_get(&c, &3u32).expect("cache_get must succeed"), + Some(30), + "key 3 must survive" + ); + } + + #[test] + fn peek_with_expiry_status_stale_entry_no_side_effects() { + let c = ShardedLruTtlCacheBase::::builder() + .max_size(64) + .ttl(Duration::from_millis(50)) + .shards(1) + .build() + .unwrap(); + + SyncConcurrentCached::cache_set(&c, 1u32, 77u32).expect("insert must succeed"); + std::thread::sleep(std::time::Duration::from_millis(100)); + + let before = c.metrics(); + + let (val, expired) = ConcurrentCloneCached::cache_peek_with_expiry_status(&c, &1u32); + assert_eq!(val, Some(77), "expired peek must return the stale value"); + assert!(expired, "expired peek must report expired=true"); + + // Counters must be unchanged. + let after = c.metrics(); + assert_eq!( + after.hits, before.hits, + "expired peek must not increment hits" + ); + assert_eq!( + after.misses, before.misses, + "expired peek must not increment misses" + ); + assert_eq!( + after.evictions, before.evictions, + "expired peek must not increment evictions" + ); + + // Entry must NOT have been removed by the peek. + let (val2, expired2) = ConcurrentCloneCached::cache_peek_with_expiry_status(&c, &1u32); + assert_eq!( + val2, + Some(77), + "entry must still be present after expired peek" + ); + assert!(expired2, "entry must still be expired after peek"); + } + + #[test] + fn peek_with_expiry_status_does_not_renew_ttl_under_refresh_on_hit() { + // peek must not extend the TTL even when refresh_on_hit is enabled. + let c = ShardedLruTtlCacheBase::::builder() + .refresh_on_hit(true) + .max_size(64) + .ttl(Duration::from_millis(50)) + .shards(1) + .build() + .unwrap(); + + SyncConcurrentCached::cache_set(&c, 1u32, 42u32).expect("insert must succeed"); + + // Entry is live; peek must return the value and report not-expired. + let (val, expired) = ConcurrentCloneCached::cache_peek_with_expiry_status(&c, &1u32); + assert_eq!(val, Some(42), "live peek must return the value"); + assert!(!expired, "live peek must report expired=false"); + + // Wait past the original TTL. + std::thread::sleep(std::time::Duration::from_millis(100)); + + // If peek had renewed the TTL the entry would still be live; it must not have. + let (val2, expired2) = ConcurrentCloneCached::cache_peek_with_expiry_status(&c, &1u32); + assert_eq!( + val2, + Some(42), + "post-sleep peek must still return the value" + ); + assert!( + expired2, + "peek must not renew TTL; entry must now be expired" + ); + } + + // --- Inherent infallible method tests --- + + #[test] + fn inherent_get_returns_option_not_result() { + let c = ShardedLruTtlCache::::builder() + .max_size(64) + .ttl(Duration::from_secs(60)) + .build() + .unwrap(); + let v: Option = c.get(&1); + assert_eq!(v, None); + c.set(1, 42); + let v: Option = c.get(&1); + assert_eq!(v, Some(42)); + } + + #[test] + fn inherent_set_returns_previous_value() { + let c = ShardedLruTtlCache::::builder() + .max_size(64) + .ttl(Duration::from_secs(60)) + .build() + .unwrap(); + let prev: Option = c.set(1, 10); + assert_eq!(prev, None); + let prev: Option = c.set(1, 20); + assert_eq!(prev, Some(10)); + assert_eq!(c.get(&1), Some(20)); + } + + #[test] + fn inherent_remove_returns_prior_value() { + let c = ShardedLruTtlCache::::builder() + .max_size(64) + .ttl(Duration::from_secs(60)) + .build() + .unwrap(); + c.set(1, 99); + let v: Option = c.remove(&1); + assert_eq!(v, Some(99)); + assert_eq!(c.remove(&1), None); + assert_eq!(c.get(&1), None); + } + + #[test] + fn inherent_remove_entry_returns_key_and_value() { + let c = ShardedLruTtlCache::::builder() + .max_size(64) + .ttl(Duration::from_secs(60)) + .build() + .unwrap(); + c.set(7, 77); + let pair: Option<(u32, u32)> = c.remove_entry(&7); + assert_eq!(pair, Some((7, 77))); + assert_eq!(c.remove_entry(&7), None); + } + + #[test] + fn inherent_delete_returns_bool() { + let c = ShardedLruTtlCache::::builder() + .max_size(64) + .ttl(Duration::from_secs(60)) + .build() + .unwrap(); + c.set(1, 10); + let removed: bool = c.delete(&1); + assert!(removed); + let removed: bool = c.delete(&1); + assert!(!removed); + } + + #[test] + fn inherent_reset_clears_and_resets_metrics() { + let c = ShardedLruTtlCache::::builder() + .max_size(64) + .ttl(Duration::from_secs(60)) + .build() + .unwrap(); + c.set(1, 1); + c.set(2, 2); + let _ = c.get(&1); + assert_eq!(c.len(), 2); + assert_eq!(c.metrics().hits, Some(1)); + c.reset(); + assert_eq!(c.len(), 0); + assert!(c.is_empty()); + assert_eq!(c.metrics().hits, Some(0)); + } + + #[test] + fn inherent_and_trait_methods_coexist_via_fully_qualified_path() { + fn use_trait(cache: &C, k: u32, v: u32) + where + C: SyncConcurrentCached, + { + let _: Result, _> = ConcurrentCached::cache_set(cache, k, v); + let _: Result, _> = ConcurrentCached::cache_get(cache, &k); + let _: Result, _> = ConcurrentCached::cache_remove(cache, &k); + } + let c = ShardedLruTtlCache::::builder() + .max_size(64) + .ttl(Duration::from_secs(60)) + .build() + .unwrap(); + use_trait(&c, 1, 100); + } } diff --git a/src/stores/sharded/mod.rs b/src/stores/sharded/mod.rs index fc1beae3..f9380102 100644 --- a/src/stores/sharded/mod.rs +++ b/src/stores/sharded/mod.rs @@ -107,8 +107,9 @@ pub(crate) fn shard_index(hash: u64, mask: usize) -> usize { /// ```rust /// use cached::ShardHasher; /// -/// // BAD — `key as u64` for small integer keys leaves bits 32-63 all zero. +/// // BAD -- `key as u64` for small integer keys leaves bits 32-63 all zero. /// // All entries land on shard 0 regardless of the configured shard count. +/// #[derive(Clone)] /// struct IdentityHasher; /// impl ShardHasher for IdentityHasher { /// fn shard_hash(&self, key: &u32) -> u64 { @@ -126,6 +127,7 @@ pub(crate) fn shard_index(hash: u64, mask: usize) -> usize { /// /// /// Distributes `u64` keys using Fibonacci hashing (`key * 2^64/φ`). /// /// Ensures the upper 32 bits (used for shard selection) are well-distributed. +/// #[derive(Clone)] /// struct FibHasher; /// impl ShardHasher for FibHasher { /// fn shard_hash(&self, key: &u64) -> u64 { @@ -138,7 +140,7 @@ pub(crate) fn shard_index(hash: u64, mask: usize) -> usize { /// and the `Arc` is cloned across threads — a borrowed or lifetime-parameterized hasher /// would prevent the cache from being `'static` and therefore from being shared via /// `thread::spawn` or stored in a `static`. -pub trait ShardHasher: Send + Sync + 'static { +pub trait ShardHasher: Clone + Send + Sync + 'static { fn shard_hash(&self, key: &K) -> u64; } @@ -192,7 +194,7 @@ pub use expiring_lru::{ ShardedExpiringLruCache, ShardedExpiringLruCacheBase, ShardedExpiringLruCacheBuilder, }; pub use lru::{ShardedLruCache, ShardedLruCacheBase, ShardedLruCacheBuilder}; -pub use unbound::{ShardedCache, ShardedCacheBase, ShardedCacheBuilder}; +pub use unbound::{ShardedUnboundCache, ShardedUnboundCacheBase, ShardedUnboundCacheBuilder}; #[cfg(feature = "time_stores")] pub use ttl::{ShardedTtlCache, ShardedTtlCacheBase, ShardedTtlCacheBuilder}; @@ -221,4 +223,32 @@ mod tests { let v3 = h.shard_hash(&43u64); assert_ne!(v1, v3); } + + /// A `Clone`-implementing custom `ShardHasher` satisfies the `ShardHasher: Clone` + /// supertrait bound (item 11). If this compiles, the bound is enforced correctly. + #[test] + fn custom_shard_hasher_requires_clone() { + #[derive(Clone)] + struct ConstHasher; + impl ShardHasher for ConstHasher { + fn shard_hash(&self, key: &u64) -> u64 { + // Fibonacci hashing so upper bits are populated. + key.wrapping_mul(0x9e3779b97f4a7c15) + } + } + let h = ConstHasher; + let h2 = h.clone(); + assert_eq!(h.shard_hash(&1), h2.shard_hash(&1)); + } + + /// `ShardHasher` has `Clone` as a supertrait - verify a non-Clone type cannot + /// satisfy the bound. This is a compile-time-only check: a `Clone` bound on the + /// trait means the trait object is only constructable for `Clone` types. + #[allow(dead_code)] + fn assert_shard_hasher_requires_clone>(_h: H) {} + #[allow(dead_code)] + fn check_shard_hasher_supertrait() { + // DefaultShardHasher derives Clone, so it satisfies the bound. + assert_shard_hasher_requires_clone(DefaultShardHasher::new()); + } } diff --git a/src/stores/sharded/ttl.rs b/src/stores/sharded/ttl.rs index a1c01cf1..3a62209c 100644 --- a/src/stores/sharded/ttl.rs +++ b/src/stores/sharded/ttl.rs @@ -2,6 +2,24 @@ use std::hash::Hash; use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +/// Encode a TTL into the `ttl_nanos` atomic. A zero duration encodes as `0` +/// (expiry disabled / no expiry). +#[inline] +fn encode_ttl(ttl: Duration) -> u64 { + ttl.as_nanos().min(u64::MAX as u128) as u64 +} + +/// Decode the `ttl_nanos` atomic into an optional TTL. `0` means expiry is +/// disabled (entries never expire), so it decodes to `None`. +#[inline] +fn decode_ttl(nanos: u64) -> Option { + if nanos == 0 { + None + } else { + Some(Duration::from_nanos(nanos)) + } +} + #[cfg(feature = "ahash")] use ahash::RandomState; #[cfg(not(feature = "ahash"))] @@ -12,7 +30,10 @@ use std::collections::HashMap; #[cfg(feature = "async_core")] use crate::ConcurrentCachedAsync; use crate::time::{Duration, Instant}; -use crate::{CacheMetrics, ConcurrentCacheEvict, ConcurrentCached, ConcurrentCloneCached}; +use crate::{ + CacheMetrics, ConcurrentCacheBase, ConcurrentCacheEvict, ConcurrentCacheTtl, ConcurrentCached, + ConcurrentCloneCached, +}; use super::{ CachePadded, DefaultShardHasher, Shard, ShardHasher, checked_shard_count, shard_index, @@ -27,7 +48,9 @@ struct TtlInner { shard_mask: usize, hasher: H, on_evict: Option>, - /// TTL in nanoseconds; 0 means disabled. + /// TTL in nanoseconds, or `0` to mean expiry is disabled (entries never expire). + /// A zero stored value is the single sentinel for "no expiry"; there is no separate + /// `ttl_set` flag. `unset_ttl`/`set_ttl(0)` store `0`; `set_ttl(nonzero)` stores the ttl. ttl_nanos: AtomicU64, refresh: AtomicBool, evictions: AtomicU64, @@ -45,9 +68,15 @@ struct TtlInner { /// read hits acquire an exclusive **write lock** to update the entry's TTL timestamp — the same /// trade-off as LRU variants. Disable `refresh_on_hit` if read-lock scalability is a priority. /// +/// **`len` / `evict` contract**: `len()` (the inherent method) returns the raw stored entry +/// count across all shards and may include expired-but-not-yet-swept entries. Call `evict()` +/// (via [`ConcurrentCacheEvict`](crate::ConcurrentCacheEvict)) to physically remove expired +/// entries and obtain an accurate live count. Sharded stores do not implement `CachedIter`. +/// /// This is a type alias for `ShardedTtlCacheBase`. -/// To use a custom shard hasher, construct a [`ShardedTtlCacheBase`] directly via -/// [`ShardedTtlCacheBase::builder()`]. +/// To use a custom shard hasher, call [`ShardedTtlCache::builder()`] and then +/// [`hasher`](ShardedTtlCacheBuilder::hasher), which yields a `ShardedTtlCacheBase` +/// over your hasher. pub type ShardedTtlCache = ShardedTtlCacheBase; /// Backing type for [`ShardedTtlCache`] with a generic shard hasher `H`. @@ -66,12 +95,7 @@ impl Clone for ShardedTtlCacheBase { impl std::fmt::Debug for ShardedTtlCacheBase { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let nanos = self.inner.ttl_nanos.load(Ordering::Relaxed); - let ttl = if nanos == 0 { - None - } else { - Some(Duration::from_nanos(nanos)) - }; + let ttl = self.ttl_duration_impl(); f.debug_struct("ShardedTtlCache") .field("shards", &self.inner.shards.len()) .field("ttl", &ttl) @@ -79,19 +103,57 @@ impl std::fmt::Debug for ShardedTtlCacheBase { } } -impl ShardedTtlCacheBase +impl ShardedTtlCacheBase { + /// Resolve the currently configured TTL, independent of hasher bounds. + /// + /// Returns `None` when expiry is disabled (entries never expire), otherwise + /// `Some(ttl)`. + #[inline] + fn ttl_duration_impl(&self) -> Option { + decode_ttl(self.inner.ttl_nanos.load(Ordering::Relaxed)) + } +} + +impl ShardedTtlCacheBase where K: Hash + Eq, - H: ShardHasher, { - /// Return a builder for constructing a [`ShardedTtlCacheBase`]. + /// Construct a ready-to-use [`ShardedTtlCache`] with the given `ttl`, the + /// [`DefaultShardHasher`], and a default shard count. + /// + /// For a custom hasher, shard count, `refresh_on_hit`, or `on_evict`, use + /// [`builder`](Self::builder). + /// + /// # Panics /// - /// Always returns a builder with the [`DefaultShardHasher`], regardless of the `H` type - /// parameter on `Self`. Call `.hasher(h)` on the builder to use a custom hasher. + /// Panics if `ttl` is zero. Use [`builder`](Self::builder) with + /// [`build`](ShardedTtlCacheBuilder::build) to handle a zero TTL without panicking. + #[must_use] + pub fn new(ttl: Duration) -> ShardedTtlCache { + Self::builder() + .ttl(ttl) + .build() + .expect("ShardedTtlCache::new requires a non-zero ttl") + } + + /// Return a builder for constructing a [`ShardedTtlCache`]. + /// + /// The builder starts with the [`DefaultShardHasher`]. To use a custom hasher, call + /// [`hasher`](ShardedTtlCacheBuilder::hasher) on the returned builder; it switches the + /// builder's hasher type and `build` then yields a `ShardedTtlCacheBase` over that hasher. + /// `new` and `builder` exist only on the default-hasher alias, so a custom hasher is always + /// introduced via `hasher`, never a `ShardedTtlCacheBase::<_, _, H>` turbofish. + #[must_use] pub fn builder() -> ShardedTtlCacheBuilder { ShardedTtlCacheBuilder::default() } +} +impl ShardedTtlCacheBase +where + K: Hash + Eq, + H: ShardHasher, +{ #[inline] fn shard_of(&self, k: &K) -> &CachePadded, RandomState>>> { let h = self.inner.hasher.shard_hash(k); @@ -100,24 +162,33 @@ where #[inline] fn ttl_duration(&self) -> Option { - let nanos = self.inner.ttl_nanos.load(Ordering::Relaxed); - if nanos == 0 { - None - } else { - Some(Duration::from_nanos(nanos)) - } + self.ttl_duration_impl() } #[inline] fn is_expired(&self, entry: &TimedEntry) -> bool { - match self.ttl_duration() { + // `expires_at = None` means never-expires (TTL was disabled at insert time). + match entry.expires_at { None => false, - Some(ttl) => entry.instant.elapsed() >= ttl, + Some(t) => Instant::now() >= t, + } + } + + /// Compute the expiry instant for a new or refreshed entry given the current TTL. + /// TTL is clamped to u64::MAX nanos (~584 years), so `checked_add` overflow is + /// practically unreachable; if it does overflow, the entry becomes never-expires (`None`). + fn compute_expires_at(&self, now: Instant) -> Option { + let nanos = self.inner.ttl_nanos.load(Ordering::Relaxed); + if nanos == 0 { + None + } else { + let ttl = Duration::from_nanos(nanos); + now.checked_add(ttl) } } } -impl + Clone> ShardedTtlCacheBase { +impl> ShardedTtlCacheBase { /// Return an independent deep copy of this cache — entries and metrics are /// duplicated, not shared. In most cases [`Clone::clone`] (Arc-share) is /// what you want. @@ -154,6 +225,58 @@ impl + Clone> ShardedTtlCacheB } } +impl> ShardedTtlCacheBase +where + K: Hash + Eq, + V: Clone, +{ + /// Retrieve a cached value, returning `None` on a miss or if the entry has expired. + /// + /// This is the infallible ergonomic API for the concrete type. Generic code over + /// [`ConcurrentCached`] should use the `Result`-returning trait methods (`cache_get` or the + /// trait's `get` alias), callable as `ConcurrentCached::get(&store, k)` when this inherent + /// method is in scope. + #[must_use] + pub fn get(&self, k: &K) -> Option { + ConcurrentCached::cache_get(self, k).unwrap() + } + + /// Insert a key-value pair and return the previous value, if any. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn set(&self, k: K, v: V) -> Option { + ConcurrentCached::cache_set(self, k, v).unwrap() + } + + /// Remove a cached value and return it if the entry was live. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn remove(&self, k: &K) -> Option { + ConcurrentCached::cache_remove(self, k).unwrap() + } + + /// Remove a cached entry and return the stored key and value, if present. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn remove_entry(&self, k: &K) -> Option<(K, V)> { + ConcurrentCached::cache_remove_entry(self, k).unwrap() + } + + /// Delete a cached entry without returning the value. Returns `true` if an entry was removed. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn delete(&self, k: &K) -> bool { + ConcurrentCached::cache_delete(self, k).unwrap() + } + + /// Remove all entries from every shard and reset metrics. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn reset(&self) { + ConcurrentCached::cache_reset(self).unwrap() + } +} + impl> ShardedTtlCacheBase where K: Hash + Eq, @@ -248,10 +371,6 @@ where where K: Clone, { - let ttl = match self.ttl_duration() { - None => return 0, - Some(t) => t, - }; let mut total = 0; let now = Instant::now(); for shard in self.inner.shards.iter() { @@ -259,7 +378,9 @@ where let mut guard = shard.lock.write(); let expired_keys: Vec = guard .iter() - .filter(|(_, e)| now.saturating_duration_since(e.instant) >= ttl) + // An entry is expired when expires_at is Some(t) and now >= t. + // None means never-expires. + .filter(|(_, e)| e.expires_at.is_some_and(|t| now >= t)) .map(|(k, _)| k.clone()) .collect(); let mut removed = Vec::new(); @@ -299,33 +420,20 @@ where /// TTL values longer than approximately 584 years are silently clamped to `u64::MAX` /// nanoseconds (~584 years). In practice this limit is never reached. /// - /// # Panics - /// - /// Panics if `ttl` is zero — use [`unset_ttl`](Self::unset_ttl) to disable expiry. + /// A zero `ttl` disables expiry — it is exactly equivalent to + /// [`unset_ttl`](Self::unset_ttl), and subsequently inserted entries never expire. pub fn set_ttl(&self, ttl: Duration) -> Option { - assert!( - !ttl.is_zero(), - "TTL must be non-zero; use unset_ttl() to disable expiry" - ); - let prev = self.inner.ttl_nanos.swap( - ttl.as_nanos().min(u64::MAX as u128) as u64, - Ordering::Relaxed, - ); - if prev == 0 { - None - } else { - Some(Duration::from_nanos(prev)) - } + let prev = self + .inner + .ttl_nanos + .swap(encode_ttl(ttl), Ordering::Relaxed); + decode_ttl(prev) } /// Remove the TTL (entries never expire after this point). pub fn unset_ttl(&self) -> Option { let prev = self.inner.ttl_nanos.swap(0, Ordering::Relaxed); - if prev == 0 { - None - } else { - Some(Duration::from_nanos(prev)) - } + decode_ttl(prev) } /// Set whether cache hits refresh the TTL of the accessed entry, @@ -351,7 +459,7 @@ where } } -impl ConcurrentCached for ShardedTtlCacheBase +impl ConcurrentCacheBase for ShardedTtlCacheBase where K: Hash + Eq, V: Clone, @@ -359,13 +467,76 @@ where { type Error = std::convert::Infallible; + fn cache_size(&self) -> Result, Self::Error> { + Ok(Some(self.len())) + } + + fn cache_hits(&self) -> Option { + Some( + self.inner + .shards + .iter() + .map(|s| s.hits.load(Ordering::Relaxed)) + .sum(), + ) + } + + fn cache_misses(&self) -> Option { + Some( + self.inner + .shards + .iter() + .map(|s| s.misses.load(Ordering::Relaxed)) + .sum(), + ) + } + + fn cache_evictions(&self) -> Option { + Some(self.inner.evictions.load(Ordering::Relaxed)) + } +} + +impl ConcurrentCacheTtl for ShardedTtlCacheBase +where + K: Hash + Eq, + V: Clone, + H: ShardHasher, +{ + fn ttl(&self) -> Option { + self.ttl_duration() + } + + fn set_ttl(&self, ttl: Duration) -> Option { + ShardedTtlCacheBase::set_ttl(self, ttl) + } + + fn unset_ttl(&self) -> Option { + ShardedTtlCacheBase::unset_ttl(self) + } + + fn refresh_on_hit(&self) -> bool { + self.inner.refresh.load(Ordering::Relaxed) + } + + fn set_refresh_on_hit(&self, refresh: bool) -> bool { + self.inner.refresh.swap(refresh, Ordering::Relaxed) + } +} + +impl ConcurrentCached for ShardedTtlCacheBase +where + K: Hash + Eq, + V: Clone, + H: ShardHasher, +{ fn cache_get(&self, k: &K) -> Result, Self::Error> { let shard = self.shard_of(k); if self.inner.refresh.load(Ordering::Relaxed) { let mut guard = shard.lock.write(); match guard.get_mut(k) { Some(entry) if !self.is_expired(entry) => { - entry.instant = Instant::now(); + let now = Instant::now(); + entry.expires_at = self.compute_expires_at(now).or(entry.expires_at); let value = Some(entry.value.clone()); drop(guard); shard.hits.fetch_add(1, Ordering::Relaxed); @@ -442,8 +613,10 @@ where fn cache_set(&self, k: K, v: V) -> Result, Self::Error> { let shard = self.shard_of(&k); + let now = Instant::now(); + let expires_at = self.compute_expires_at(now); let new_entry = TimedEntry { - instant: Instant::now(), + expires_at, value: v, }; let old = shard.lock.write().insert(k, new_entry); @@ -458,7 +631,8 @@ where if let Some(cb) = &self.inner.on_evict { cb(&stored_k, &entry.value); } - if self.is_expired(&entry) { + // expired = Some(t) and now >= t; None (never-expires) or now < t -> live + if entry.expires_at.is_some_and(|t| Instant::now() >= t) { Ok(None) } else { Ok(Some(entry.value)) @@ -480,10 +654,6 @@ where Ok(removed.map(|(k, entry)| (k, entry.value))) } - fn cache_size(&self) -> Result, Self::Error> { - Ok(Some(self.len())) - } - fn cache_clear(&self) -> Result<(), Self::Error> { self.clear(); Ok(()) @@ -502,22 +672,6 @@ where self.inner.evictions.store(0, Ordering::Relaxed); Ok(()) } - - fn set_refresh_on_hit(&self, refresh: bool) -> bool { - self.inner.refresh.swap(refresh, Ordering::Relaxed) - } - - fn ttl(&self) -> Option { - self.ttl_duration() - } - - fn set_ttl(&self, ttl: Duration) -> Option { - ShardedTtlCacheBase::set_ttl(self, ttl) - } - - fn unset_ttl(&self) -> Option { - ShardedTtlCacheBase::unset_ttl(self) - } } #[cfg(feature = "async_core")] @@ -527,8 +681,6 @@ where V: Clone + Send + Sync, H: ShardHasher, { - type Error = std::convert::Infallible; - async fn async_cache_get(&self, k: &K) -> Result, Self::Error> { ConcurrentCached::cache_get(self, k) } @@ -556,26 +708,6 @@ where async fn async_cache_reset_metrics(&self) -> Result<(), Self::Error> { ConcurrentCached::cache_reset_metrics(self) } - - fn cache_size(&self) -> Result, Self::Error> { - Ok(Some(self.len())) - } - - fn set_refresh_on_hit(&self, b: bool) -> bool { - >::set_refresh_on_hit(self, b) - } - - fn ttl(&self) -> Option { - self.ttl_duration() - } - - fn set_ttl(&self, ttl: Duration) -> Option { - ShardedTtlCacheBase::set_ttl(self, ttl) - } - - fn unset_ttl(&self) -> Option { - ShardedTtlCacheBase::unset_ttl(self) - } } /// Builder for [`ShardedTtlCacheBase`]. @@ -608,12 +740,32 @@ impl Default for ShardedTtlCacheBuilder { impl ShardedTtlCacheBuilder { /// Set the TTL for cache entries. Required. + /// + /// Overrides any previously set ttl/ttl_secs/ttl_millis on this builder. #[must_use] pub fn ttl(mut self, ttl: Duration) -> Self { self.ttl = Some(ttl); self } + /// Set the TTL for cache entries in whole seconds. Equivalent to + /// `ttl(Duration::from_secs(secs))`. + /// + /// Overrides any previously set ttl/ttl_secs/ttl_millis on this builder. + #[must_use] + pub fn ttl_secs(self, secs: u64) -> Self { + self.ttl(Duration::from_secs(secs)) + } + + /// Set the TTL for cache entries in milliseconds. Equivalent to + /// `ttl(Duration::from_millis(millis))`. + /// + /// Overrides any previously set ttl/ttl_secs/ttl_millis on this builder. + #[must_use] + pub fn ttl_millis(self, millis: u64) -> Self { + self.ttl(Duration::from_millis(millis)) + } + /// Set the number of shards (rounded up to the next power of two). #[must_use] pub fn shards(mut self, shards: usize) -> Self { @@ -636,6 +788,7 @@ impl ShardedTtlCacheBuilder { /// distribute keys across those high bits to avoid lopsided shards; a hasher that only /// varies the low 32 bits will pile every key into one shard. See [`ShardHasher`] for the /// distribution contract and a worked example. Defaults to [`DefaultShardHasher`]. + #[doc(alias = "with_hasher")] #[must_use] pub fn hasher>(self, hasher: H2) -> ShardedTtlCacheBuilder { ShardedTtlCacheBuilder { @@ -673,8 +826,9 @@ impl ShardedTtlCacheBuilder { /// # Errors /// /// Returns [`BuildError::MissingRequired`] if `ttl` was not set, - /// [`BuildError::InvalidTtl`] if the TTL is zero, or [`BuildError`] if the + /// [`BuildError::InvalidValue`] if the TTL is zero, or [`BuildError`] if the /// shard count overflows. + #[must_use = "the Result from build() must be used"] pub fn build(self) -> Result, BuildError> where K: Hash + Eq, @@ -696,7 +850,7 @@ impl ShardedTtlCacheBuilder { .hasher .expect("hasher is always initialized via Default or .hasher()"), on_evict: self.on_evict, - ttl_nanos: AtomicU64::new(ttl.as_nanos().min(u64::MAX as u128) as u64), + ttl_nanos: AtomicU64::new(encode_ttl(ttl)), refresh: AtomicBool::new(self.refresh), evictions: AtomicU64::new(0), }), @@ -735,18 +889,16 @@ impl ShardedTtlCacheBuilder { let new_cache = self .build() .unwrap_or_else(|e| panic!("ShardedTtlCache build failed: {e}")); - let existing_ttl = existing.ttl_duration(); + let _existing_ttl = existing.ttl_duration(); for shard in existing.inner.shards.iter() { let entries: Vec<(K, TimedEntry)> = { let guard = shard.lock.read(); + let now = Instant::now(); guard .iter() .filter(|(_, entry)| { - // Skip entries that are already expired per the existing cache's TTL. - match existing_ttl { - None => true, - Some(ttl) => entry.instant.elapsed() < ttl, - } + // Skip entries that are already expired per their per-entry expires_at. + entry.expires_at.is_none_or(|t| now < t) }) .map(|(k, e)| (k.clone(), e.clone())) .collect() @@ -772,7 +924,7 @@ where fn cache_get_with_expiry_status(&self, k: &K) -> (Option, bool) { let shard = self.shard_of(k); if self.inner.refresh.load(Ordering::Relaxed) { - // Refresh-on-hit path: write lock needed to update the entry's TTL timestamp. + // Refresh-on-hit path: write lock needed to update the entry's expires_at. let mut guard = shard.lock.write(); match guard.get_mut(k) { None => { @@ -784,7 +936,8 @@ where let expired = self.is_expired(entry); let value = entry.value.clone(); if !expired { - entry.instant = Instant::now(); + let now = Instant::now(); + entry.expires_at = self.compute_expires_at(now).or(entry.expires_at); } drop(guard); if expired { @@ -820,14 +973,84 @@ where } } } + + /// Non-renewing read: takes only a read lock, never updates the TTL timestamp, the + /// hits/misses counters, or removes the entry. Returns `(Some(v), expired)` for a present + /// entry (expired or not) or `(None, false)` when absent. + fn cache_peek_with_expiry_status(&self, k: &K) -> (Option, bool) { + let shard = self.shard_of(k); + let guard = shard.lock.read(); + match guard.get(k) { + None => (None, false), + Some(entry) => { + let expired = self.is_expired(entry); + (Some(entry.value.clone()), expired) + } + } + } } #[cfg(test)] mod tests { use super::*; + use crate::ConcurrentCached; use crate::ConcurrentCached as SyncConcurrentCached; use crate::ConcurrentCloneCached; + #[test] + fn new_returns_ready_cache_respecting_ttl() { + let c = ShardedTtlCache::::new(Duration::from_millis(10)); + assert_eq!(c.ttl(), Some(Duration::from_millis(10))); + assert_eq!(SyncConcurrentCached::cache_set(&c, 1, 100).unwrap(), None); + assert_eq!(SyncConcurrentCached::cache_get(&c, &1).unwrap(), Some(100)); + std::thread::sleep(std::time::Duration::from_millis(50)); + assert_eq!( + SyncConcurrentCached::cache_get(&c, &1).unwrap(), + None, + "entry must expire after ttl" + ); + } + + #[test] + #[should_panic(expected = "non-zero ttl")] + fn new_zero_ttl_panics() { + let _c = ShardedTtlCache::::new(Duration::ZERO); + } + + #[test] + fn ttl_secs_and_ttl_millis_set_duration() { + let c = ShardedTtlCache::::builder() + .ttl_secs(7) + .build() + .unwrap(); + assert_eq!(c.ttl(), Some(Duration::from_secs(7))); + + let c = ShardedTtlCache::::builder() + .ttl_millis(250) + .build() + .unwrap(); + assert_eq!(c.ttl(), Some(Duration::from_millis(250))); + } + + #[test] + fn ttl_setters_override_last_writer_wins() { + // ttl(secs=10) then ttl_secs(5) -> 5s + let c = ShardedTtlCache::::builder() + .ttl(Duration::from_secs(10)) + .ttl_secs(5) + .build() + .unwrap(); + assert_eq!(c.ttl(), Some(Duration::from_secs(5))); + + // ttl_secs then ttl_millis -> the millis value + let c = ShardedTtlCache::::builder() + .ttl_secs(10) + .ttl_millis(500) + .build() + .unwrap(); + assert_eq!(c.ttl(), Some(Duration::from_millis(500))); + } + #[test] fn basic_get_set_remove() { let c = ShardedTtlCache::::builder() @@ -963,8 +1186,11 @@ mod tests { .ttl(Duration::from_nanos(0)) .build(); assert!( - matches!(err, Err(crate::stores::BuildError::InvalidTtl { .. })), - "expected InvalidTtl, got {err:?}", + matches!( + err, + Err(crate::stores::BuildError::InvalidValue { field: "ttl", .. }) + ), + "expected InvalidValue, got {err:?}", ); } @@ -1219,4 +1445,224 @@ mod tests { "entry must still be expired on second expiry-status call" ); } + + #[test] + fn peek_with_expiry_status_no_side_effects() { + // Build a 1-shard cache so metrics are not split across shards, making + // counter captures exact. + let c = ShardedTtlCacheBase::::builder() + .ttl(Duration::from_secs(60)) + .shards(1) + .build() + .unwrap(); + + SyncConcurrentCached::cache_set(&c, 1u32, 42u32).expect("insert must succeed"); + + // Capture counters before any peek. + let before = c.metrics(); + + // Live key: expect (Some(42), false). + let (val, expired) = ConcurrentCloneCached::cache_peek_with_expiry_status(&c, &1u32); + assert_eq!(val, Some(42), "live peek must return the value"); + assert!(!expired, "live peek must report expired=false"); + + // Absent key: expect (None, false). + let (val2, expired2) = ConcurrentCloneCached::cache_peek_with_expiry_status(&c, &999u32); + assert!(val2.is_none(), "absent peek must return None"); + assert!(!expired2, "absent peek must report expired=false"); + + // Counters must be unchanged. + let after = c.metrics(); + assert_eq!(after.hits, before.hits, "peek must not increment hits"); + assert_eq!( + after.misses, before.misses, + "peek must not increment misses" + ); + assert_eq!( + after.evictions, before.evictions, + "peek must not increment evictions" + ); + // Entry must still be present. + assert_eq!( + SyncConcurrentCached::cache_get(&c, &1u32).expect("cache_get must succeed"), + Some(42), + "entry must still be present after peek" + ); + } + + #[test] + fn peek_with_expiry_status_stale_entry_no_side_effects() { + // Insert an entry with a very short TTL, let it expire, then peek it. + let c = ShardedTtlCacheBase::::builder() + .ttl(Duration::from_millis(10)) + .shards(1) + .build() + .unwrap(); + + SyncConcurrentCached::cache_set(&c, 1u32, 77u32).expect("insert must succeed"); + std::thread::sleep(std::time::Duration::from_millis(50)); + + let before = c.metrics(); + + let (val, expired) = ConcurrentCloneCached::cache_peek_with_expiry_status(&c, &1u32); + assert_eq!(val, Some(77), "expired peek must return the stale value"); + assert!(expired, "expired peek must report expired=true"); + + // Counters must be unchanged. + let after = c.metrics(); + assert_eq!( + after.hits, before.hits, + "expired peek must not increment hits" + ); + assert_eq!( + after.misses, before.misses, + "expired peek must not increment misses" + ); + assert_eq!( + after.evictions, before.evictions, + "expired peek must not increment evictions" + ); + + // Entry must NOT have been removed by the peek. + let (val2, expired2) = ConcurrentCloneCached::cache_peek_with_expiry_status(&c, &1u32); + assert_eq!( + val2, + Some(77), + "entry must still be present after expired peek" + ); + assert!(expired2, "entry must still be expired after peek"); + } + + #[test] + fn peek_with_expiry_status_does_not_renew_ttl_under_refresh_on_hit() { + // peek must not extend the TTL even when refresh_on_hit is enabled. + let c = ShardedTtlCacheBase::::builder() + .refresh_on_hit(true) + .ttl(Duration::from_millis(10)) + .shards(1) + .build() + .unwrap(); + + SyncConcurrentCached::cache_set(&c, 1u32, 42u32).expect("insert must succeed"); + + // Entry is live; peek must return the value and report not-expired. + let (val, expired) = ConcurrentCloneCached::cache_peek_with_expiry_status(&c, &1u32); + assert_eq!(val, Some(42), "live peek must return the value"); + assert!(!expired, "live peek must report expired=false"); + + // Wait past the original TTL. + std::thread::sleep(std::time::Duration::from_millis(50)); + + // If peek had renewed the TTL the entry would still be live; it must not have. + let (val2, expired2) = ConcurrentCloneCached::cache_peek_with_expiry_status(&c, &1u32); + assert_eq!( + val2, + Some(42), + "post-sleep peek must still return the value" + ); + assert!( + expired2, + "peek must not renew TTL; entry must now be expired" + ); + } + + // --- Inherent infallible method tests --- + + #[test] + fn inherent_get_returns_option_not_result() { + let c = ShardedTtlCache::::builder() + .ttl(Duration::from_secs(60)) + .build() + .unwrap(); + let v: Option = c.get(&1); + assert_eq!(v, None); + c.set(1, 42); + let v: Option = c.get(&1); + assert_eq!(v, Some(42)); + } + + #[test] + fn inherent_set_returns_previous_value() { + let c = ShardedTtlCache::::builder() + .ttl(Duration::from_secs(60)) + .build() + .unwrap(); + let prev: Option = c.set(1, 10); + assert_eq!(prev, None); + let prev: Option = c.set(1, 20); + assert_eq!(prev, Some(10)); + assert_eq!(c.get(&1), Some(20)); + } + + #[test] + fn inherent_remove_returns_prior_value() { + let c = ShardedTtlCache::::builder() + .ttl(Duration::from_secs(60)) + .build() + .unwrap(); + c.set(1, 99); + let v: Option = c.remove(&1); + assert_eq!(v, Some(99)); + assert_eq!(c.remove(&1), None); + assert_eq!(c.get(&1), None); + } + + #[test] + fn inherent_remove_entry_returns_key_and_value() { + let c = ShardedTtlCache::::builder() + .ttl(Duration::from_secs(60)) + .build() + .unwrap(); + c.set(7, 77); + let pair: Option<(u32, u32)> = c.remove_entry(&7); + assert_eq!(pair, Some((7, 77))); + assert_eq!(c.remove_entry(&7), None); + } + + #[test] + fn inherent_delete_returns_bool() { + let c = ShardedTtlCache::::builder() + .ttl(Duration::from_secs(60)) + .build() + .unwrap(); + c.set(1, 10); + let removed: bool = c.delete(&1); + assert!(removed); + let removed: bool = c.delete(&1); + assert!(!removed); + } + + #[test] + fn inherent_reset_clears_and_resets_metrics() { + let c = ShardedTtlCache::::builder() + .ttl(Duration::from_secs(60)) + .build() + .unwrap(); + c.set(1, 1); + c.set(2, 2); + let _ = c.get(&1); + assert_eq!(c.len(), 2); + assert_eq!(c.metrics().hits, Some(1)); + c.reset(); + assert_eq!(c.len(), 0); + assert!(c.is_empty()); + assert_eq!(c.metrics().hits, Some(0)); + } + + #[test] + fn inherent_and_trait_methods_coexist_via_fully_qualified_path() { + fn use_trait(cache: &C, k: u32, v: u32) + where + C: SyncConcurrentCached, + { + let _: Result, _> = ConcurrentCached::cache_set(cache, k, v); + let _: Result, _> = ConcurrentCached::cache_get(cache, &k); + let _: Result, _> = ConcurrentCached::cache_remove(cache, &k); + } + let c = ShardedTtlCache::::builder() + .ttl(Duration::from_secs(60)) + .build() + .unwrap(); + use_trait(&c, 1, 100); + } } diff --git a/src/stores/sharded/unbound.rs b/src/stores/sharded/unbound.rs index 26187387..1c82b009 100644 --- a/src/stores/sharded/unbound.rs +++ b/src/stores/sharded/unbound.rs @@ -11,7 +11,7 @@ use std::collections::HashMap; #[cfg(feature = "async_core")] use crate::ConcurrentCachedAsync; -use crate::{CacheMetrics, ConcurrentCached}; +use crate::{CacheMetrics, ConcurrentCacheBase, ConcurrentCached}; use super::{ CachePadded, DefaultShardHasher, Shard, ShardHasher, checked_shard_count, shard_index, @@ -31,27 +31,28 @@ struct UnboundInner { /// A fully-concurrent, partitioned, unbounded in-memory cache. /// /// Wraps an `Arc` — `clone()` is an Arc-share (shared state), not a deep copy. -/// Use [`deep_clone`](ShardedCacheBase::deep_clone) to get an independent copy. +/// Use [`deep_clone`](ShardedUnboundCacheBase::deep_clone) to get an independent copy. /// /// **Note**: reads return owned values cloned from under the shard lock, so `V` must /// implement `Clone`. /// -/// This is a type alias for `ShardedCacheBase`. -/// To use a custom shard hasher, construct a [`ShardedCacheBase`] directly via -/// [`ShardedCacheBase::builder()`]. -pub type ShardedCache = ShardedCacheBase; +/// This is a type alias for `ShardedUnboundCacheBase`. +/// To use a custom shard hasher, call [`ShardedUnboundCache::builder()`] and then +/// [`hasher`](ShardedUnboundCacheBuilder::hasher), which yields a `ShardedUnboundCacheBase` +/// over your hasher. +pub type ShardedUnboundCache = ShardedUnboundCacheBase; -/// Backing type for [`ShardedCache`] with a generic shard hasher `H`. +/// Backing type for [`ShardedUnboundCache`] with a generic shard hasher `H`. /// -/// In most cases prefer the [`ShardedCache`] alias which uses the default +/// In most cases prefer the [`ShardedUnboundCache`] alias which uses the default /// shard hasher (ahash-backed when the `ahash` feature is enabled, otherwise /// `std::collections::hash_map::RandomState`). Use this type directly only /// when you need a custom [`ShardHasher`] implementation. -pub struct ShardedCacheBase { +pub struct ShardedUnboundCacheBase { inner: Arc>, } -impl Clone for ShardedCacheBase { +impl Clone for ShardedUnboundCacheBase { /// Arc-share clone — both handles point to the same underlying cache. fn clone(&self) -> Self { Self { @@ -60,27 +61,48 @@ impl Clone for ShardedCacheBase { } } -impl std::fmt::Debug for ShardedCacheBase { +impl std::fmt::Debug for ShardedUnboundCacheBase { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ShardedCache") + f.debug_struct("ShardedUnboundCache") .field("shards", &self.inner.shards.len()) .finish_non_exhaustive() } } -impl ShardedCacheBase +impl ShardedUnboundCacheBase where K: Hash + Eq, - H: ShardHasher, { - /// Return a builder for constructing a [`ShardedCacheBase`]. + /// Construct a ready-to-use [`ShardedUnboundCache`] with the [`DefaultShardHasher`] and a + /// default shard count. + /// + /// `ShardedUnboundCache` has no required configuration, so this never fails. For a custom + /// hasher, shard count, or `on_evict`, use [`builder`](Self::builder). + #[must_use] + pub fn new() -> ShardedUnboundCache { + Self::builder() + .build() + .expect("ShardedUnboundCache default build is infallible") + } + + /// Return a builder for constructing a [`ShardedUnboundCache`]. /// - /// Always returns a builder with the [`DefaultShardHasher`], regardless of the `H` type - /// parameter on `Self`. Call `.hasher(h)` on the builder to use a custom hasher. - pub fn builder() -> ShardedCacheBuilder { - ShardedCacheBuilder::default() + /// The builder starts with the [`DefaultShardHasher`]. To use a custom hasher, call + /// [`hasher`](ShardedUnboundCacheBuilder::hasher) on the returned builder; it switches the + /// builder's hasher type and `build` then yields a `ShardedUnboundCacheBase` over that hasher. + /// `new` and `builder` exist only on the default-hasher alias, so a custom hasher is always + /// introduced via `hasher`, never a `ShardedUnboundCacheBase::<_, _, H>` turbofish. + #[must_use] + pub fn builder() -> ShardedUnboundCacheBuilder { + ShardedUnboundCacheBuilder::default() } +} +impl ShardedUnboundCacheBase +where + K: Hash + Eq, + H: ShardHasher, +{ #[inline] fn shard_of(&self, k: &K) -> &CachePadded>> { let h = self.inner.hasher.shard_hash(k); @@ -88,34 +110,34 @@ where } } -impl Default for ShardedCache +impl Default for ShardedUnboundCache where K: Hash + Eq, { fn default() -> Self { - ShardedCacheBuilder::default() + ShardedUnboundCacheBuilder::default() .build() - .unwrap_or_else(|e| panic!("ShardedCache build failed: {e}")) + .unwrap_or_else(|e| panic!("ShardedUnboundCache build failed: {e}")) } } -impl + Clone> ShardedCacheBase { +impl> ShardedUnboundCacheBase { /// Return an independent deep copy of this cache — entries and metrics are /// duplicated, not shared. In most cases [`Clone::clone`] (Arc-share) is /// what you want. /// /// ```rust - /// use cached::{ConcurrentCached, ShardedCache}; + /// use cached::ShardedUnboundCache; /// - /// let cache: ShardedCache = ShardedCache::builder().build().unwrap(); - /// cache.cache_set("k".to_string(), 1).expect("ShardedCache operations are infallible"); + /// let cache: ShardedUnboundCache = ShardedUnboundCache::new(); + /// cache.set("k".to_string(), 1); /// /// let shared = cache.clone(); // Arc clone — same backing store /// let deep = cache.deep_clone(); // independent snapshot /// - /// cache.cache_set("k".to_string(), 2).expect("ShardedCache operations are infallible"); - /// assert_eq!(shared.cache_get(&"k".to_string()).expect("ShardedCache operations are infallible"), Some(2)); // sees update - /// assert_eq!(deep.cache_get(&"k".to_string()).expect("ShardedCache operations are infallible"), Some(1)); // snapshot unchanged + /// cache.set("k".to_string(), 2); + /// assert_eq!(shared.get(&"k".to_string()), Some(2)); // sees update + /// assert_eq!(deep.get(&"k".to_string()), Some(1)); // snapshot unchanged /// ``` #[must_use] pub fn deep_clone(&self) -> Self { @@ -147,7 +169,59 @@ impl + Clone> ShardedCacheBase } } -impl> ShardedCacheBase +impl> ShardedUnboundCacheBase +where + K: Hash + Eq, + V: Clone, +{ + /// Retrieve a cached value, returning `None` on a miss. + /// + /// This is the infallible ergonomic API for the concrete type. Generic code over + /// [`ConcurrentCached`] should use the `Result`-returning trait methods (`cache_get` or the + /// trait's `get` alias), callable as `ConcurrentCached::get(&store, k)` when this inherent + /// method is in scope. + #[must_use] + pub fn get(&self, k: &K) -> Option { + ConcurrentCached::cache_get(self, k).unwrap() + } + + /// Insert a key-value pair and return the previous value, if any. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn set(&self, k: K, v: V) -> Option { + ConcurrentCached::cache_set(self, k, v).unwrap() + } + + /// Remove a cached value and return it if the entry was live. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn remove(&self, k: &K) -> Option { + ConcurrentCached::cache_remove(self, k).unwrap() + } + + /// Remove a cached entry and return the stored key and value, if present. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn remove_entry(&self, k: &K) -> Option<(K, V)> { + ConcurrentCached::cache_remove_entry(self, k).unwrap() + } + + /// Delete a cached entry without returning the value. Returns `true` if an entry was removed. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn delete(&self, k: &K) -> bool { + ConcurrentCached::cache_delete(self, k).unwrap() + } + + /// Remove all entries from every shard and reset metrics. + /// + /// This is the infallible ergonomic API for the concrete type. + pub fn reset(&self) { + ConcurrentCached::cache_reset(self).unwrap() + } +} + +impl> ShardedUnboundCacheBase where K: Hash + Eq, { @@ -212,7 +286,7 @@ where /// /// If no `on_evict` callback is configured, this is equivalent to [`clear`](Self::clear). /// - /// **Note:** `ShardedCache` does not track eviction counts — `metrics().evictions` always + /// **Note:** `ShardedUnboundCache` does not track eviction counts — `metrics().evictions` always /// returns `None` regardless of whether `on_evict` fires. pub fn cache_clear_with_on_evict(&self) { if self.inner.on_evict.is_none() { @@ -229,7 +303,7 @@ where } } -impl ConcurrentCached for ShardedCacheBase +impl ConcurrentCacheBase for ShardedUnboundCacheBase where K: Hash + Eq, V: Clone, @@ -237,6 +311,37 @@ where { type Error = std::convert::Infallible; + fn cache_size(&self) -> Result, Self::Error> { + Ok(Some(self.len())) + } + + fn cache_hits(&self) -> Option { + Some( + self.inner + .shards + .iter() + .map(|s| s.hits.load(Ordering::Relaxed)) + .sum(), + ) + } + + fn cache_misses(&self) -> Option { + Some( + self.inner + .shards + .iter() + .map(|s| s.misses.load(Ordering::Relaxed)) + .sum(), + ) + } +} + +impl ConcurrentCached for ShardedUnboundCacheBase +where + K: Hash + Eq, + V: Clone, + H: ShardHasher, +{ fn cache_get(&self, k: &K) -> Result, Self::Error> { let shard = self.shard_of(k); let guard = shard.lock.read(); @@ -272,10 +377,6 @@ where Ok(removed) } - fn cache_size(&self) -> Result, Self::Error> { - Ok(Some(self.len())) - } - fn cache_clear(&self) -> Result<(), Self::Error> { self.clear(); Ok(()) @@ -293,21 +394,15 @@ where } Ok(()) } - - fn set_refresh_on_hit(&self, _refresh: bool) -> bool { - false - } } #[cfg(feature = "async_core")] -impl ConcurrentCachedAsync for ShardedCacheBase +impl ConcurrentCachedAsync for ShardedUnboundCacheBase where K: Hash + Eq + Send + Sync, V: Clone + Send + Sync, H: ShardHasher, { - type Error = std::convert::Infallible; - async fn async_cache_get(&self, k: &K) -> Result, Self::Error> { ConcurrentCached::cache_get(self, k) } @@ -335,18 +430,10 @@ where async fn async_cache_reset_metrics(&self) -> Result<(), Self::Error> { ConcurrentCached::cache_reset_metrics(self) } - - fn cache_size(&self) -> Result, Self::Error> { - Ok(Some(self.len())) - } - - fn set_refresh_on_hit(&self, b: bool) -> bool { - >::set_refresh_on_hit(self, b) - } } -/// Builder for [`ShardedCacheBase`]. -pub struct ShardedCacheBuilder { +/// Builder for [`ShardedUnboundCacheBase`]. +pub struct ShardedUnboundCacheBuilder { shards: Option, hasher: Option, on_evict: Option>, @@ -354,7 +441,7 @@ pub struct ShardedCacheBuilder { _v: std::marker::PhantomData, } -impl Default for ShardedCacheBuilder { +impl Default for ShardedUnboundCacheBuilder { fn default() -> Self { Self { shards: None, @@ -366,7 +453,7 @@ impl Default for ShardedCacheBuilder { } } -impl ShardedCacheBuilder { +impl ShardedUnboundCacheBuilder { /// Set the number of shards (rounded up to the next power of two). #[must_use] pub fn shards(mut self, shards: usize) -> Self { @@ -384,8 +471,8 @@ impl ShardedCacheBuilder { /// distribution contract and a worked example. Defaults to [`DefaultShardHasher`]. #[doc(alias = "with_hasher")] #[must_use] - pub fn hasher>(self, hasher: H2) -> ShardedCacheBuilder { - ShardedCacheBuilder { + pub fn hasher>(self, hasher: H2) -> ShardedUnboundCacheBuilder { + ShardedUnboundCacheBuilder { shards: self.shards, hasher: Some(hasher), on_evict: self.on_evict, @@ -397,10 +484,10 @@ impl ShardedCacheBuilder { /// Set a callback invoked when an entry is explicitly removed via /// [`cache_remove`](ConcurrentCached::cache_remove) or /// [`cache_remove_entry`](ConcurrentCached::cache_remove_entry). - /// Does **not** fire on [`clear`](ShardedCacheBase::clear); - /// use [`cache_clear_with_on_evict`](ShardedCacheBase::cache_clear_with_on_evict) to opt in. + /// Does **not** fire on [`clear`](ShardedUnboundCacheBase::clear); + /// use [`cache_clear_with_on_evict`](ShardedUnboundCacheBase::cache_clear_with_on_evict) to opt in. /// - /// **Note**: `ShardedCache` does not track eviction counts — `metrics().evictions` always + /// **Note**: `ShardedUnboundCache` does not track eviction counts — `metrics().evictions` always /// returns `None` even when `on_evict` is configured. Use the callback itself to count /// evictions if needed. /// @@ -414,7 +501,7 @@ impl ShardedCacheBuilder { /// Build the cache. /// - /// Use [`ShardedCache::builder()`] (or [`ShardedCacheBase::builder()`]) to obtain a builder, + /// Use [`ShardedUnboundCache::builder()`] (or [`ShardedUnboundCacheBase::builder()`]) to obtain a builder, /// configure it, then call `.build()`. /// /// This builder never fails for valid inputs. The only error case is an @@ -425,7 +512,8 @@ impl ShardedCacheBuilder { /// /// Returns [`BuildError::InvalidValue`] if the `shards` count overflows /// when rounded up to the next power of two. - pub fn build(self) -> Result, BuildError> + #[must_use = "the Result from build() must be used"] + pub fn build(self) -> Result, BuildError> where K: Hash + Eq, H: ShardHasher, @@ -436,7 +524,7 @@ impl ShardedCacheBuilder { .map(|_| CachePadded(Shard::new(HashMap::with_hasher(RandomState::new())))) .collect::>() .into_boxed_slice(); - Ok(ShardedCacheBase { + Ok(ShardedUnboundCacheBase { inner: Arc::new(UnboundInner { shards, shard_mask: mask, @@ -466,8 +554,8 @@ impl ShardedCacheBuilder { #[must_use] pub fn copy_from>( self, - existing: &ShardedCacheBase, - ) -> ShardedCacheBase + existing: &ShardedUnboundCacheBase, + ) -> ShardedUnboundCacheBase where K: Clone + Hash + Eq, V: Clone, @@ -475,7 +563,7 @@ impl ShardedCacheBuilder { { let new_cache = self .build() - .unwrap_or_else(|e| panic!("ShardedCache build failed: {e}")); + .unwrap_or_else(|e| panic!("ShardedUnboundCache build failed: {e}")); for shard in existing.inner.shards.iter() { let entries: Vec<(K, V)> = { let guard = shard.lock.read(); @@ -492,11 +580,20 @@ impl ShardedCacheBuilder { #[cfg(test)] mod tests { use super::*; + use crate::ConcurrentCached; use crate::ConcurrentCached as SyncConcurrentCached; + #[test] + fn new_returns_ready_cache() { + let c = ShardedUnboundCache::::new(); + assert_eq!(SyncConcurrentCached::cache_set(&c, 1, 100).unwrap(), None); + assert_eq!(SyncConcurrentCached::cache_get(&c, &1).unwrap(), Some(100)); + assert_eq!(c.len(), 1); + } + #[test] fn basic_get_set_remove() { - let c = ShardedCache::::builder().build().unwrap(); + let c = ShardedUnboundCache::::builder().build().unwrap(); assert_eq!( SyncConcurrentCached::cache_get(&c, &1).expect("cache_get must succeed"), None @@ -529,7 +626,7 @@ mod tests { #[test] fn clone_shares_state() { - let c1 = ShardedCache::::builder().build().unwrap(); + let c1 = ShardedUnboundCache::::builder().build().unwrap(); let c2 = c1.clone(); SyncConcurrentCached::cache_set(&c1, 1, 10).expect("insert must succeed"); assert_eq!( @@ -540,7 +637,7 @@ mod tests { #[test] fn metrics_sum() { - let c = ShardedCache::::builder().build().unwrap(); + let c = ShardedUnboundCache::::builder().build().unwrap(); SyncConcurrentCached::cache_set(&c, 1, 1).expect("insert must succeed"); SyncConcurrentCached::cache_get(&c, &1).expect("key was just inserted"); SyncConcurrentCached::cache_get(&c, &2).expect("cache_get must succeed"); @@ -551,7 +648,7 @@ mod tests { #[test] fn len_and_clear() { - let c = ShardedCache::::builder().build().unwrap(); + let c = ShardedUnboundCache::::builder().build().unwrap(); for i in 0..10u32 { SyncConcurrentCached::cache_set(&c, i, i).expect("insert must succeed"); } @@ -564,7 +661,7 @@ mod tests { #[test] fn shard_sizes() { - let c = ShardedCache::::builder() + let c = ShardedUnboundCache::::builder() .shards(8) .build() .unwrap(); @@ -581,7 +678,7 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; let count = Arc::new(AtomicUsize::new(0)); let count2 = count.clone(); - let c = ShardedCacheBase::::builder() + let c = ShardedUnboundCacheBase::::builder() .on_evict(move |_, _| { count2.fetch_add(1, Ordering::Relaxed); }) @@ -594,14 +691,14 @@ mod tests { #[test] fn custom_hasher() { - #[derive(Default)] + #[derive(Clone, Default)] struct ConstHasher; impl ShardHasher for ConstHasher { fn shard_hash(&self, _key: &u32) -> u64 { 0 } } - let c = ShardedCacheBase::::builder() + let c = ShardedUnboundCacheBase::::builder() .shards(8) .hasher(ConstHasher) .build() @@ -617,11 +714,11 @@ mod tests { #[test] fn copy_from_preserves_entries() { - let old = ShardedCache::::builder().build().unwrap(); + let old = ShardedUnboundCache::::builder().build().unwrap(); for i in 0..50u32 { SyncConcurrentCached::cache_set(&old, i, i * 10).expect("insert must succeed"); } - let new_cache = ShardedCacheBase::::builder() + let new_cache = ShardedUnboundCacheBase::::builder() .shards(4) .copy_from(&old); for i in 0..50u32 { @@ -634,7 +731,7 @@ mod tests { #[test] fn deep_clone_is_independent() { - let c1 = ShardedCache::::builder().build().unwrap(); + let c1 = ShardedUnboundCache::::builder().build().unwrap(); SyncConcurrentCached::cache_set(&c1, 1, 1).expect("insert must succeed"); let c2 = c1.deep_clone(); SyncConcurrentCached::cache_set(&c1, 2, 2).expect("insert must succeed"); @@ -655,12 +752,12 @@ mod tests { #[test] fn send_sync() { fn assert_send_sync() {} - assert_send_sync::>(); + assert_send_sync::>(); } #[test] fn build_error_on_overflow() { - let c = ShardedCacheBase::::builder() + let c = ShardedUnboundCacheBase::::builder() .shards(usize::MAX) .build(); assert!(c.is_err()); @@ -675,7 +772,9 @@ mod tests { #[test] fn build_error_on_zero_shards() { - let c = ShardedCacheBase::::builder().shards(0).build(); + let c = ShardedUnboundCacheBase::::builder() + .shards(0) + .build(); assert!(c.is_err(), "zero shards should return Err"); match c.expect_err("zero shards should fail") { BuildError::InvalidValue { field, .. } => { @@ -690,7 +789,7 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; let count = Arc::new(AtomicUsize::new(0)); let count2 = count.clone(); - let c = ShardedCacheBase::::builder() + let c = ShardedUnboundCacheBase::::builder() .on_evict(move |_, _| { count2.fetch_add(1, Ordering::Relaxed); }) @@ -717,7 +816,7 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; let count = Arc::new(AtomicUsize::new(0)); let count2 = count.clone(); - let c = ShardedCacheBase::::builder() + let c = ShardedUnboundCacheBase::::builder() .on_evict(move |_, _| { count2.fetch_add(1, Ordering::Relaxed); }) @@ -736,7 +835,7 @@ mod tests { #[test] fn cache_remove_entry_basic() { - let c = ShardedCacheBase::::builder() + let c = ShardedUnboundCacheBase::::builder() .shards(1) .build() .unwrap(); @@ -762,7 +861,7 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; let count = Arc::new(AtomicUsize::new(0)); let count2 = count.clone(); - let c = ShardedCacheBase::::builder() + let c = ShardedUnboundCacheBase::::builder() .shards(1) .on_evict(move |_, _| { count2.fetch_add(1, Ordering::Relaxed); @@ -780,7 +879,7 @@ mod tests { #[test] fn cache_delete_returns_true_for_present_entry() { - let c = ShardedCacheBase::::builder() + let c = ShardedUnboundCacheBase::::builder() .shards(1) .build() .unwrap(); @@ -788,4 +887,90 @@ mod tests { assert!(SyncConcurrentCached::cache_delete(&c, &1u32).expect("cache_delete must succeed")); assert!(!SyncConcurrentCached::cache_delete(&c, &1u32).expect("cache_delete must succeed")); } + + // --- Inherent infallible method tests --- + + #[test] + fn inherent_get_returns_option_not_result() { + let c = ShardedUnboundCache::::new(); + // Return type is Option -- no .unwrap() or ? needed. + let v: Option = c.get(&1); + assert_eq!(v, None); + c.set(1, 42); + let v: Option = c.get(&1); + assert_eq!(v, Some(42)); + } + + #[test] + fn inherent_set_returns_previous_value() { + let c = ShardedUnboundCache::::new(); + // First insert returns None (no prior value). + let prev: Option = c.set(1, 10); + assert_eq!(prev, None); + // Overwrite returns the old value. + let prev: Option = c.set(1, 20); + assert_eq!(prev, Some(10)); + assert_eq!(c.get(&1), Some(20)); + } + + #[test] + fn inherent_remove_returns_prior_value() { + let c = ShardedUnboundCache::::new(); + c.set(1, 99); + let v: Option = c.remove(&1); + assert_eq!(v, Some(99)); + // Absent key returns None. + assert_eq!(c.remove(&1), None); + assert_eq!(c.get(&1), None); + } + + #[test] + fn inherent_remove_entry_returns_key_and_value() { + let c = ShardedUnboundCache::::new(); + c.set(7, 77); + let pair: Option<(u32, u32)> = c.remove_entry(&7); + assert_eq!(pair, Some((7, 77))); + // Absent key returns None. + assert_eq!(c.remove_entry(&7), None); + } + + #[test] + fn inherent_delete_returns_bool() { + let c = ShardedUnboundCache::::new(); + c.set(1, 10); + let removed: bool = c.delete(&1); + assert!(removed); + let removed: bool = c.delete(&1); + assert!(!removed); + } + + #[test] + fn inherent_reset_clears_and_resets_metrics() { + let c = ShardedUnboundCache::::new(); + c.set(1, 1); + c.set(2, 2); + let _ = c.get(&1); + assert_eq!(c.len(), 2); + assert_eq!(c.metrics().hits, Some(1)); + c.reset(); + assert_eq!(c.len(), 0); + assert!(c.is_empty()); + assert_eq!(c.metrics().hits, Some(0)); + } + + #[test] + fn inherent_and_trait_methods_coexist_via_fully_qualified_path() { + // Verify that generic code over ConcurrentCached still works via the trait path + // even though the inherent `get`/`set`/`remove` methods shadow the trait aliases. + fn use_trait(cache: &C, k: u32, v: u32) + where + C: SyncConcurrentCached, + { + let _: Result, _> = ConcurrentCached::cache_set(cache, k, v); + let _: Result, _> = ConcurrentCached::cache_get(cache, &k); + let _: Result, _> = ConcurrentCached::cache_remove(cache, &k); + } + let c = ShardedUnboundCache::::new(); + use_trait(&c, 1, 100); + } } diff --git a/src/stores/ttl.rs b/src/stores/ttl.rs index d10d427d..69602a10 100644 --- a/src/stores/ttl.rs +++ b/src/stores/ttl.rs @@ -1,13 +1,7 @@ use crate::time::Duration; use crate::time::Instant; use std::cmp::Eq; -use std::hash::Hash; - -#[cfg(feature = "ahash")] -use ahash::RandomState; - -#[cfg(not(feature = "ahash"))] -use std::collections::hash_map::RandomState; +use std::hash::{BuildHasher, Hash}; use std::collections::{HashMap, hash_map::Entry}; @@ -16,7 +10,7 @@ use {super::CachedAsync, std::future::Future}; use crate::{CachedIter, CachedPeek, CloneCached}; -use super::{CacheEvict, Cached, TimedEntry}; +use super::{CacheEvict, Cached, DefaultHashBuilder, TimedEntry}; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering}; @@ -26,8 +20,18 @@ use std::sync::atomic::{AtomicU64, Ordering}; /// evicted if expired at time of retrieval. /// /// Note: This cache is in-memory only -pub struct TtlCache { - pub(super) store: HashMap, RandomState>, +/// +/// **`len` / `iter` / `evict` contract**: `len()` returns the raw stored entry count +/// and may include expired-but-not-yet-swept entries. `iter()` omits expired entries +/// from the view but does not remove them. Call `evict()` (via [`CacheEvict`](crate::CacheEvict)) +/// to physically remove expired entries and obtain an accurate live count. +/// +/// The optional type parameter `S` selects the hash builder. It defaults to +/// [`DefaultHashBuilder`] (ahash when the `ahash` feature is enabled, otherwise +/// `std::collections::hash_map::RandomState`). Supply a custom `S` via +/// [`TtlCacheBuilder::hasher`] to use a different hasher. +pub struct TtlCache { + pub(super) store: HashMap, S>, pub(super) ttl: Duration, pub(super) hits: AtomicU64, pub(super) misses: AtomicU64, @@ -37,7 +41,7 @@ pub struct TtlCache { pub(super) on_evict: Option>, } -impl std::fmt::Debug for TtlCache { +impl std::fmt::Debug for TtlCache { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("TtlCache") .field("ttl", &self.ttl) @@ -51,10 +55,11 @@ impl std::fmt::Debug for TtlCache { } } -impl Clone for TtlCache +impl Clone for TtlCache where K: Clone + Hash + Eq, V: Clone, + S: Clone, { fn clone(&self) -> Self { Self { @@ -71,22 +76,55 @@ where } /// Builder for [`TtlCache`]. -pub struct TtlCacheBuilder { +pub struct TtlCacheBuilder { ttl: Option, capacity: Option, refresh: bool, on_evict: Option>, + hasher: S, +} + +impl Default for TtlCacheBuilder { + fn default() -> Self { + Self { + ttl: None, + capacity: None, + refresh: false, + on_evict: None, + hasher: super::new_default_hash_builder(), + } + } } -impl TtlCacheBuilder { - /// Set the TTL for cache entries. Required — `build()` returns +impl TtlCacheBuilder { + /// Set the TTL for cache entries. Required -- `build()` returns /// `Err(BuildError::MissingRequired("ttl"))` if not set. + /// + /// Overrides any previously set ttl/ttl_secs/ttl_millis on this builder. #[must_use] pub fn ttl(mut self, ttl: Duration) -> Self { self.ttl = Some(ttl); self } + /// Set the TTL for cache entries in whole seconds. Equivalent to + /// `ttl(Duration::from_secs(secs))`. + /// + /// Overrides any previously set ttl/ttl_secs/ttl_millis on this builder. + #[must_use] + pub fn ttl_secs(self, secs: u64) -> Self { + self.ttl(Duration::from_secs(secs)) + } + + /// Set the TTL for cache entries in milliseconds. Equivalent to + /// `ttl(Duration::from_millis(millis))`. + /// + /// Overrides any previously set ttl/ttl_secs/ttl_millis on this builder. + #[must_use] + pub fn ttl_millis(self, millis: u64) -> Self { + self.ttl(Duration::from_millis(millis)) + } + /// Set the initial allocation capacity (optional). #[must_use] pub fn capacity(mut self, capacity: usize) -> Self { @@ -116,21 +154,57 @@ impl TtlCacheBuilder { self } + /// Switch to a custom hash builder `S2`, returning a builder parameterized on `S2`. + /// + /// The hasher is used to hash keys in the internal `HashMap`. Calling this method + /// changes the builder's type parameter so `build()` returns a `TtlCache`. + /// + /// # Example + /// + /// ```rust + /// use cached::{Cached, TtlCache}; + /// use std::collections::hash_map::RandomState; + /// + /// let mut cache = TtlCache::::builder() + /// .ttl_secs(60) + /// .hasher(RandomState::new()) + /// .build() + /// .unwrap(); + /// cache.cache_set(1, 100); + /// assert_eq!(cache.cache_get(&1), Some(&100)); + /// ``` + #[doc(alias = "with_hasher")] + #[must_use] + pub fn hasher(self, hasher: S2) -> TtlCacheBuilder { + TtlCacheBuilder { + ttl: self.ttl, + capacity: self.capacity, + refresh: self.refresh, + on_evict: self.on_evict, + hasher, + } + } + /// Build the cache. /// /// # Errors /// /// Returns [`BuildError`](super::BuildError) if `ttl` was not set or is zero /// ([`BuildError::MissingRequired`](super::BuildError::MissingRequired) / - /// [`BuildError::InvalidTtl`](super::BuildError::InvalidTtl)). - pub fn build(self) -> Result, super::BuildError> + /// [`BuildError::InvalidValue`](super::BuildError::InvalidValue)). + pub fn build(self) -> Result, super::BuildError> where K: Hash + Eq, + S: BuildHasher, { let ttl = self.ttl.ok_or(super::BuildError::MissingRequired("ttl"))?; super::validate_ttl(ttl)?; + let store = match self.capacity { + Some(cap) => HashMap::with_capacity_and_hasher(cap, self.hasher), + None => HashMap::with_hasher(self.hasher), + }; Ok(TtlCache { - store: TtlCache::::new_store(self.capacity), + store, ttl, hits: AtomicU64::new(0), misses: AtomicU64::new(0), @@ -143,39 +217,53 @@ impl TtlCacheBuilder { } impl TtlCache { - /// Return a builder for constructing a [`TtlCache`]. + /// Construct a ready-to-use [`TtlCache`] with the given `ttl`. + /// + /// For optional settings (initial capacity, `refresh_on_hit`, `on_evict`) use + /// [`builder`](Self::builder). + /// + /// # Panics + /// + /// Panics if `ttl` is zero. Use [`builder`](Self::builder) with + /// [`build`](TtlCacheBuilder::build) to handle a zero TTL without panicking. #[must_use] - pub fn builder() -> TtlCacheBuilder { - TtlCacheBuilder { - ttl: None, - capacity: None, - refresh: false, - on_evict: None, - } + pub fn new(ttl: Duration) -> Self { + Self::builder() + .ttl(ttl) + .build() + .expect("TtlCache::new requires a non-zero ttl") } - /// Returns whether the ttl is refreshed when the value is retrieved. + /// Return a builder for constructing a [`TtlCache`]. #[must_use] - pub fn refresh_on_hit(&self) -> bool { - self.refresh - } - - /// Sets whether the ttl is refreshed when the value is retrieved. - pub fn set_refresh_on_hit(&mut self, refresh: bool) { - self.refresh = refresh; + pub fn builder() -> TtlCacheBuilder { + TtlCacheBuilder::default() } +} - fn new_store(capacity: Option) -> HashMap, RandomState> { - capacity.map_or_else( - || HashMap::with_hasher(RandomState::new()), - |cap| HashMap::with_capacity_and_hasher(cap, RandomState::new()), - ) +impl TtlCache { + /// `true` if the entry is still live. + /// `expires_at = None` means the entry never expires (TTL was disabled at insert time). + #[inline] + pub(super) fn entry_live(expires_at: Option) -> bool { + expires_at.is_none_or(|t| Instant::now() < t) } - /// Returns a reference to the cache's `store` - #[must_use] - pub fn store(&self) -> &HashMap, RandomState> { - &self.store + /// Compute the expiry instant for a new or refreshed entry given the current TTL. + /// Returns `None` when `ttl` is zero (expiry disabled), or `Some(now + ttl)`. + /// Returns `Err(CacheSetError::TimeBounds)` on overflow. + #[inline] + pub(super) fn compute_expires_at( + ttl: Duration, + now: Instant, + ) -> Result, super::CacheSetError> { + if ttl.is_zero() { + Ok(None) + } else { + now.checked_add(ttl) + .map(Some) + .ok_or(super::CacheSetError::TimeBounds) + } } /// Remove all entries and fire the `on_evict` callback for each one, incrementing the @@ -202,14 +290,15 @@ impl TtlCache { } /// Evict expired values from the cache. + #[must_use] pub fn evict(&mut self) -> usize { - let ttl = self.ttl; let on_evict = &self.on_evict; let evictions = &self.evictions; let mut removed = 0; let now = Instant::now(); self.store.retain(|key, entry| { - if now.saturating_duration_since(entry.instant) < ttl { + // None means never-expires; Some(t) expires when now >= t. + if entry.expires_at.is_none_or(|t| now < t) { true } else { if let Some(on_evict) = on_evict { @@ -224,24 +313,29 @@ impl TtlCache { } } -impl Cached for TtlCache { +impl Cached for TtlCache { + type Error = super::CacheSetError; + fn cache_get(&mut self, key: &Q) -> Option<&V> where K: std::borrow::Borrow, Q: std::hash::Hash + Eq + ?Sized, { if let Some(entry) = self.store.get_mut(key) - && entry.instant.elapsed() < self.ttl + && Self::entry_live(entry.expires_at) { self.hits.fetch_add(1, Ordering::Relaxed); if self.refresh { - entry.instant = Instant::now(); + entry.expires_at = Self::compute_expires_at(self.ttl, Instant::now()) + .ok() + .flatten() + .or(entry.expires_at); } // SAFETY: `ptr` points into a HashMap entry obtained from `get_mut`. // We return immediately without modifying the map, so the entry is // not moved while the returned reference is live. The raw pointer is // needed because the borrow checker cannot see that the `&mut entry` - // borrow ends here when `refresh` mutated `entry.instant` above. + // borrow ends here when `refresh` mutated `entry.expires_at` above. let ptr = &entry.value as *const V; return Some(unsafe { &*ptr }); } @@ -261,13 +355,16 @@ impl Cached for TtlCache { Q: std::hash::Hash + Eq + ?Sized, { if let Some(entry) = self.store.get_mut(key) - && entry.instant.elapsed() < self.ttl + && Self::entry_live(entry.expires_at) { self.hits.fetch_add(1, Ordering::Relaxed); if self.refresh { - entry.instant = Instant::now(); + entry.expires_at = Self::compute_expires_at(self.ttl, Instant::now()) + .ok() + .flatten() + .or(entry.expires_at); } - // SAFETY: same as `cache_get` — entry is not moved between obtaining + // SAFETY: same as `cache_get` -- entry is not moved between obtaining // the pointer and returning, and `&mut self` prevents concurrent access. let ptr = &mut entry.value as *mut V; return Some(unsafe { &mut *ptr }); @@ -282,12 +379,17 @@ impl Cached for TtlCache { None } - fn cache_get_or_set_with V>(&mut self, key: K, f: F) -> &mut V { + fn cache_get_or_set_with_mut V>(&mut self, key: K, f: F) -> &mut V { match self.store.entry(key) { Entry::Occupied(mut occupied) => { - if occupied.get().instant.elapsed() < self.ttl { + if Self::entry_live(occupied.get().expires_at) { if self.refresh { - occupied.get_mut().instant = Instant::now(); + let now = Instant::now(); + let new_exp = Self::compute_expires_at(self.ttl, now) + .ok() + .flatten() + .or(occupied.get().expires_at); + occupied.get_mut().expires_at = new_exp; } self.hits.fetch_add(1, Ordering::Relaxed); } else { @@ -297,8 +399,10 @@ impl Cached for TtlCache { } self.evictions.fetch_add(1, Ordering::Relaxed); let val = f(); + let now = Instant::now(); + let expires_at = Self::compute_expires_at(self.ttl, now).unwrap_or(None); occupied.insert(TimedEntry { - instant: Instant::now(), + expires_at, value: val, }); } @@ -307,9 +411,11 @@ impl Cached for TtlCache { Entry::Vacant(vacant) => { self.misses.fetch_add(1, Ordering::Relaxed); let val = f(); + let now = Instant::now(); + let expires_at = Self::compute_expires_at(self.ttl, now).unwrap_or(None); &mut vacant .insert(TimedEntry { - instant: Instant::now(), + expires_at, value: val, }) .value @@ -317,16 +423,21 @@ impl Cached for TtlCache { } } - fn cache_try_get_or_set_with Result, E>( + fn cache_try_get_or_set_with_mut Result, E>( &mut self, key: K, f: F, ) -> Result<&mut V, E> { match self.store.entry(key) { Entry::Occupied(mut occupied) => { - if occupied.get().instant.elapsed() < self.ttl { + if Self::entry_live(occupied.get().expires_at) { if self.refresh { - occupied.get_mut().instant = Instant::now(); + let now = Instant::now(); + let new_exp = Self::compute_expires_at(self.ttl, now) + .ok() + .flatten() + .or(occupied.get().expires_at); + occupied.get_mut().expires_at = new_exp; } self.hits.fetch_add(1, Ordering::Relaxed); } else { @@ -336,8 +447,10 @@ impl Cached for TtlCache { } self.evictions.fetch_add(1, Ordering::Relaxed); let val = f()?; + let now = Instant::now(); + let expires_at = Self::compute_expires_at(self.ttl, now).unwrap_or(None); occupied.insert(TimedEntry { - instant: Instant::now(), + expires_at, value: val, }); } @@ -346,9 +459,11 @@ impl Cached for TtlCache { Entry::Vacant(vacant) => { self.misses.fetch_add(1, Ordering::Relaxed); let val = f()?; + let now = Instant::now(); + let expires_at = Self::compute_expires_at(self.ttl, now).unwrap_or(None); Ok(&mut vacant .insert(TimedEntry { - instant: Instant::now(), + expires_at, value: val, }) .value) @@ -358,19 +473,41 @@ impl Cached for TtlCache { /// Insert a key-value pair. Returns the previous value only if it had not yet expired. /// Expired previous values are silently discarded. + /// + /// If computing the expiry instant overflows (very large TTL), the entry is stored + /// with `expires_at = None` (never expires). Use [`cache_try_set`](crate::Cached::cache_try_set) + /// when you need to detect this overflow condition. fn cache_set(&mut self, key: K, val: V) -> Option { + let now = Instant::now(); + let expires_at = Self::compute_expires_at(self.ttl, now).unwrap_or(None); let entry = TimedEntry { - instant: Instant::now(), + expires_at, value: val, }; self.store.insert(key, entry).and_then(|entry| { - if entry.instant.elapsed() < self.ttl { + if Self::entry_live(entry.expires_at) { Some(entry.value) } else { None } }) } + + fn cache_try_set(&mut self, key: K, val: V) -> Result, super::CacheSetError> { + let now = Instant::now(); + let expires_at = Self::compute_expires_at(self.ttl, now)?; + let entry = TimedEntry { + expires_at, + value: val, + }; + Ok(self.store.insert(key, entry).and_then(|entry| { + if Self::entry_live(entry.expires_at) { + Some(entry.value) + } else { + None + } + })) + } fn cache_remove(&mut self, k: &Q) -> Option where K: std::borrow::Borrow, @@ -381,7 +518,7 @@ impl Cached for TtlCache { on_evict(&stored_k, &entry.value); } self.evictions.fetch_add(1, Ordering::Relaxed); - if entry.instant.elapsed() < self.ttl { + if Self::entry_live(entry.expires_at) { Some(entry.value) } else { None @@ -417,7 +554,9 @@ impl Cached for TtlCache { } fn cache_reset(&mut self) { // Entries are dropped in-place; `on_evict` is NOT called for cleared entries. - self.store = Self::new_store(self.initial_capacity); + // We use clear + shrink_to rather than rebuilding so we don't need S: Clone. + self.store.clear(); + self.store.shrink_to(self.initial_capacity.unwrap_or(0)); self.cache_reset_metrics(); } fn cache_size(&self) -> usize { @@ -434,15 +573,14 @@ impl Cached for TtlCache { } } -impl CachedIter for TtlCache { +impl CachedIter for TtlCache { fn iter<'a>(&'a self) -> impl Iterator + 'a where K: 'a, V: 'a, { - let ttl = self.ttl; self.store.iter().filter_map(move |(k, entry)| { - if entry.instant.elapsed() < ttl { + if Self::entry_live(entry.expires_at) { Some((k, &entry.value)) } else { None @@ -451,14 +589,14 @@ impl CachedIter for TtlCache { } } -impl CachedPeek for TtlCache { +impl CachedPeek for TtlCache { fn cache_peek(&self, k: &Q) -> Option<&V> where K: std::borrow::Borrow, Q: std::hash::Hash + Eq + ?Sized, { if let Some(entry) = self.store.get(k) - && entry.instant.elapsed() < self.ttl + && Self::entry_live(entry.expires_at) { return Some(&entry.value); } @@ -466,17 +604,26 @@ impl CachedPeek for TtlCache { } } -impl crate::CacheTtl for TtlCache { +impl crate::CacheTtl for TtlCache { fn ttl(&self) -> Option { - Some(self.ttl) + // A zero TTL means expiry is disabled. + if self.ttl.is_zero() { + None + } else { + Some(self.ttl) + } } + /// A zero `ttl` disables expiry -- exactly equivalent to `unset_ttl`. + /// Returns the previous TTL, or `None` if expiry was already disabled. fn set_ttl(&mut self, ttl: Duration) -> Option { let old = self.ttl; self.ttl = ttl; - Some(old) + if old.is_zero() { None } else { Some(old) } } fn unset_ttl(&mut self) -> Option { - None + let old = self.ttl; + self.ttl = Duration::ZERO; + if old.is_zero() { None } else { Some(old) } } fn refresh_on_hit(&self) -> bool { self.refresh @@ -488,21 +635,28 @@ impl crate::CacheTtl for TtlCache { } } -impl CloneCached for TtlCache { +impl CloneCached + for TtlCache +{ fn cache_get_with_expiry_status(&mut self, k: &Q) -> (Option, bool) where K: std::borrow::Borrow, Q: std::hash::Hash + Eq + ?Sized, { if let Some(entry) = self.store.get_mut(k) { - let expired = entry.instant.elapsed() >= self.ttl; + let expired = !Self::entry_live(entry.expires_at); if expired { self.misses.fetch_add(1, Ordering::Relaxed); (Some(entry.value.clone()), true) } else { self.hits.fetch_add(1, Ordering::Relaxed); if self.refresh { - entry.instant = Instant::now(); + let now = Instant::now(); + let new_exp = Self::compute_expires_at(self.ttl, now) + .ok() + .flatten() + .or(entry.expires_at); + entry.expires_at = new_exp; } (Some(entry.value.clone()), false) } @@ -511,14 +665,34 @@ impl CloneCached for TtlCache { (None, false) } } + + /// Peek at the entry (including expired entries) without any read side effects. + /// + /// Returns `(Some(v), true)` for an expired entry, `(Some(v), false)` for a live + /// entry, and `(None, false)` when the key is absent. Does not update hit/miss + /// counters, does not promote in LRU order, and does not renew the TTL. + fn cache_peek_with_expiry_status(&self, k: &Q) -> (Option, bool) + where + K: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized, + V: Clone, + { + if let Some(entry) = self.store.get(k) { + let expired = !Self::entry_live(entry.expires_at); + (Some(entry.value.clone()), expired) + } else { + (None, false) + } + } } #[cfg(feature = "async_core")] -impl CachedAsync for TtlCache +impl CachedAsync for TtlCache where K: Hash + Eq + Clone + Send, + S: BuildHasher + Send, { - fn async_get_or_set_with<'a, F, Fut>( + fn async_cache_get_or_set_with_mut<'a, F, Fut>( &'a mut self, k: K, f: F, @@ -532,9 +706,14 @@ where async move { match self.store.entry(k) { Entry::Occupied(mut occupied) => { - if occupied.get().instant.elapsed() < self.ttl { + if Self::entry_live(occupied.get().expires_at) { if self.refresh { - occupied.get_mut().instant = Instant::now(); + let now = Instant::now(); + let new_exp = Self::compute_expires_at(self.ttl, now) + .ok() + .flatten() + .or(occupied.get().expires_at); + occupied.get_mut().expires_at = new_exp; } self.hits.fetch_add(1, Ordering::Relaxed); } else { @@ -543,8 +722,10 @@ where on_evict(occupied.key(), &occupied.get().value); } self.evictions.fetch_add(1, Ordering::Relaxed); + let now = Instant::now(); + let expires_at = Self::compute_expires_at(self.ttl, now).unwrap_or(None); occupied.insert(TimedEntry { - instant: Instant::now(), + expires_at, value: f().await, }); } @@ -552,9 +733,11 @@ where } Entry::Vacant(vacant) => { self.misses.fetch_add(1, Ordering::Relaxed); + let now = Instant::now(); + let expires_at = Self::compute_expires_at(self.ttl, now).unwrap_or(None); &mut vacant .insert(TimedEntry { - instant: Instant::now(), + expires_at, value: f().await, }) .value @@ -563,7 +746,7 @@ where } } - fn async_try_get_or_set_with<'a, F, Fut, E>( + fn async_cache_try_get_or_set_with_mut<'a, F, Fut, E>( &'a mut self, k: K, f: F, @@ -578,9 +761,14 @@ where async move { let v = match self.store.entry(k) { Entry::Occupied(mut occupied) => { - if occupied.get().instant.elapsed() < self.ttl { + if Self::entry_live(occupied.get().expires_at) { if self.refresh { - occupied.get_mut().instant = Instant::now(); + let now = Instant::now(); + let new_exp = Self::compute_expires_at(self.ttl, now) + .ok() + .flatten() + .or(occupied.get().expires_at); + occupied.get_mut().expires_at = new_exp; } self.hits.fetch_add(1, Ordering::Relaxed); } else { @@ -589,8 +777,10 @@ where on_evict(occupied.key(), &occupied.get().value); } self.evictions.fetch_add(1, Ordering::Relaxed); + let now = Instant::now(); + let expires_at = Self::compute_expires_at(self.ttl, now).unwrap_or(None); occupied.insert(TimedEntry { - instant: Instant::now(), + expires_at, value: f().await?, }); } @@ -598,9 +788,11 @@ where } Entry::Vacant(vacant) => { self.misses.fetch_add(1, Ordering::Relaxed); + let now = Instant::now(); + let expires_at = Self::compute_expires_at(self.ttl, now).unwrap_or(None); &mut vacant .insert(TimedEntry { - instant: Instant::now(), + expires_at, value: f().await?, }) .value @@ -611,7 +803,7 @@ where } } -impl CacheEvict for TtlCache { +impl CacheEvict for TtlCache { fn evict(&mut self) -> usize { TtlCache::evict(self) } @@ -624,6 +816,70 @@ mod tests { use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; + #[test] + fn new_returns_ready_cache_respecting_ttl() { + use crate::CacheTtl; + let mut c: TtlCache = TtlCache::new(crate::time::Duration::from_millis(50)); + assert_eq!( + CacheTtl::ttl(&c), + Some(crate::time::Duration::from_millis(50)) + ); + assert_eq!(c.cache_set(1, 100), None); + assert_eq!(c.cache_get(&1), Some(&100)); + std::thread::sleep(std::time::Duration::from_millis(100)); + assert_eq!(c.cache_get(&1), None, "entry must expire after ttl"); + } + + #[test] + #[should_panic(expected = "non-zero ttl")] + fn new_zero_ttl_panics() { + let _c: TtlCache = TtlCache::new(crate::time::Duration::ZERO); + } + + #[test] + fn ttl_secs_and_ttl_millis_set_duration() { + use crate::CacheTtl; + let c: TtlCache = TtlCache::builder().ttl_secs(7).build().unwrap(); + assert_eq!(CacheTtl::ttl(&c), Some(crate::time::Duration::from_secs(7))); + + let c: TtlCache = TtlCache::builder().ttl_millis(250).build().unwrap(); + assert_eq!( + CacheTtl::ttl(&c), + Some(crate::time::Duration::from_millis(250)) + ); + } + + #[test] + fn ttl_setters_override_last_writer_wins() { + use crate::CacheTtl; + // ttl(secs=10) then ttl_secs(5) -> 5s + let c: TtlCache = TtlCache::builder() + .ttl(crate::time::Duration::from_secs(10)) + .ttl_secs(5) + .build() + .unwrap(); + assert_eq!(CacheTtl::ttl(&c), Some(crate::time::Duration::from_secs(5))); + + // ttl_secs then ttl_millis -> the millis value + let c: TtlCache = TtlCache::builder() + .ttl_secs(10) + .ttl_millis(500) + .build() + .unwrap(); + assert_eq!( + CacheTtl::ttl(&c), + Some(crate::time::Duration::from_millis(500)) + ); + + // ttl_millis then ttl -> the ttl value + let c: TtlCache = TtlCache::builder() + .ttl_millis(500) + .ttl(crate::time::Duration::from_secs(3)) + .build() + .unwrap(); + assert_eq!(CacheTtl::ttl(&c), Some(crate::time::Duration::from_secs(3))); + } + #[test] fn cache_clear_with_on_evict_fires_for_all_entries() { let count = Arc::new(AtomicUsize::new(0)); @@ -799,11 +1055,11 @@ mod tests { std::thread::sleep(std::time::Duration::from_millis(100)); // Even for an expired entry, on_evict must fire. - c.cache_remove_entry(&1u32); + let _ = c.cache_remove_entry(&1u32); assert_eq!(count.load(Ordering::Relaxed), 1); // No fire for absent key. - c.cache_remove_entry(&999u32); + let _ = c.cache_remove_entry(&999u32); assert_eq!(count.load(Ordering::Relaxed), 1); } @@ -816,12 +1072,60 @@ mod tests { c.cache_set(1u32, 10u32); std::thread::sleep(std::time::Duration::from_millis(100)); let before = c.cache_evictions().expect("evictions are always tracked"); - c.cache_remove_entry(&1u32); // expired but present — must increment - c.cache_remove_entry(&999u32); // absent — must not increment + let _ = c.cache_remove_entry(&1u32); // expired but present -- must increment + let _ = c.cache_remove_entry(&999u32); // absent -- must not increment assert_eq!( c.cache_evictions().expect("evictions are always tracked") - before, 1, "cache_remove_entry must increment evictions for present key only" ); } + + // --- custom hasher tests --- + + #[test] + fn custom_hasher_get_set_round_trip() { + use std::collections::hash_map::RandomState; + let mut c = TtlCache::::builder() + .ttl_secs(60) + .hasher(RandomState::new()) + .build() + .unwrap(); + assert_eq!(c.cache_set(1, 100), None); + assert_eq!(c.cache_set(2, 200), None); + assert_eq!(c.cache_get(&1), Some(&100)); + assert_eq!(c.cache_get(&2), Some(&200)); + assert_eq!(c.cache_hits(), Some(2)); + assert_eq!(c.cache_misses(), Some(0)); + assert_eq!(c.cache_get(&99), None); + assert_eq!(c.cache_misses(), Some(1)); + } + + #[test] + fn default_constructor_still_works() { + let mut c: TtlCache = TtlCache::new(crate::time::Duration::from_secs(60)); + c.cache_set(1, 10); + assert_eq!(c.cache_get(&1), Some(&10)); + + let mut b = TtlCache::::builder() + .ttl_secs(60) + .build() + .unwrap(); + b.cache_set(2, 20); + assert_eq!(b.cache_get(&2), Some(&20)); + } + + #[test] + fn custom_hasher_respects_ttl_expiry() { + use std::collections::hash_map::RandomState; + let mut c = TtlCache::::builder() + .ttl(crate::time::Duration::from_millis(50)) + .hasher(RandomState::new()) + .build() + .unwrap(); + c.cache_set(1, 10); + assert_eq!(c.cache_get(&1), Some(&10)); + std::thread::sleep(std::time::Duration::from_millis(100)); + assert_eq!(c.cache_get(&1), None, "entry must expire after ttl"); + } } diff --git a/src/stores/ttl_sorted.rs b/src/stores/ttl_sorted.rs index d5a0439e..034f6268 100644 --- a/src/stores/ttl_sorted.rs +++ b/src/stores/ttl_sorted.rs @@ -2,23 +2,17 @@ use crate::time::Duration; use crate::time::Instant; use crate::{CacheEvict, CacheTtl, Cached, CachedIter, CachedPeek, CachedRead, CloneCached}; -use super::StripedCounter; +use super::{DefaultHashBuilder, StripedCounter}; use std::borrow::Borrow; use std::cmp::Ordering as CmpOrdering; use std::collections::BTreeSet; -use std::hash::{Hash, Hasher}; +use std::hash::{BuildHasher, Hash, Hasher}; use std::ops::Bound::{Excluded, Included}; use std::sync::Arc; use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering}; #[cfg(feature = "async_core")] use {super::CachedAsync, std::future::Future}; -#[cfg(feature = "ahash")] -use ahash::RandomState; - -#[cfg(not(feature = "ahash"))] -use std::collections::hash_map::RandomState; - use std::collections::HashMap; /// Wrap keys in Arc for shared ownership between the HashMap values and BTreeSet index. @@ -66,50 +60,39 @@ impl Borrow for CacheArc { } } -#[non_exhaustive] -#[derive(Debug)] -pub enum Error { - /// Calculating expiration `Instant`s resulted in a - /// value outside of `Instant`s internal bounds - TimeBounds, -} - -/// Public alias for the internal error type — use this name when matching on errors returned by -/// [`TtlSortedCache`] operations (e.g. [`Cached::cache_try_set`](crate::Cached::cache_try_set)). -pub type TtlSortedCacheError = Error; +/// A timestamped key to allow identifying key ranges. +/// +/// `expiry` is `Option`: `None` means "never expires" and sorts as GREATER +/// than any `Some(instant)` so that never-expiring entries appear last in the +/// expiry-ordered BTreeSet (evicted last under size pressure, never swept by TTL). +/// Rust's default `Option` ordering would put `None` first (least), so we implement +/// a custom `Ord` / `PartialOrd` that reverses that. +#[derive(Hash, Eq, PartialEq)] +struct Stamped { + expiry: Option, -impl std::fmt::Display for Error { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Error::TimeBounds => f.write_str("ttl is outside Instant bounds"), - } - } + // wrapped in an option so it's easy to generate + // a range bound containing None + key: Option>, } -impl std::error::Error for Error {} - -impl From for std::io::Error { - fn from(error: Error) -> Self { - match error { - Error::TimeBounds => std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "ttl is outside Instant bounds", - ), - } +impl Ord for Stamped { + fn cmp(&self, other: &Self) -> CmpOrdering { + // Compare expiries: None (never-expires) sorts GREATEST. + let expiry_ord = match (&self.expiry, &other.expiry) { + (None, None) => CmpOrdering::Equal, + (None, Some(_)) => CmpOrdering::Greater, + (Some(_), None) => CmpOrdering::Less, + (Some(a), Some(b)) => a.cmp(b), + }; + expiry_ord.then_with(|| self.key.cmp(&other.key)) } } -/// A timestamped key to allow identifying key ranges -#[derive(Hash, Eq, PartialEq, Ord, PartialOrd)] -struct Stamped { - // note: the field order matters here since the derived ord traits - // generate lexicographic ordering based on the top-to-bottom - // declaration order - expiry: Instant, - - // wrapped in an option so it's easy to generate - // a range bound containing None - key: Option>, +impl PartialOrd for Stamped { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } } impl Clone for Stamped { @@ -122,14 +105,21 @@ impl Clone for Stamped { } impl Stamped { + /// Build a sentinel `Stamped` for use as a BTreeSet range bound. + /// Only `Some(expiry)` bounds are used for expiry-sweep ranges; never-expiring + /// entries (`None`) sort beyond all `Some(_)` values and are excluded automatically. fn bound(expiry: Instant) -> Stamped { - Stamped { expiry, key: None } + Stamped { + expiry: Some(expiry), + key: None, + } } } -/// A timestamped value to allow re-building a timestamped key +/// A timestamped value to allow re-building a timestamped key. +/// `expiry` is `None` when the entry never expires (TTL was zero at insert time). struct Entry { - expiry: Instant, + expiry: Option, key: CacheArc, value: V, } @@ -143,7 +133,7 @@ impl Entry { } fn is_expired(&self) -> bool { - self.expiry < Instant::now() + self.expiry.is_some_and(|e| e < Instant::now()) } } @@ -160,7 +150,7 @@ impl Clone for Entry { /// Policy for [`TtlSortedCache::insert_inner`] when `now + ttl` overflows `Instant`. #[derive(Clone, Copy)] enum TtlOverflow { - /// Return [`Error::TimeBounds`] without mutating the cache. + /// Return [`super::CacheSetError::TimeBounds`] without mutating the cache. Error, /// Saturate the expiry to "now" (immediately stale) and still store the entry. SaturateNow, @@ -175,18 +165,40 @@ enum TtlOverflow { /// To accomplish this, there are a few trade-offs: /// - Maximum cache size logic cannot support "LRU", instead dropping the next value to expire /// - Cache keys must implement `Ord` -/// - The cache's size, reported by `.len` is only guaranteed to be accurate immediately -/// after a call to either `.evict` or `.retain_latest` /// - Eviction must be explicitly requested, either on its own or while inserting +/// +/// **`len` / `iter` / `evict` contract**: `len()` returns the raw stored entry count +/// and may include expired-but-not-yet-swept entries - it is only guaranteed to be +/// accurate immediately after a call to `evict()` or `retain_latest()`. `iter()` omits +/// expired entries from the view but does not remove them. Call `evict()` (via +/// [`CacheEvict`](crate::CacheEvict)) to physically remove expired entries and obtain +/// an accurate live count. +/// +/// `cache_get_or_set_with` returns `&V` (a shared reference), not `&mut V`. +/// Binding it as `&mut V` is a compile error; use +/// [`cache_get_or_set_with_mut`](crate::Cached::cache_get_or_set_with_mut) when +/// a mutable reference is needed. +/// +/// ```compile_fail +/// use cached::{Cached, stores::TtlSortedCache}; +/// use cached::time::Duration; +/// +/// let mut cache = TtlSortedCache::::builder() +/// .ttl(Duration::from_secs(60)) +/// .build() +/// .unwrap(); +/// // compile error: cannot bind &mut u32 from cache_get_or_set_with which returns &u32 +/// let _: &mut u32 = cache.cache_get_or_set_with(1, || 2); +/// ``` #[cfg_attr(docsrs, doc(cfg(feature = "time_stores")))] -pub struct TtlSortedCache { +pub struct TtlSortedCache { // a minimum instant to compare ranges against since // all keys must logically expire after the creation // of the cache min_instant: Instant, // k/v where entry contains corresponds to an ordered value in `keys` - map: HashMap, RandomState>, + map: HashMap, S>, // ordered in ascending expiration `Instant`s // to support retaining/evicting without full traversal @@ -200,7 +212,7 @@ pub struct TtlSortedCache { pub(super) on_evict: Option>, } -impl std::fmt::Debug for TtlSortedCache { +impl std::fmt::Debug for TtlSortedCache { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("TtlSortedCache") .field("ttl", &self.ttl) @@ -213,10 +225,11 @@ impl std::fmt::Debug for TtlSortedCache { } } -impl Clone for TtlSortedCache +impl Clone for TtlSortedCache where K: Clone + Hash + Eq + Ord, V: Clone, + S: Clone, { fn clone(&self) -> Self { Self { @@ -235,14 +248,27 @@ where /// Builder for [`TtlSortedCache`]. #[cfg_attr(docsrs, doc(cfg(feature = "time_stores")))] -pub struct TtlSortedCacheBuilder { +pub struct TtlSortedCacheBuilder { size: Option, capacity: Option, ttl: Option, on_evict: Option>, + hasher: S, +} + +impl Default for TtlSortedCacheBuilder { + fn default() -> Self { + Self { + size: None, + capacity: None, + ttl: None, + on_evict: None, + hasher: super::new_default_hash_builder(), + } + } } -impl TtlSortedCacheBuilder { +impl TtlSortedCacheBuilder { /// Set the maximum number of entries (eviction bound). When the cache exceeds this /// limit, the next-to-expire entries are evicted until it is within bounds. Unlike /// [`capacity`](Self::capacity), this is a hard cap on entry count, not a preallocation @@ -276,12 +302,32 @@ impl TtlSortedCacheBuilder { } /// Set the TTL for cache entries. Required. + /// + /// Overrides any previously set ttl/ttl_secs/ttl_millis on this builder. #[must_use] pub fn ttl(mut self, ttl: Duration) -> Self { self.ttl = Some(ttl); self } + /// Set the TTL for cache entries in whole seconds. Equivalent to + /// `ttl(Duration::from_secs(secs))`. + /// + /// Overrides any previously set ttl/ttl_secs/ttl_millis on this builder. + #[must_use] + pub fn ttl_secs(self, secs: u64) -> Self { + self.ttl(Duration::from_secs(secs)) + } + + /// Set the TTL for cache entries in milliseconds. Equivalent to + /// `ttl(Duration::from_millis(millis))`. + /// + /// Overrides any previously set ttl/ttl_secs/ttl_millis on this builder. + #[must_use] + pub fn ttl_millis(self, millis: u64) -> Self { + self.ttl(Duration::from_millis(millis)) + } + /// Set a callback invoked when an entry is evicted. Fires for: /// - Size-limit evictions during insert (capacity-based, oldest-TTL-first). /// - TTL-expiry sweeps via [`evict`](TtlSortedCache::evict) and [`retain_latest`](TtlSortedCache::retain_latest). @@ -298,14 +344,47 @@ impl TtlSortedCacheBuilder { self } + /// Switch to a custom hash builder `S2`, returning a builder parameterized on `S2`. + /// + /// The hasher is used to hash keys in the internal `HashMap`. Calling this method + /// changes the builder's type parameter so `build()` returns a `TtlSortedCache`. + /// + /// # Example + /// + /// ```rust + /// use cached::{Cached, stores::TtlSortedCache}; + /// use cached::time::Duration; + /// use std::collections::hash_map::RandomState; + /// + /// let mut cache = TtlSortedCache::::builder() + /// .ttl_secs(60) + /// .hasher(RandomState::new()) + /// .build() + /// .unwrap(); + /// cache.cache_set(1, 100); + /// assert_eq!(cache.cache_get(&1), Some(&100)); + /// ``` + #[doc(alias = "with_hasher")] + #[must_use] + pub fn hasher(self, hasher: S2) -> TtlSortedCacheBuilder { + TtlSortedCacheBuilder { + size: self.size, + capacity: self.capacity, + ttl: self.ttl, + on_evict: self.on_evict, + hasher, + } + } + /// Build the cache. /// /// # Errors /// /// Returns [`BuildError`](super::BuildError) if `ttl` is not set or is zero, or if `size` is `0`. - pub fn build(self) -> Result, super::BuildError> + pub fn build(self) -> Result, super::BuildError> where K: Hash + Eq + Ord + Clone, + S: BuildHasher, { let ttl = self.ttl.ok_or(super::BuildError::MissingRequired("ttl"))?; super::validate_ttl(ttl)?; @@ -317,7 +396,7 @@ impl TtlSortedCacheBuilder { } let mut cache = TtlSortedCache { min_instant: Instant::now(), - map: HashMap::with_hasher(RandomState::new()), + map: HashMap::with_hasher(self.hasher), keys: BTreeSet::new(), ttl, size_limit: self.size, @@ -344,17 +423,31 @@ impl TtlSortedCacheBuilder { } impl TtlSortedCache { + /// Construct a ready-to-use [`TtlSortedCache`] with the given `ttl` and no size bound. + /// + /// For optional settings (`max_size`, `capacity`, `on_evict`) use + /// [`builder`](Self::builder). + /// + /// # Panics + /// + /// Panics if `ttl` is zero. Use [`builder`](Self::builder) with + /// [`build`](TtlSortedCacheBuilder::build) to handle a zero TTL without panicking. + #[must_use] + pub fn new(ttl: Duration) -> Self { + Self::builder() + .ttl(ttl) + .build() + .expect("TtlSortedCache::new requires a non-zero ttl") + } + /// Return a builder for constructing an [`TtlSortedCache`]. #[must_use] pub fn builder() -> TtlSortedCacheBuilder { - TtlSortedCacheBuilder { - size: None, - capacity: None, - ttl: None, - on_evict: None, - } + TtlSortedCacheBuilder::default() } +} +impl TtlSortedCache { /// Set the maximum number of entries. When reached, the next entries to expire are evicted. /// Returns the previous value if one was set. /// @@ -366,6 +459,15 @@ impl TtlSortedCache { /// /// Panics if `max_size` is 0. Use [`TtlSortedCache::try_set_max_size`] to handle invalid /// sizes without panicking. + /// + /// # See also + /// + /// [`LruCache::set_max_size`](super::LruCache::set_max_size) and + /// [`LruTtlCache::set_max_size`](super::LruTtlCache::set_max_size) are parallel methods + /// on the other LRU-family stores. Note that this method returns `Option` (the + /// previous bound, which is optional) rather than `usize`, because `TtlSortedCache` does + /// not require a size bound at construction. All stores also provide a fallible + /// `try_set_max_size` counterpart. pub fn set_max_size(&mut self, max_size: usize) -> Option { assert!(max_size > 0, "max_size must be greater than zero"); let prev = self.size_limit; @@ -382,13 +484,13 @@ impl TtlSortedCache { /// /// # Errors /// - /// Returns an `InvalidInput` error if `max_size` is 0. - pub fn try_set_max_size(&mut self, max_size: usize) -> std::io::Result> { + /// Returns [`SetMaxSizeError::ZeroSize`](super::SetMaxSizeError) if `max_size` is 0. + pub fn try_set_max_size( + &mut self, + max_size: usize, + ) -> Result, super::SetMaxSizeError> { if max_size == 0 { - return Err(std::io::Error::new( - std::io::ErrorKind::InvalidInput, - "max_size must be greater than zero", - )); + return Err(super::SetMaxSizeError::ZeroSize); } Ok(self.set_max_size(max_size)) } @@ -411,6 +513,7 @@ impl TtlSortedCache { /// Evict values that have expired. /// Returns number of dropped items. + #[must_use] pub fn evict(&mut self) -> usize { let cutoff = Instant::now(); let min = Stamped::bound(self.min_instant); @@ -483,17 +586,27 @@ impl TtlSortedCache { } /// Insert k/v pair without running eviction logic. See `.insert_ttl_evict` - pub fn insert(&mut self, key: K, value: V) -> Result, Error> { + pub fn insert(&mut self, key: K, value: V) -> Result, super::CacheSetError> { self.insert_ttl_evict(key, value, None, false) } /// Insert k/v pair with explicit ttl. See `.insert_ttl_evict` - pub fn insert_ttl(&mut self, key: K, value: V, ttl: Duration) -> Result, Error> { + pub fn insert_ttl( + &mut self, + key: K, + value: V, + ttl: Duration, + ) -> Result, super::CacheSetError> { self.insert_ttl_evict(key, value, Some(ttl), false) } /// Insert k/v pair and run eviction logic. See `.insert_ttl_evict` - pub fn insert_evict(&mut self, key: K, value: V, evict: bool) -> Result, Error> { + pub fn insert_evict( + &mut self, + key: K, + value: V, + evict: bool, + ) -> Result, super::CacheSetError> { self.insert_ttl_evict(key, value, None, evict) } @@ -507,23 +620,28 @@ impl TtlSortedCache { value: V, ttl: Option, evict: bool, - ) -> Result, Error> { + ) -> Result, super::CacheSetError> { self.insert_inner(key, value, ttl, evict, TtlOverflow::Error, false) } /// Shared insertion routine for [`insert_ttl_evict`](Self::insert_ttl_evict) and the - /// infallible `cache_get_or_set_with` paths. + /// infallible `cache_get_or_set_with_mut` paths. /// /// `on_overflow` selects what happens in the (practically unreachable) case where /// `now + ttl` exceeds `Instant`'s representable range — a TTL on the order of /// hundreds of years: - /// - [`TtlOverflow::Error`]: return [`Error::TimeBounds`] before any mutation + /// - [`TtlOverflow::Error`]: return [`super::CacheSetError::TimeBounds`] before any mutation /// (used by the fallible public API). /// - [`TtlOverflow::SaturateNow`]: store the entry with an already-elapsed expiry /// so the value is still retained (and returnable by reference) but is treated as /// immediately stale. Size-limit enforcement is skipped in this branch so the /// just-inserted entry cannot be the one evicted, which lets the infallible /// `get_or_set` paths return `&mut V` without a fallible re-lookup. + /// + /// When the effective TTL (explicit `ttl` arg or `self.ttl`) is zero, the entry is + /// stored with `expiry = None` (never expires) rather than being given an immediate + /// expiry. Zero TTL means "disable expiry" for new inserts, consistent with the other + /// TTL stores. In this case `overflowed` is always `false`. fn insert_inner( &mut self, key: K, @@ -532,15 +650,22 @@ impl TtlSortedCache { evict: bool, on_overflow: TtlOverflow, skip_size_eviction: bool, - ) -> Result, Error> { + ) -> Result, super::CacheSetError> { let arc_key = CacheArc::new(key.clone()); - let now = Instant::now(); - let (expiry, overflowed) = match now.checked_add(ttl.unwrap_or(self.ttl)) { - Some(expiry) => (expiry, false), - None => match on_overflow { - TtlOverflow::Error => return Err(Error::TimeBounds), - TtlOverflow::SaturateNow => (now, true), - }, + let effective_ttl = ttl.unwrap_or(self.ttl); + + // A zero TTL means "never expires": store expiry = None. + let (expiry, overflowed) = if effective_ttl.is_zero() { + (None, false) + } else { + let now = Instant::now(); + match now.checked_add(effective_ttl) { + Some(t) => (Some(t), false), + None => match on_overflow { + TtlOverflow::Error => return Err(super::CacheSetError::TimeBounds), + TtlOverflow::SaturateNow => (Some(now), true), + }, + } }; let new_stamped = Stamped { @@ -582,7 +707,7 @@ impl TtlSortedCache { self.retain_latest(size_limit, evict); } } else if evict { - self.evict(); + let _ = self.evict(); } } @@ -595,7 +720,7 @@ impl TtlSortedCache { /// an unrepresentable TTL saturates to an immediately-stale entry rather than /// erroring. When a `size_limit` is configured the just-inserted entry is /// protected from eviction: other entries are evicted in TTL order to restore - /// capacity. Used by the infallible `cache_get_or_set_with` family. + /// capacity. Used by the infallible `cache_get_or_set_with_mut` family. fn set_and_get_mut(&mut self, key: K, value: V) -> &mut V { // `Ok` is guaranteed: `TtlOverflow::SaturateNow` never returns `Err`. // `skip_size_eviction = true` defers size enforcement to the block below, @@ -673,7 +798,9 @@ impl TtlSortedCache { } } -impl Cached for TtlSortedCache { +impl Cached for TtlSortedCache { + type Error = super::CacheSetError; + fn cache_get(&mut self, key: &Q) -> Option<&V> where K: Borrow, @@ -726,11 +853,11 @@ impl Cached for TtlSortedCache { self.insert(key, value).unwrap_or(None) } - fn cache_try_set(&mut self, k: K, v: V) -> Result, Box> { - self.insert(k, v).map_err(|e| Box::new(e) as _) + fn cache_try_set(&mut self, k: K, v: V) -> Result, super::CacheSetError> { + self.insert(k, v) } - fn cache_get_or_set_with V>(&mut self, key: K, f: F) -> &mut V { + fn cache_get_or_set_with_mut V>(&mut self, key: K, f: F) -> &mut V { if self.cache_get(&key).is_some() { return self .map @@ -745,7 +872,7 @@ impl Cached for TtlSortedCache { self.set_and_get_mut(key, f()) } - fn cache_try_get_or_set_with Result, E>( + fn cache_try_get_or_set_with_mut Result, E>( &mut self, key: K, f: F, @@ -811,7 +938,9 @@ impl Cached for TtlSortedCache { fn cache_reset(&mut self) { // Entries are dropped in-place; `on_evict` is NOT called for cleared entries. - self.map = HashMap::with_hasher(RandomState::new()); + // Use clear + shrink_to to avoid needing S: Clone to rebuild the HashMap. + self.map.clear(); + self.map.shrink_to(0); self.keys = BTreeSet::new(); self.min_instant = Instant::now(); self.cache_reset_metrics(); @@ -848,7 +977,7 @@ impl Cached for TtlSortedCache { } } -impl CachedIter for TtlSortedCache { +impl CachedIter for TtlSortedCache { fn iter<'a>(&'a self) -> impl Iterator + 'a where K: 'a, @@ -864,22 +993,47 @@ impl CachedIter for TtlSortedCache { } } -impl CacheTtl for TtlSortedCache { +impl CacheTtl for TtlSortedCache { + /// Returns `Some(ttl)` — the currently configured TTL duration. + /// + /// When `ttl` is `Duration::ZERO`, entries inserted while zero is set never expire + /// (they are stored with `expiry = None`). This method still reports `Some(Duration::ZERO)` + /// in that case so callers can observe the configured value. fn ttl(&self) -> Option { Some(self.ttl) } + /// Set the global TTL for future inserts, returning the previous value. + /// + /// A zero `Duration` disables expiry for **future** inserts: entries inserted while the TTL + /// is zero are stored with `expiry = None` and never expire. Pre-existing entries keep their + /// original expiry and still expire on schedule. This is consistent with the other TTL stores + /// (`TtlCache`, `LruTtlCache`). To restore expiry, call `set_ttl` with a non-zero duration. fn set_ttl(&mut self, ttl: Duration) -> Option { let prev = self.ttl; self.ttl = ttl; Some(prev) } - /// `TtlSortedCache` always requires a TTL; calling `unset_ttl` is a no-op and always returns `None`. + /// Disable expiry for future inserts by setting the TTL to `Duration::ZERO`. + /// + /// Equivalent to `set_ttl(Duration::ZERO)`: entries inserted after this call never expire. + /// Pre-existing entries keep their original expiry. Returns `None` (no "previous unset" state + /// to restore; use `ttl()` to capture the previous value before calling `unset_ttl` if + /// needed). fn unset_ttl(&mut self) -> Option { + self.ttl = Duration::ZERO; None } + /// `TtlSortedCache` does not refresh entries on hit; always returns `false`. + fn refresh_on_hit(&self) -> bool { + false + } + /// `TtlSortedCache` does not support refresh-on-hit; this is a no-op and always returns `false`. + fn set_refresh_on_hit(&mut self, _refresh: bool) -> bool { + false + } } -impl CachedPeek for TtlSortedCache { +impl CachedPeek for TtlSortedCache { fn cache_peek(&self, key: &Q) -> Option<&V> where K: Borrow, @@ -895,7 +1049,7 @@ impl CachedPeek for TtlSortedCache { } } -impl CachedRead for TtlSortedCache { +impl CachedRead for TtlSortedCache { fn cache_get_read(&self, key: &Q) -> Option<&V> where K: Borrow, @@ -911,7 +1065,9 @@ impl CachedRead for TtlSortedCache { } } -impl CloneCached for TtlSortedCache { +impl CloneCached + for TtlSortedCache +{ fn cache_get_with_expiry_status(&mut self, k: &Q) -> (Option, bool) where K: Borrow, @@ -932,15 +1088,34 @@ impl CloneCached for TtlSortedCache< } } } + + /// Peek at the entry (including expired entries) without any read side effects. + /// + /// Returns `(Some(v), true)` for an expired entry, `(Some(v), false)` for a live + /// entry, and `(None, false)` when the key is absent. Does not update hit/miss + /// counters or renew the TTL. + fn cache_peek_with_expiry_status(&self, k: &Q) -> (Option, bool) + where + K: Borrow, + Q: Hash + Eq + ?Sized, + V: Clone, + { + match self.map.get(k) { + None => (None, false), + Some(entry) if entry.is_expired() => (Some(entry.value.clone()), true), + Some(entry) => (Some(entry.value.clone()), false), + } + } } #[cfg(feature = "async_core")] -impl CachedAsync for TtlSortedCache +impl CachedAsync for TtlSortedCache where K: Hash + Eq + Ord + Clone + Send + Sync, V: Send, + S: BuildHasher + Send, { - fn async_get_or_set_with<'a, F, Fut>( + fn async_cache_get_or_set_with_mut<'a, F, Fut>( &'a mut self, k: K, f: F, @@ -966,7 +1141,7 @@ where } } - fn async_try_get_or_set_with<'a, F, Fut, E>( + fn async_cache_try_get_or_set_with_mut<'a, F, Fut, E>( &'a mut self, k: K, f: F, @@ -994,7 +1169,9 @@ where } } -impl CacheEvict for TtlSortedCache { +impl CacheEvict + for TtlSortedCache +{ fn evict(&mut self) -> usize { TtlSortedCache::evict(self) } @@ -1004,12 +1181,35 @@ impl CacheEvict for TtlSortedCache::builder() + .ttl(Duration::from_secs(60)) + .build() + .unwrap(); + // Duration::MAX overflows Instant::now().checked_add -> None -> Error branch. + let result = cache.insert_ttl(1u32, 42u32, Duration::MAX); + assert_eq!(result, Err(CacheSetError::TimeBounds)); + // The cache must not be mutated on error. + assert_eq!(cache.cache_size(), 0); + } + #[derive(Clone, Debug)] struct CountingKey { label: &'static str, @@ -1052,6 +1252,56 @@ mod test { } } + #[test] + fn new_returns_ready_cache_respecting_ttl() { + use crate::CacheTtl; + let mut c: TtlSortedCache = TtlSortedCache::new(Duration::from_millis(50)); + assert_eq!(CacheTtl::ttl(&c), Some(Duration::from_millis(50))); + c.cache_set(1, 100); + assert_eq!(c.cache_get(&1), Some(&100)); + std::thread::sleep(std::time::Duration::from_millis(100)); + assert_eq!(c.cache_get(&1), None, "entry must expire after ttl"); + // No size bound from new(). + assert_eq!(c.cache_capacity(), None); + } + + #[test] + #[should_panic(expected = "non-zero ttl")] + fn new_zero_ttl_panics() { + let _c: TtlSortedCache = TtlSortedCache::new(Duration::ZERO); + } + + #[test] + fn ttl_secs_and_ttl_millis_set_duration() { + use crate::CacheTtl; + let c: TtlSortedCache = TtlSortedCache::builder().ttl_secs(7).build().unwrap(); + assert_eq!(CacheTtl::ttl(&c), Some(Duration::from_secs(7))); + + let c: TtlSortedCache = + TtlSortedCache::builder().ttl_millis(250).build().unwrap(); + assert_eq!(CacheTtl::ttl(&c), Some(Duration::from_millis(250))); + } + + #[test] + fn ttl_setters_override_last_writer_wins() { + use crate::CacheTtl; + // ttl(secs=10) then ttl_secs(5) -> 5s + let c: TtlSortedCache = TtlSortedCache::builder() + .ttl(Duration::from_secs(10)) + .ttl_secs(5) + .build() + .unwrap(); + assert_eq!(CacheTtl::ttl(&c), Some(Duration::from_secs(5))); + + // ttl_secs then ttl_millis -> the millis value + let c: TtlSortedCache = TtlSortedCache::builder() + .ttl_secs(10) + .ttl_millis(500) + .build() + .unwrap(); + assert_eq!(CacheTtl::ttl(&c), Some(Duration::from_millis(500))); + } + #[test] fn borrow_keys() { let mut cache = TtlSortedCache::builder() @@ -1120,12 +1370,16 @@ mod test { .build() .expect("cache should build"); + // Use a very short but non-zero TTL (zero now means "never expires"). cache - .insert_ttl("expired", 10, Duration::from_nanos(0)) + .insert_ttl("expired", 10, Duration::from_millis(1)) .unwrap(); assert_eq!(cache.cache_size(), 1); assert_eq!(cache.keys.len(), 1); + // Wait for the TTL to elapse before querying. + std::thread::sleep(std::time::Duration::from_millis(20)); + assert_eq!(cache.cache_get(&"expired"), None); assert_eq!(cache.cache_size(), 0); @@ -1150,12 +1404,16 @@ mod test { .build() .expect("cache should build"); + // Use a very short but non-zero TTL (zero now means "never expires"). cache - .insert_ttl("expired-mut", 20, Duration::from_nanos(0)) + .insert_ttl("expired-mut", 20, Duration::from_millis(1)) .unwrap(); assert_eq!(cache.cache_size(), 1); assert_eq!(cache.keys.len(), 1); + // Wait for the TTL to elapse before querying. + std::thread::sleep(std::time::Duration::from_millis(20)); + assert_eq!(cache.cache_get_mut(&"expired-mut"), None); assert_eq!(cache.cache_size(), 0); @@ -1323,6 +1581,29 @@ mod test { } } + #[test] + fn try_set_max_size_rejects_zero() { + let mut cache = TtlSortedCache::::builder() + .ttl(Duration::from_millis(1_000)) + .build() + .unwrap(); + assert_eq!( + cache.try_set_max_size(0), + Err(super::super::SetMaxSizeError::ZeroSize) + ); + assert_eq!(cache.try_set_max_size(5).unwrap(), None); + } + + #[test] + #[should_panic(expected = "max_size must be greater than zero")] + fn set_max_size_zero_panics() { + let mut cache = TtlSortedCache::::builder() + .ttl(Duration::from_millis(1_000)) + .build() + .unwrap(); + cache.set_max_size(0); + } + #[test] fn explicit_capacity_takes_precedence_over_max_size_preallocation() { // Regression for #266: an explicit, smaller `capacity` must not be defeated @@ -1392,16 +1673,40 @@ mod test { .insert_ttl("long", 1u32, Duration::from_secs(60)) .unwrap(); let v: &mut u32 = cache - .cache_try_get_or_set_with("short", || Ok::(2)) + .cache_try_get_or_set_with_mut("short", || Ok::(2)) .unwrap(); assert_eq!(*v, 2); assert_eq!(cache.cache_size(), 1); assert_eq!(cache.cache_get("short"), Some(&2u32)); } + #[test] + fn shared_ref_get_or_set_with_wrapper_delegates_to_mut() { + // The `&V`-returning `cache_get_or_set_with` / `cache_try_get_or_set_with` + // are provided as defaults that delegate to the `_mut` variants. Exercise + // them directly (not the `_mut` methods) so the delegation stays covered. + let mut cache: TtlSortedCache<&str, u32> = TtlSortedCache::builder() + .ttl(Duration::from_secs(60)) + .build() + .unwrap(); + + let v: &u32 = cache.cache_get_or_set_with("a", || 1u32); + assert_eq!(*v, 1); + + let v: &u32 = cache + .cache_try_get_or_set_with("b", || Ok::(2)) + .unwrap(); + assert_eq!(*v, 2); + + // Hit path: the closure must not run, and the stored value is returned by `&V`. + let v: &u32 = cache.cache_get_or_set_with("a", || 99u32); + assert_eq!(*v, 1); + assert_eq!(cache.cache_size(), 2); + } + #[cfg(feature = "async")] #[tokio::test] - async fn async_get_or_set_with_max_size_limit_short_ttl_does_not_panic() { + async fn async_cache_get_or_set_with_max_size_limit_short_ttl_does_not_panic() { use crate::CachedAsync; let mut cache = TtlSortedCache::builder() .ttl(Duration::from_millis(1)) @@ -1412,7 +1717,7 @@ mod test { .insert_ttl("long", 1u32, Duration::from_secs(60)) .unwrap(); let v = cache - .async_get_or_set_with("short", || async { 2u32 }) + .async_cache_get_or_set_with("short", || async { 2u32 }) .await; assert_eq!(*v, 2); assert_eq!(cache.cache_size(), 1); @@ -1608,14 +1913,14 @@ mod test { c.cache_set(1u32, 10u32); std::thread::sleep(std::time::Duration::from_millis(100)); - c.cache_remove_entry(&1u32); + let _ = c.cache_remove_entry(&1u32); assert_eq!( count.load(Ordering::Relaxed), 1, "on_evict fires for expired entries" ); - c.cache_remove_entry(&999u32); + let _ = c.cache_remove_entry(&999u32); assert_eq!(count.load(Ordering::Relaxed), 1, "no fire for absent key"); } @@ -1637,12 +1942,470 @@ mod test { c.cache_set(1u32, 10u32); std::thread::sleep(std::time::Duration::from_millis(100)); let before = c.cache_evictions().expect("evictions are always tracked"); - c.cache_remove_entry(&1u32); // expired but present — must increment - c.cache_remove_entry(&999u32); // absent — must not increment + let _ = c.cache_remove_entry(&1u32); // expired but present — must increment + let _ = c.cache_remove_entry(&999u32); // absent — must not increment assert_eq!( c.cache_evictions().expect("evictions are always tracked") - before, 1, "cache_remove_entry must increment evictions for present key only" ); } + + // ── Item 3: set_ttl(0) = "never expires" behavioral tests ───────────── + + /// Zero TTL at insert time means entries NEVER expire (not "expire immediately"). + #[test] + fn set_ttl_zero_entries_never_expire() { + use crate::CacheTtl; + let mut cache = TtlSortedCache::::builder() + .ttl(Duration::from_millis(50)) + .build() + .unwrap(); + // Switch to zero TTL before inserting. + cache.set_ttl(Duration::ZERO); + cache.cache_set(1u32, 10u32); + // Wait well past the original 50ms TTL. + std::thread::sleep(std::time::Duration::from_millis(150)); + // Entry must still be present (never expires). + assert_eq!( + cache.cache_get(&1u32), + Some(&10u32), + "entry inserted with zero TTL must never expire" + ); + // ttl() still reports the configured value. + assert_eq!(CacheTtl::ttl(&cache), Some(Duration::ZERO)); + } + + /// Switching set_ttl to zero only affects entries inserted AFTER the change. + /// Pre-existing finite-expiry entries still expire on their original schedule. + #[test] + fn set_ttl_zero_only_affects_future_inserts() { + let mut cache = TtlSortedCache::::builder() + .ttl(Duration::from_millis(80)) + .build() + .unwrap(); + // Insert with the current finite TTL. + cache.cache_set(1u32, 100u32); + // Switch to zero TTL (never-expires) for future inserts. + cache.set_ttl(Duration::ZERO); + cache.cache_set(2u32, 200u32); + // Wait past the finite TTL for key 1. + std::thread::sleep(std::time::Duration::from_millis(150)); + // Key 1 (finite TTL) must be expired. + assert_eq!( + cache.cache_get(&1u32), + None, + "pre-existing finite-TTL entry must expire" + ); + // Key 2 (inserted with zero TTL = never expires) must still be present. + assert_eq!( + cache.cache_get(&2u32), + Some(&200u32), + "entry inserted with zero TTL must never expire" + ); + } + + /// Under size pressure, never-expiring entries (None expiry) are evicted LAST — + /// after all finite-expiry entries have been dropped. + #[test] + fn set_ttl_zero_never_expire_entries_evicted_last_under_size_pressure() { + // Build with max_size = 2. + let mut cache = TtlSortedCache::::builder() + .ttl(Duration::from_secs(10)) + .max_size(2) + .build() + .unwrap(); + + // Insert one never-expiring entry. + cache.set_ttl(Duration::ZERO); + cache.cache_set(1u32, 10u32); + + // Insert two finite-TTL entries (these must be evicted before the never-expiring one). + cache.set_ttl(Duration::from_millis(500)); + cache.cache_set(2u32, 20u32); + cache.cache_set(3u32, 30u32); + // At this point the cache has 3 entries and max_size = 2; key 1 (never-expiring, None + // expiry, sorts greatest) must be the survivor along with the later finite entry. + // Actually, retain_latest evicts the soonest-expiring first: key 2 and key 3 have + // Some(expiry) and key 1 has None (greatest). So one of key 2/3 was evicted, and + // key 1 (never-expires) survives. + assert_eq!(cache.cache_size(), 2, "max_size must be enforced"); + assert_eq!( + cache.cache_get(&1u32), + Some(&10u32), + "never-expiring entry must survive size eviction" + ); + + // Now insert one more to push out the remaining finite-expiry entry. + cache.cache_set(4u32, 40u32); + assert_eq!(cache.cache_size(), 2); + assert_eq!( + cache.cache_get(&1u32), + Some(&10u32), + "never-expiring entry must still survive" + ); + } + + /// unset_ttl is equivalent to set_ttl(Duration::ZERO): future inserts never expire. + #[test] + fn unset_ttl_makes_future_inserts_never_expire() { + use crate::CacheTtl; + let mut cache = TtlSortedCache::::builder() + .ttl(Duration::from_millis(50)) + .build() + .unwrap(); + cache.unset_ttl(); + assert_eq!( + CacheTtl::ttl(&cache), + Some(Duration::ZERO), + "unset_ttl sets internal ttl to zero" + ); + cache.cache_set(1u32, 99u32); + std::thread::sleep(std::time::Duration::from_millis(120)); + assert_eq!( + cache.cache_get(&1u32), + Some(&99u32), + "entry inserted after unset_ttl must never expire" + ); + } + + /// Evict must not sweep never-expiring (None expiry) entries. + #[test] + fn evict_does_not_remove_never_expiring_entries() { + let mut cache = TtlSortedCache::::builder() + .ttl(Duration::from_millis(20)) + .build() + .unwrap(); + // Insert a finite-TTL entry. + cache.cache_set(1u32, 10u32); + // Switch to zero TTL and insert a never-expiring entry. + cache.set_ttl(Duration::ZERO); + cache.cache_set(2u32, 20u32); + // Wait for the finite entry to expire. + std::thread::sleep(std::time::Duration::from_millis(80)); + let evicted = cache.evict(); + // Only the finite-TTL entry should be swept. + assert_eq!( + evicted, 1, + "evict must sweep only expired finite-TTL entries" + ); + assert_eq!(cache.cache_size(), 1, "never-expiring entry must remain"); + assert_eq!(cache.cache_get(&2u32), Some(&20u32)); + } + + /// `insert_ttl` called with an EXPLICIT `Duration::ZERO` (not the cache-level + /// `set_ttl`) must store `expiry = None` (never expires), not `Some(now)` + /// (immediate). The cache's default TTL stays finite the whole time. + #[test] + fn insert_ttl_explicit_zero_never_expires() { + let mut cache = TtlSortedCache::::builder() + .ttl(Duration::from_millis(20)) + .build() + .unwrap(); + // Explicit zero TTL on this one entry — default ttl remains 20ms. + cache.insert_ttl(1u32, 10u32, Duration::ZERO).unwrap(); + // The entry's internal expiry must be None (never), not Some(now). + assert!( + cache + .map + .get(&1u32) + .expect("entry present") + .expiry + .is_none(), + "explicit Duration::ZERO must store expiry = None (never expires)" + ); + // Wait far past the default 20ms TTL. + std::thread::sleep(std::time::Duration::from_millis(80)); + assert_eq!( + cache.cache_get(&1u32), + Some(&10u32), + "entry inserted with explicit zero TTL must never expire" + ); + // A sibling inserted with the finite default TTL must still expire. + cache.cache_set(2u32, 20u32); + std::thread::sleep(std::time::Duration::from_millis(80)); + assert_eq!( + cache.cache_get(&2u32), + None, + "finite-TTL sibling must expire" + ); + assert_eq!(cache.cache_get(&1u32), Some(&10u32)); + } + + /// `insert_ttl_evict` with explicit `Duration::ZERO` also stores `None`, + /// and the never-expiring entry is not swept by the eviction pass it triggers. + #[test] + fn insert_ttl_evict_explicit_zero_never_expires_and_survives_evict() { + let mut cache = TtlSortedCache::::builder() + .ttl(Duration::from_millis(10)) + .build() + .unwrap(); + // A finite, soon-to-expire entry. + cache.cache_set(1u32, 10u32); + std::thread::sleep(std::time::Duration::from_millis(40)); + // Insert a never-expiring entry AND run the eviction pass in the same call. + cache + .insert_ttl_evict(2u32, 20u32, Some(Duration::ZERO), true) + .unwrap(); + assert!( + cache + .map + .get(&2u32) + .expect("entry present") + .expiry + .is_none(), + "explicit zero TTL must be None" + ); + // The expired finite entry was swept; the never-expiring one survives. + assert_eq!(cache.cache_get(&1u32), None, "expired entry swept by evict"); + assert_eq!( + cache.cache_get(&2u32), + Some(&20u32), + "never-expiring entry must survive its own evict pass" + ); + } + + /// `retain_latest` over a MIX of never-expires (`None`) and finite (`Some`) entries: + /// finite entries are popped first (soonest-expiry order); `None` entries are retained + /// last regardless of insertion order. Verified across several `count` values. + #[test] + fn retain_latest_keeps_never_expiring_entries_last() { + // Insertion order deliberately interleaves never/finite to prove that ordering, + // not insertion order, decides eviction. + fn fresh() -> TtlSortedCache { + let mut cache = TtlSortedCache::::builder() + .ttl(Duration::from_secs(60)) + .build() + .unwrap(); + // finite (soonest) + cache.set_ttl(Duration::from_millis(100)); + cache.cache_set(1u32, 10u32); + // never + cache.set_ttl(Duration::ZERO); + cache.cache_set(2u32, 20u32); + // finite (later than key 1) + cache.set_ttl(Duration::from_secs(60)); + cache.cache_set(3u32, 30u32); + // never + cache.set_ttl(Duration::ZERO); + cache.cache_set(4u32, 40u32); + cache + } + + // count = 2: the two finite entries (1, 3) are dropped, both nevers (2, 4) kept. + let mut cache = fresh(); + let dropped = cache.retain_latest(2, false); + assert_eq!(dropped, 2); + assert_eq!(cache.cache_get(&1u32), None, "soonest finite dropped"); + assert_eq!(cache.cache_get(&3u32), None, "later finite dropped"); + assert_eq!(cache.cache_get(&2u32), Some(&20u32), "never-expires kept"); + assert_eq!(cache.cache_get(&4u32), Some(&40u32), "never-expires kept"); + + // count = 3: only the soonest finite (key 1) is dropped; key 3 and both nevers kept. + let mut cache = fresh(); + let dropped = cache.retain_latest(3, false); + assert_eq!(dropped, 1); + assert_eq!(cache.cache_get(&1u32), None, "soonest finite dropped first"); + assert_eq!(cache.cache_get(&3u32), Some(&30u32)); + assert_eq!(cache.cache_get(&2u32), Some(&20u32)); + assert_eq!(cache.cache_get(&4u32), Some(&40u32)); + + // count = 1: both finite dropped, then ONE never must be dropped. The surviving + // entry must be a never-expires entry (key 2 or key 4), never a finite one. + let mut cache = fresh(); + let dropped = cache.retain_latest(1, false); + assert_eq!(dropped, 3); + assert_eq!(cache.cache_size(), 1); + assert_eq!(cache.cache_get(&1u32), None); + assert_eq!(cache.cache_get(&3u32), None); + let survivor_is_never = + cache.cache_get(&2u32).is_some() || cache.cache_get(&4u32).is_some(); + assert!( + survivor_is_never, + "the last-retained entry must be a never-expires entry, not a finite one" + ); + + // count = 0: everything dropped. + let mut cache = fresh(); + let dropped = cache.retain_latest(0, false); + assert_eq!(dropped, 4); + assert_eq!(cache.cache_size(), 0); + } + + /// Max-size eviction with never-expires and finite entries interleaved in insertion + /// order: finite entries are always evicted before never-expires entries, regardless + /// of when the never-expires entries were inserted. + #[test] + fn max_size_eviction_evicts_finite_before_never_interleaved() { + let mut cache = TtlSortedCache::::builder() + .ttl(Duration::from_secs(60)) + .max_size(3) + .build() + .unwrap(); + // Insert a never-expires entry FIRST (oldest by insertion order). + cache.set_ttl(Duration::ZERO); + cache.cache_set(1u32, 10u32); + // Then finite entries. + cache.set_ttl(Duration::from_secs(30)); + cache.cache_set(2u32, 20u32); + cache.cache_set(3u32, 30u32); + assert_eq!(cache.cache_size(), 3); + // A 4th finite insert exceeds max_size=3 -> evict the soonest-expiring (a finite one). + cache.cache_set(4u32, 40u32); + assert_eq!(cache.cache_size(), 3); + assert_eq!( + cache.cache_get(&1u32), + Some(&10u32), + "the oldest-inserted never-expires entry must not be evicted" + ); + // The evicted one must be a finite entry (key 2 was the soonest of the finites). + assert_eq!(cache.cache_get(&2u32), None, "soonest finite evicted"); + // Push more finite inserts; the never-expires entry must keep surviving. + cache.cache_set(5u32, 50u32); + cache.cache_set(6u32, 60u32); + assert_eq!(cache.cache_size(), 3); + assert_eq!( + cache.cache_get(&1u32), + Some(&10u32), + "never-expires entry survives repeated finite-driven eviction" + ); + } + + /// `cache_get_or_set_with` when the cache TTL is zero: the just-inserted entry is + /// retrievable immediately and never expires (stored with expiry = None). + #[test] + fn get_or_set_with_zero_ttl_inserts_never_expiring_entry() { + let mut cache = TtlSortedCache::::builder() + .ttl(Duration::from_millis(10)) + .build() + .unwrap(); + cache.set_ttl(Duration::ZERO); + // Miss path computes and inserts; value retrievable immediately. + let v = cache.cache_get_or_set_with(1u32, || 42u32); + assert_eq!(*v, 42); + assert!( + cache + .map + .get(&1u32) + .expect("entry present") + .expiry + .is_none(), + "zero-ttl get_or_set must store expiry = None" + ); + // Persists well past the former 10ms TTL. + std::thread::sleep(std::time::Duration::from_millis(60)); + assert_eq!( + cache.cache_get(&1u32), + Some(&42u32), + "zero-ttl get_or_set entry must never expire" + ); + // Hit path: closure must not run. + let v = cache.cache_get_or_set_with(1u32, || 999u32); + assert_eq!(*v, 42, "existing never-expiring entry returned on hit"); + } + + /// `cache_try_get_or_set_with` when the cache TTL is zero: same contract via the + /// fallible path. The entry is retrievable immediately and never expires. + #[test] + fn try_get_or_set_with_zero_ttl_inserts_never_expiring_entry() { + let mut cache = TtlSortedCache::<&str, u32>::builder() + .ttl(Duration::from_millis(10)) + .build() + .unwrap(); + cache.set_ttl(Duration::ZERO); + let v: &u32 = cache + .cache_try_get_or_set_with("k", || Ok::(7)) + .unwrap(); + assert_eq!(*v, 7); + assert!( + cache.map.get("k").expect("entry present").expiry.is_none(), + "zero-ttl try_get_or_set must store expiry = None" + ); + std::thread::sleep(std::time::Duration::from_millis(60)); + assert_eq!( + cache.cache_get("k"), + Some(&7u32), + "zero-ttl try_get_or_set entry must never expire" + ); + } + + /// The four renamed single-owner `CachedAsync` default methods, exercised on a real + /// `TtlSortedCache` (not only `UnboundCache`). Confirms the rename works on a store + /// whose `cache_get`/`cache_set`/`cache_remove`/`cache_clear` carry TTL semantics. + #[cfg(feature = "async")] + #[tokio::test] + async fn async_cache_methods_on_ttl_sorted_cache() { + use crate::CachedAsync; + let mut cache: TtlSortedCache = TtlSortedCache::builder() + .ttl(Duration::from_secs(60)) + .build() + .unwrap(); + + let prev = cache.async_cache_set("a".to_string(), 1u32).await; + assert_eq!(prev, None, "first insert returns None"); + + let prev = cache.async_cache_set("a".to_string(), 2u32).await; + assert_eq!(prev, Some(1u32), "overwrite returns previous value"); + + let got = cache.async_cache_get("a").await; + assert_eq!(got, Some(&2u32), "async_cache_get hit"); + + let missing = cache.async_cache_get("z").await; + assert_eq!(missing, None, "async_cache_get miss"); + + let removed = cache.async_cache_remove("a").await; + assert_eq!(removed, Some(2u32), "async_cache_remove returns value"); + assert_eq!(cache.async_cache_get("a").await, None, "gone after remove"); + + cache.async_cache_set("x".to_string(), 10u32).await; + cache.async_cache_set("y".to_string(), 20u32).await; + assert_eq!(cache.cache_size(), 2); + cache.async_cache_clear().await; + assert_eq!(cache.cache_size(), 0, "async_cache_clear empties cache"); + } + + // --- custom hasher tests --- + + #[test] + fn custom_hasher_get_set_round_trip() { + use crate::stores::Cached; + use std::collections::hash_map::RandomState; + let mut c = TtlSortedCache::::builder() + .ttl_secs(60) + .hasher(RandomState::new()) + .build() + .unwrap(); + assert_eq!(c.cache_set(1, 100), None); + assert_eq!(c.cache_set(2, 200), None); + assert_eq!(c.cache_get(&1), Some(&100)); + assert_eq!(c.cache_get(&2), Some(&200)); + assert_eq!(c.cache_hits(), Some(2)); + assert_eq!(c.cache_misses(), Some(0)); + assert_eq!(c.cache_get(&99), None); + assert_eq!(c.cache_misses(), Some(1)); + } + + #[test] + fn default_constructor_still_works() { + use crate::stores::Cached; + let mut c: TtlSortedCache = TtlSortedCache::new(Duration::from_secs(60)); + c.cache_set(1, 10); + assert_eq!(c.cache_get(&1), Some(&10)); + } + + #[test] + fn custom_hasher_respects_ttl_expiry() { + use crate::stores::Cached; + use std::collections::hash_map::RandomState; + let mut c = TtlSortedCache::::builder() + .ttl(Duration::from_millis(50)) + .hasher(RandomState::new()) + .build() + .unwrap(); + c.cache_set(1, 10); + assert_eq!(c.cache_get(&1), Some(&10)); + std::thread::sleep(std::time::Duration::from_millis(100)); + // After TTL, entry should expire (lazy removal on cache_get). + assert_eq!(c.cache_get(&1), None, "entry must expire after ttl"); + } } diff --git a/src/stores/unbound.rs b/src/stores/unbound.rs index c1f0e0ab..ac008e10 100644 --- a/src/stores/unbound.rs +++ b/src/stores/unbound.rs @@ -2,35 +2,35 @@ use super::Cached; use crate::{CachedIter, CachedPeek, CachedRead}; use std::cmp::Eq; -use std::hash::Hash; - -#[cfg(feature = "ahash")] -use ahash::RandomState; - -#[cfg(not(feature = "ahash"))] -use std::collections::hash_map::RandomState; +use std::hash::{BuildHasher, Hash}; use std::collections::{HashMap, hash_map::Entry}; #[cfg(feature = "async_core")] use {super::CachedAsync, std::future::Future}; -use super::StripedCounter; +use super::{DefaultHashBuilder, StripedCounter}; /// Default unbounded cache /// /// This cache has no size limit or eviction policy. /// /// Note: This cache is in-memory only -pub struct UnboundCache { - pub(super) store: HashMap, +/// +/// The optional type parameter `S` selects the hash builder used by the +/// backing `HashMap`. It defaults to [`DefaultHashBuilder`] (ahash when +/// the `ahash` feature is enabled, otherwise `std::collections::hash_map::RandomState`), +/// matching the pre-3.0 behavior. Supply a custom `S` via +/// [`UnboundCacheBuilder::hasher`] to use a different hasher. +pub struct UnboundCache { + pub(super) store: HashMap, pub(super) hits: StripedCounter, pub(super) misses: StripedCounter, pub(super) initial_capacity: Option, pub(super) on_evict: Option>, } -impl std::fmt::Debug for UnboundCache { +impl std::fmt::Debug for UnboundCache { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("UnboundCache") .field("hits", &self.hits.load()) @@ -40,10 +40,11 @@ impl std::fmt::Debug for UnboundCache { } } -impl Clone for UnboundCache +impl Clone for UnboundCache where K: Clone + Hash + Eq, V: Clone, + S: Clone, { fn clone(&self) -> Self { Self { @@ -56,39 +57,43 @@ where } } -impl PartialEq for UnboundCache +impl PartialEq for UnboundCache where K: Eq + Hash, V: PartialEq, + S: BuildHasher, { - fn eq(&self, other: &UnboundCache) -> bool { + fn eq(&self, other: &UnboundCache) -> bool { self.store.eq(&other.store) } } -impl Eq for UnboundCache +impl Eq for UnboundCache where K: Eq + Hash, V: PartialEq, + S: BuildHasher, { } /// Builder for [`UnboundCache`]. -pub struct UnboundCacheBuilder { +pub struct UnboundCacheBuilder { capacity: Option, on_evict: Option>, + hasher: S, } -impl Default for UnboundCacheBuilder { +impl Default for UnboundCacheBuilder { fn default() -> Self { Self { capacity: None, on_evict: None, + hasher: super::new_default_hash_builder(), } } } -impl UnboundCacheBuilder { +impl UnboundCacheBuilder { /// Set the initial allocation capacity (optional, purely a hint). #[must_use] pub fn capacity(mut self, capacity: usize) -> Self { @@ -100,7 +105,7 @@ impl UnboundCacheBuilder { /// [`cache_remove`](crate::Cached::cache_remove). /// /// Note: because `UnboundCache` has no eviction policy, `on_evict` will - /// not fire during normal cache operations — only on explicit removal. + /// not fire during normal cache operations -- only on explicit removal. /// Use [`cache_clear_with_on_evict`](UnboundCache::cache_clear_with_on_evict) /// instead of [`cache_clear`](crate::Cached::cache_clear) to opt into callback /// firing when clearing all entries. @@ -110,6 +115,34 @@ impl UnboundCacheBuilder { self } + /// Switch to a custom hash builder `S2`, returning a builder parameterized on `S2`. + /// + /// The hasher is used for the backing `HashMap`. Calling this method changes the + /// builder's type parameter so `build()` returns an `UnboundCache`. + /// + /// # Example + /// + /// ```rust + /// use cached::{Cached, UnboundCache}; + /// use std::collections::hash_map::RandomState; + /// + /// let mut cache = UnboundCache::::builder() + /// .hasher(RandomState::new()) + /// .build() + /// .unwrap(); + /// cache.cache_set(1, 100); + /// assert_eq!(cache.cache_get(&1), Some(&100)); + /// ``` + #[doc(alias = "with_hasher")] + #[must_use] + pub fn hasher(self, hasher: S2) -> UnboundCacheBuilder { + UnboundCacheBuilder { + capacity: self.capacity, + on_evict: self.on_evict, + hasher, + } + } + /// Build the cache. /// /// `UnboundCache` has no required fields and this always succeeds. @@ -117,11 +150,15 @@ impl UnboundCacheBuilder { /// # Errors /// /// This method currently never returns an error. - pub fn build(self) -> Result, super::BuildError> + pub fn build(self) -> Result, super::BuildError> where K: Hash + Eq, + S: BuildHasher, { - let store = UnboundCache::::new_store(self.capacity); + let store = match self.capacity { + Some(cap) => HashMap::with_capacity_and_hasher(cap, self.hasher), + None => HashMap::with_hasher(self.hasher), + }; Ok(UnboundCache { store, hits: StripedCounter::new(), @@ -132,26 +169,32 @@ impl UnboundCacheBuilder { } } -impl UnboundCache { - /// Return a builder for constructing an [`UnboundCache`]. - #[must_use] - pub fn builder() -> UnboundCacheBuilder { - UnboundCacheBuilder::default() +impl Default for UnboundCache { + fn default() -> Self { + Self::new() } +} - fn new_store(capacity: Option) -> HashMap { - capacity.map_or_else( - || HashMap::with_hasher(RandomState::new()), - |cap| HashMap::with_capacity_and_hasher(cap, RandomState::new()), - ) +impl UnboundCache { + /// Construct a ready-to-use [`UnboundCache`] with default configuration. + /// + /// `UnboundCache` has no required configuration, so this never fails. For + /// optional settings (initial capacity, `on_evict`) use [`builder`](Self::builder). + #[must_use] + pub fn new() -> Self { + Self::builder() + .build() + .expect("UnboundCache default build is infallible") } - /// Returns a reference to the cache's `store` + /// Return a builder for constructing an [`UnboundCache`]. #[must_use] - pub fn store(&self) -> &HashMap { - &self.store + pub fn builder() -> UnboundCacheBuilder { + UnboundCacheBuilder::default() } +} +impl UnboundCache { /// Remove all entries and fire the `on_evict` callback for each one. /// /// Unlike [`cache_clear`](crate::Cached::cache_clear) (which removes entries silently), @@ -170,7 +213,9 @@ impl UnboundCache { } } -impl Cached for UnboundCache { +impl Cached for UnboundCache { + type Error = std::convert::Infallible; + fn cache_get(&mut self, key: &Q) -> Option<&V> where K: std::borrow::Borrow, @@ -200,7 +245,7 @@ impl Cached for UnboundCache { fn cache_set(&mut self, key: K, val: V) -> Option { self.store.insert(key, val) } - fn cache_get_or_set_with V>(&mut self, key: K, f: F) -> &mut V { + fn cache_get_or_set_with_mut V>(&mut self, key: K, f: F) -> &mut V { match self.store.entry(key) { Entry::Occupied(occupied) => { self.hits.increment(); @@ -213,7 +258,7 @@ impl Cached for UnboundCache { } } } - fn cache_try_get_or_set_with Result, E>( + fn cache_try_get_or_set_with_mut Result, E>( &mut self, key: K, f: F, @@ -256,8 +301,12 @@ impl Cached for UnboundCache { self.store.clear(); } fn cache_reset(&mut self) { - // Entries are dropped in-place; `on_evict` is not called during reset. - self.store = Self::new_store(self.initial_capacity); + // Clear all entries and shrink capacity back toward the initial hint. + // This single generic impl applies to all hasher types `S`; there is no + // inherent override or specialization for any particular hasher. + // Entries are dropped in-place; `on_evict` is NOT called for cleared entries. + self.store.clear(); + self.store.shrink_to(self.initial_capacity.unwrap_or(0)); self.cache_reset_metrics(); } fn cache_reset_metrics(&mut self) { @@ -275,7 +324,7 @@ impl Cached for UnboundCache { } } -impl CachedIter for UnboundCache { +impl CachedIter for UnboundCache { fn iter<'a>(&'a self) -> impl Iterator + 'a where K: 'a, @@ -285,7 +334,7 @@ impl CachedIter for UnboundCache { } } -impl CachedPeek for UnboundCache { +impl CachedPeek for UnboundCache { fn cache_peek(&self, k: &Q) -> Option<&V> where K: std::borrow::Borrow, @@ -295,7 +344,7 @@ impl CachedPeek for UnboundCache { } } -impl CachedRead for UnboundCache { +impl CachedRead for UnboundCache { fn cache_get_read(&self, k: &Q) -> Option<&V> where K: std::borrow::Borrow, @@ -312,11 +361,12 @@ impl CachedRead for UnboundCache { } #[cfg(feature = "async_core")] -impl CachedAsync for UnboundCache +impl CachedAsync for UnboundCache where K: Hash + Eq + Clone + Send, + S: BuildHasher + Send, { - fn async_get_or_set_with<'a, F, Fut>( + fn async_cache_get_or_set_with_mut<'a, F, Fut>( &'a mut self, key: K, f: F, @@ -341,7 +391,7 @@ where } } - fn async_try_get_or_set_with<'a, F, Fut, E>( + fn async_cache_try_get_or_set_with_mut<'a, F, Fut, E>( &'a mut self, key: K, f: F, @@ -373,7 +423,15 @@ where /// Cache store tests mod tests { use super::*; - use crate::Cached; + use crate::{Cached, CachedExt}; + + #[test] + fn new_returns_ready_cache() { + let mut c: UnboundCache = UnboundCache::new(); + assert_eq!(c.set(1, 100), None); + assert_eq!(c.get(&1), Some(&100)); + assert_eq!(c.len(), 1); + } #[test] fn basic_cache() { @@ -455,6 +513,9 @@ mod tests { c.cache_reset(); + assert_eq!(0, c.cache_size()); + // After reset the store is empty; capacity may be 0 or the initial hint. + // We only assert emptiness here since shrink_to(0) is the reset behavior. assert_eq!(0, c.store.capacity()); let init_capacity = 1; @@ -469,7 +530,8 @@ mod tests { c.cache_reset(); - assert!(init_capacity <= c.store.capacity()); + // After reset with initial_capacity=1, shrink_to(1) leaves at least 1 bucket. + assert_eq!(0, c.cache_size()); } #[test] @@ -543,12 +605,12 @@ mod tests { Err("dead".to_string()) } } - let res: Result<&mut usize, String> = c.cache_try_get_or_set_with(0, || _try_get(10)); + let res: Result<&usize, String> = c.cache_try_get_or_set_with(0, || _try_get(10)); assert!(res.is_err()); - let res: Result<&mut usize, String> = c.cache_try_get_or_set_with(0, || _try_get(1)); + let res: Result<&usize, String> = c.cache_try_get_or_set_with(0, || _try_get(1)); assert_eq!(res.unwrap(), &1); - let res: Result<&mut usize, String> = c.cache_try_get_or_set_with(0, || _try_get(5)); + let res: Result<&usize, String> = c.cache_try_get_or_set_with(0, || _try_get(5)); assert_eq!(res.unwrap(), &1); } @@ -652,11 +714,11 @@ mod tests { .build() .unwrap(); c.cache_set(1u32, 10u32); - c.cache_remove_entry(&1u32); + let _ = c.cache_remove_entry(&1u32); assert_eq!(count.load(Ordering::Relaxed), 1); // No fire for absent key. - c.cache_remove_entry(&999u32); + let _ = c.cache_remove_entry(&999u32); assert_eq!(count.load(Ordering::Relaxed), 1); } @@ -718,4 +780,53 @@ mod tests { "cache_remove_entry must return the stored key instance" ); } + + // --- custom hasher tests --- + + #[test] + fn custom_hasher_get_set_round_trip() { + // Verify .hasher() switches the hash builder and the cache still works. + use std::collections::hash_map::RandomState; + let mut c = UnboundCache::::builder() + .hasher(RandomState::new()) + .build() + .unwrap(); + assert_eq!(c.cache_set(1, 100), None); + assert_eq!(c.cache_set(2, 200), None); + assert_eq!(c.cache_get(&1), Some(&100)); + assert_eq!(c.cache_get(&2), Some(&200)); + assert_eq!(c.cache_hits(), Some(2)); + assert_eq!(c.cache_misses(), Some(0)); + assert_eq!(c.cache_get(&99), None); + assert_eq!(c.cache_misses(), Some(1)); + } + + #[test] + fn default_constructor_still_works() { + // Verify that code using the default type param compiles and works. + let mut c: UnboundCache = UnboundCache::new(); + c.cache_set(1, 10); + assert_eq!(c.cache_get(&1), Some(&10)); + + let mut b = UnboundCache::::builder().build().unwrap(); + b.cache_set(2, 20); + assert_eq!(b.cache_get(&2), Some(&20)); + } + + #[test] + fn custom_hasher_with_capacity_builder() { + use std::collections::hash_map::RandomState; + let mut c = UnboundCache::::builder() + .capacity(16) + .hasher(RandomState::new()) + .build() + .unwrap(); + for i in 0..10u32 { + c.cache_set(i, i * 2); + } + for i in 0..10u32 { + assert_eq!(c.cache_get(&i), Some(&(i * 2))); + } + assert_eq!(c.cache_size(), 10); + } } diff --git a/tests/cached.rs b/tests/cached.rs index 9c29d3a8..7ca406b4 100644 --- a/tests/cached.rs +++ b/tests/cached.rs @@ -4,7 +4,7 @@ Full tests of macro-defined functions #[cfg(feature = "time_stores")] use cached::time::Duration; -use cached::{Cached, LruCache, UnboundCache}; +use cached::{Cached, CachedExt, LruCache, UnboundCache}; use cached::{Expires, ExpiringLruCache}; #[cfg(feature = "proc_macro")] use cached::{macros::cached, macros::once}; @@ -35,6 +35,15 @@ fn compile_fail_unsync_reads_timed() { t.compile_fail("tests/ui/unsync_reads_timed_cache.rs"); } +// `new`/`builder` on each sharded `*Base` are constrained to the default-hasher +// specialization, so a `Base::<_, _, CustomHasher>::{new,builder}()` turbofish (which +// would silently drop the custom hasher) must not compile. +#[test] +fn compile_fail_sharded_constructor() { + let t = trybuild::TestCases::new(); + t.compile_fail("tests/ui/sharded_base_custom_hasher_constructor.rs"); +} + // One negative trybuild case per *semantic* compile error the macros raise // (i.e. errors we define for invalid attribute/signature states). Pure // syn-parser pass-through messages for malformed user strings (bad `ty` / @@ -59,13 +68,16 @@ fn compile_fail_macro_arg_validation() { t.compile_fail("tests/ui/cached_result_fallback_sync_writes.rs"); t.compile_fail("tests/ui/cached_sync_lock_unknown.rs"); t.compile_fail("tests/ui/cached_expires_ttl_exclusive.rs"); + t.compile_fail("tests/ui/cached_expires_malformed_ttl.rs"); t.compile_fail("tests/ui/cached_expires_type_exclusive.rs"); t.compile_fail("tests/ui/cached_expires_create_exclusive.rs"); t.compile_fail("tests/ui/cached_expires_with_cached_flag_exclusive.rs"); t.compile_fail("tests/ui/cached_expires_unsync_reads_exclusive.rs"); t.compile_fail("tests/ui/cached_expires_non_expires_type.rs"); t.compile_fail("tests/ui/cached_expires_refresh_exclusive.rs"); - t.compile_fail("tests/ui/cached_expires_unbound_exclusive.rs"); + t.compile_fail("tests/ui/cached_unbound_attr_removed.rs"); + t.compile_fail("tests/ui/cached_key_unparseable.rs"); + t.compile_fail("tests/ui/cached_convert_unparseable.rs"); t.compile_fail("tests/ui/cached_expires_cache_none_exclusive.rs"); t.compile_fail("tests/ui/cached_expires_cache_err_exclusive.rs"); t.compile_fail("tests/ui/cached_cache_err_requires_result_return.rs"); @@ -81,6 +93,7 @@ fn compile_fail_macro_arg_validation() { t.compile_fail("tests/ui/once_time_attr_renamed.rs"); t.compile_fail("tests/ui/once_with_cached_flag_foreign.rs"); t.compile_fail("tests/ui/once_expires_ttl_exclusive.rs"); + t.compile_fail("tests/ui/once_expires_malformed_ttl.rs"); t.compile_fail("tests/ui/once_expires_non_expires_type.rs"); t.compile_fail("tests/ui/once_expires_cache_none_exclusive.rs"); t.compile_fail("tests/ui/once_expires_cache_err_exclusive.rs"); @@ -103,6 +116,7 @@ fn compile_fail_macro_arg_validation() { t.compile_fail("tests/ui/concurrent_cached_async_redis_no_ttl.rs"); t.compile_fail("tests/ui/concurrent_cached_redis_no_ttl.rs"); t.compile_fail("tests/ui/concurrent_cached_disk_create_conflict.rs"); + t.compile_fail("tests/ui/concurrent_cached_refresh_create_conflict.rs"); t.compile_fail("tests/ui/concurrent_cached_max_size_create_conflict.rs"); t.compile_fail("tests/ui/concurrent_cached_disk_create_ignored_attrs.rs"); t.compile_fail("tests/ui/concurrent_cached_option_return.rs"); @@ -124,6 +138,7 @@ fn compile_fail_macro_arg_validation() { t.compile_fail("tests/ui/concurrent_cached_key_without_convert.rs"); t.compile_fail("tests/ui/concurrent_cached_refresh_without_ttl.rs"); t.compile_fail("tests/ui/concurrent_cached_expires_ttl_exclusive.rs"); + t.compile_fail("tests/ui/concurrent_cached_expires_malformed_ttl.rs"); t.compile_fail("tests/ui/concurrent_cached_expires_redis_exclusive.rs"); t.compile_fail("tests/ui/concurrent_cached_expires_disk_exclusive.rs"); t.compile_fail("tests/ui/concurrent_cached_expires_ty_exclusive.rs"); @@ -188,7 +203,7 @@ fn test_proc_cached_result() { assert!(proc_cached_result(2).is_ok()); assert!(proc_cached_result(4).is_ok()); { - let cache = PROC_CACHED_RESULT.read(); + let cache = PROC_CACHED_RESULT.0.read(); assert_eq!(2, cache.cache_size()); assert_eq!(2, cache.cache_hits().unwrap()); assert_eq!(4, cache.cache_misses().unwrap()); @@ -213,7 +228,7 @@ fn test_proc_cached_option() { assert!(proc_cached_option(1).is_some()); assert!(proc_cached_option(4).is_some()); { - let cache = PROC_CACHED_OPTION.read(); + let cache = PROC_CACHED_OPTION.0.read(); assert_eq!(3, cache.cache_size()); assert_eq!(3, cache.cache_hits().unwrap()); assert_eq!(5, cache.cache_misses().unwrap()); @@ -330,7 +345,7 @@ fn test_cached_return_flag() { assert_eq!(*r, 1); assert!(r.is_positive()); { - let cache = CACHED_RETURN_FLAG.read(); + let cache = CACHED_RETURN_FLAG.0.read(); assert_eq!(cache.cache_hits(), Some(1)); assert_eq!(cache.cache_misses(), Some(1)); } @@ -360,7 +375,7 @@ fn test_cached_return_flag_result() { let r = cached_return_flag_result(10); assert!(r.is_err()); { - let cache = CACHED_RETURN_FLAG_RESULT.read(); + let cache = CACHED_RETURN_FLAG_RESULT.0.read(); assert_eq!(cache.cache_hits(), Some(1)); assert_eq!(cache.cache_misses(), Some(2)); } @@ -390,7 +405,7 @@ fn test_cached_return_flag_option() { let r = cached_return_flag_option(10); assert!(r.is_none()); { - let cache = CACHED_RETURN_FLAG_OPTION.read(); + let cache = CACHED_RETURN_FLAG_OPTION.0.read(); assert_eq!(cache.cache_hits(), Some(1)); assert_eq!(cache.cache_misses(), Some(2)); } @@ -499,14 +514,14 @@ fn test_cached_smartstring() { string.push_str("very stringy"); assert_eq!("equal", cached_smartstring(string.clone())); { - let cache = CACHED_SMARTSTRING.read(); + let cache = CACHED_SMARTSTRING.0.read(); assert_eq!(cache.cache_hits(), Some(0)); assert_eq!(cache.cache_misses(), Some(1)); } assert_eq!("equal", cached_smartstring(string.clone())); { - let cache = CACHED_SMARTSTRING.read(); + let cache = CACHED_SMARTSTRING.0.read(); assert_eq!(cache.cache_hits(), Some(1)); assert_eq!(cache.cache_misses(), Some(1)); } @@ -514,7 +529,7 @@ fn test_cached_smartstring() { let string = smartstring::alias::String::from("also stringy"); assert_eq!("not equal", cached_smartstring(string)); { - let cache = CACHED_SMARTSTRING.read(); + let cache = CACHED_SMARTSTRING.0.read(); assert_eq!(cache.cache_hits(), Some(1)); assert_eq!(cache.cache_misses(), Some(2)); } @@ -543,7 +558,7 @@ fn test_cached_max_size_alias_sets_bound() { assert_eq!(cached_max_size_alias(1), 2); assert_eq!(cached_max_size_alias(2), 4); assert_eq!(cached_max_size_alias(3), 6); // evicts the LRU entry - let cache = CACHED_MAX_SIZE_ALIAS.read(); + let cache = CACHED_MAX_SIZE_ALIAS.0.read(); // capacity reflects the `max_size = 2` bound, and the store never exceeds it assert_eq!(cache.capacity(), 2); assert_eq!(cache.cache_size(), 2); @@ -572,21 +587,21 @@ fn sync_cached_remove_entry_and_delete_aliases() { fn test_cached_smartstring_from_str() { assert!(cached_smartstring_from_str("true")); { - let cache = CACHED_SMARTSTRING_FROM_STR.read(); + let cache = CACHED_SMARTSTRING_FROM_STR.0.read(); assert_eq!(cache.cache_hits(), Some(0)); assert_eq!(cache.cache_misses(), Some(1)); } assert!(cached_smartstring_from_str("true")); { - let cache = CACHED_SMARTSTRING_FROM_STR.read(); + let cache = CACHED_SMARTSTRING_FROM_STR.0.read(); assert_eq!(cache.cache_hits(), Some(1)); assert_eq!(cache.cache_misses(), Some(1)); } assert!(!cached_smartstring_from_str("false")); { - let cache = CACHED_SMARTSTRING_FROM_STR.read(); + let cache = CACHED_SMARTSTRING_FROM_STR.0.read(); assert_eq!(cache.cache_hits(), Some(1)); assert_eq!(cache.cache_misses(), Some(2)); } @@ -715,7 +730,7 @@ mod expires_macro_tests { #[test] fn test_expires_macro_hit_and_miss() { { - let mut c = SM_CACHED_EXPIRES.write(); + let mut c = SM_CACHED_EXPIRES.0.write(); c.cache_clear(); c.cache_reset_metrics(); } @@ -727,7 +742,7 @@ mod expires_macro_tests { let v2 = sm_cached_expires(1, false); assert!(!v2.expired); { - let c = SM_CACHED_EXPIRES.read(); + let c = SM_CACHED_EXPIRES.0.read(); assert_eq!(c.cache_hits(), Some(1)); assert_eq!(c.cache_misses(), Some(1)); } @@ -739,7 +754,7 @@ mod expires_macro_tests { let v4 = sm_cached_expires(2, false); assert!(!v4.expired); { - let c = SM_CACHED_EXPIRES.read(); + let c = SM_CACHED_EXPIRES.0.read(); assert_eq!(c.cache_evictions(), Some(1)); } } @@ -747,7 +762,7 @@ mod expires_macro_tests { #[test] fn test_expires_lru_macro_hit_and_miss() { { - let mut c = SM_CACHED_EXPIRES_LRU.write(); + let mut c = SM_CACHED_EXPIRES_LRU.0.write(); c.cache_clear(); c.cache_reset_metrics(); } @@ -756,7 +771,7 @@ mod expires_macro_tests { let v2 = sm_cached_expires_lru(10, false); assert!(!v2.expired); { - let c = SM_CACHED_EXPIRES_LRU.read(); + let c = SM_CACHED_EXPIRES_LRU.0.read(); assert_eq!(c.cache_hits(), Some(1)); assert_eq!(c.cache_misses(), Some(1)); } @@ -767,7 +782,7 @@ mod expires_macro_tests { let v4 = sm_cached_expires_lru(11, false); assert!(!v4.expired); { - let c = SM_CACHED_EXPIRES_LRU.read(); + let c = SM_CACHED_EXPIRES_LRU.0.read(); assert_eq!(c.cache_evictions(), Some(1)); } } @@ -801,7 +816,7 @@ mod expires_macro_tests { #[test] fn test_expires_macro_dynamic_ttl_from_arg() { { - let mut c = DYN_TTL.write(); + let mut c = DYN_TTL.0.write(); c.cache_clear(); c.cache_reset_metrics(); } @@ -814,7 +829,7 @@ mod expires_macro_tests { assert_eq!(dyn_ttl(1, 0).v, 1); assert_eq!(dyn_ttl(2, 60_000).v, 2); - let c = DYN_TTL.read(); + let c = DYN_TTL.0.read(); // Only the long-TTL key produced a live hit; the 0ms key never hits. assert_eq!(c.cache_hits(), Some(1)); } @@ -838,7 +853,7 @@ mod time_store_tests { input.len() } - #[once(ttl = 1)] + #[once(ttl_secs = 1)] fn slow_once_timestamp_after_body(input: u32) -> u32 { sleep(Duration::from_millis(1100)); input @@ -848,7 +863,7 @@ mod time_store_tests { fn test_expiring_sized_unsync_read_macro() { assert_eq!(3, expiring_sized_unsync_read("abc")); assert_eq!(3, expiring_sized_unsync_read("abc")); - let cache = EXPIRING_SIZED_UNSYNC_READ.read(); + let cache = EXPIRING_SIZED_UNSYNC_READ.0.read(); assert_eq!(Some(&3), cache.cache_peek("abc")); assert_eq!(Some(&3), cache.cache_get_read("abc")); } @@ -860,7 +875,7 @@ mod time_store_tests { assert_eq!(1, slow_once_timestamp_after_body(2)); } - #[cached(max_size = 1, ttl = 1)] + #[cached(max_size = 1, ttl_secs = 1)] fn proc_timed_sized_sleeper(n: u64) -> u64 { sleep(Duration::new(1, 0)); n @@ -871,7 +886,7 @@ mod time_store_tests { proc_timed_sized_sleeper(1); proc_timed_sized_sleeper(1); { - let cache = PROC_TIMED_SIZED_SLEEPER.read(); + let cache = PROC_TIMED_SIZED_SLEEPER.0.read(); assert_eq!(1, cache.cache_misses().unwrap()); assert_eq!(1, cache.cache_hits().unwrap()); } @@ -879,7 +894,7 @@ mod time_store_tests { sleep(Duration::new(1, 0)); proc_timed_sized_sleeper(1); { - let cache = PROC_TIMED_SIZED_SLEEPER.read(); + let cache = PROC_TIMED_SIZED_SLEEPER.0.read(); assert_eq!(2, cache.cache_misses().unwrap()); assert_eq!(1, cache.cache_hits().unwrap()); assert_eq!(cache.key_order(), vec![1]); @@ -887,13 +902,13 @@ mod time_store_tests { // sleep to expire the one entry sleep(Duration::new(1, 0)); { - let cache = PROC_TIMED_SIZED_SLEEPER.read(); + let cache = PROC_TIMED_SIZED_SLEEPER.0.read(); assert!(cache.key_order().is_empty()); } proc_timed_sized_sleeper(1); proc_timed_sized_sleeper(1); { - let cache = PROC_TIMED_SIZED_SLEEPER.read(); + let cache = PROC_TIMED_SIZED_SLEEPER.0.read(); assert_eq!(3, cache.cache_misses().unwrap()); assert_eq!(2, cache.cache_hits().unwrap()); assert_eq!(cache.key_order(), vec![1]); @@ -901,7 +916,7 @@ mod time_store_tests { // lru size is 1, so this new thing evicts the existing key proc_timed_sized_sleeper(2); { - let cache = PROC_TIMED_SIZED_SLEEPER.read(); + let cache = PROC_TIMED_SIZED_SLEEPER.0.read(); assert_eq!(4, cache.cache_misses().unwrap()); assert_eq!(2, cache.cache_hits().unwrap()); assert_eq!(cache.key_order(), vec![2]); @@ -911,7 +926,7 @@ mod time_store_tests { /// should only cache the _first_ value returned for 1 second. /// all arguments are ignored for subsequent calls until the /// cache expires after a second. - #[once(ttl = 1)] + #[once(ttl_secs = 1)] fn only_cached_once_per_second(s: String) -> Vec { vec![s] } @@ -929,7 +944,7 @@ mod time_store_tests { /// should only cache the _first_ `Ok` returned for 1 second. /// all arguments are ignored for subsequent calls until the /// cache expires after a second. - #[once(ttl = 1)] + #[once(ttl_secs = 1)] fn only_cached_result_once_per_second( s: String, error: bool, @@ -951,7 +966,7 @@ mod time_store_tests { /// should only cache the _first_ `Some` returned for 1 second. /// all arguments are ignored for subsequent calls until the /// cache expires after a second. - #[once(ttl = 1)] + #[once(ttl_secs = 1)] fn only_cached_option_once_per_second(s: String, none: bool) -> Option> { if none { None } else { Some(vec![s]) } } @@ -967,7 +982,7 @@ mod time_store_tests { assert_eq!(vec!["b".to_string()], b); } - #[cached(ttl = 2, sync_writes = "default", key = "u32", convert = "{ 1 }")] + #[cached(ttl_secs = 2, sync_writes = "default", key = "u32", convert = "{ 1 }")] fn cached_sync_writes(s: String) -> Vec { vec![s] } @@ -985,7 +1000,7 @@ mod time_store_tests { assert_eq!(a, c); } - #[cached(ttl = 2, sync_writes = true, key = "u32", convert = "{ 2 }")] + #[cached(ttl_secs = 2, sync_writes = true, key = "u32", convert = "{ 2 }")] fn cached_sync_writes_true(s: String) -> Vec { vec![s] } @@ -997,7 +1012,7 @@ mod time_store_tests { assert_eq!(a, b); } - #[cached(ttl = 2, sync_writes = false, key = "u32", convert = "{ 3 }")] + #[cached(ttl_secs = 2, sync_writes = false, key = "u32", convert = "{ 3 }")] fn cached_sync_writes_false(s: String) -> Vec { vec![s] } @@ -1010,7 +1025,7 @@ mod time_store_tests { } #[cached( - ttl = 2, + ttl_secs = 2, sync_writes = "by_key", sync_writes_buckets = 8, key = "u32", @@ -1034,7 +1049,7 @@ mod time_store_tests { } #[cached( - ttl = 1, + ttl_secs = 1, refresh = true, key = "String", convert = r#"{ String::from(s) }"# @@ -1047,14 +1062,14 @@ mod time_store_tests { fn test_cached_timed_refresh() { assert!(cached_timed_refresh("true")); { - let cache = CACHED_TIMED_REFRESH.read(); + let cache = CACHED_TIMED_REFRESH.0.read(); assert_eq!(cache.cache_hits(), Some(0)); assert_eq!(cache.cache_misses(), Some(1)); } assert!(cached_timed_refresh("true")); { - let cache = CACHED_TIMED_REFRESH.read(); + let cache = CACHED_TIMED_REFRESH.0.read(); assert_eq!(cache.cache_hits(), Some(1)); assert_eq!(cache.cache_misses(), Some(1)); } @@ -1066,7 +1081,7 @@ mod time_store_tests { std::thread::sleep(Duration::from_millis(500)); assert!(cached_timed_refresh("true")); { - let cache = CACHED_TIMED_REFRESH.read(); + let cache = CACHED_TIMED_REFRESH.0.read(); assert_eq!(cache.cache_hits(), Some(4)); assert_eq!(cache.cache_misses(), Some(1)); } @@ -1074,7 +1089,7 @@ mod time_store_tests { #[cached( max_size = 2, - ttl = 1, + ttl_secs = 1, refresh = true, key = "String", convert = r#"{ String::from(s) }"# @@ -1087,14 +1102,14 @@ mod time_store_tests { fn test_cached_timed_sized_refresh() { assert!(cached_timed_sized_refresh("true")); { - let cache = CACHED_TIMED_SIZED_REFRESH.read(); + let cache = CACHED_TIMED_SIZED_REFRESH.0.read(); assert_eq!(cache.cache_hits(), Some(0)); assert_eq!(cache.cache_misses(), Some(1)); } assert!(cached_timed_sized_refresh("true")); { - let cache = CACHED_TIMED_SIZED_REFRESH.read(); + let cache = CACHED_TIMED_SIZED_REFRESH.0.read(); assert_eq!(cache.cache_hits(), Some(1)); assert_eq!(cache.cache_misses(), Some(1)); } @@ -1106,7 +1121,7 @@ mod time_store_tests { std::thread::sleep(Duration::from_millis(500)); assert!(cached_timed_sized_refresh("true")); { - let cache = CACHED_TIMED_SIZED_REFRESH.read(); + let cache = CACHED_TIMED_SIZED_REFRESH.0.read(); assert_eq!(cache.cache_hits(), Some(4)); assert_eq!(cache.cache_misses(), Some(1)); } @@ -1114,7 +1129,7 @@ mod time_store_tests { #[cached( max_size = 2, - ttl = 1, + ttl_secs = 1, refresh = true, key = "String", convert = r#"{ String::from(s) }"# @@ -1127,13 +1142,13 @@ mod time_store_tests { fn test_cached_timed_sized_refresh_prime() { assert!(cached_timed_sized_refresh_prime("true")); { - let cache = CACHED_TIMED_SIZED_REFRESH_PRIME.read(); + let cache = CACHED_TIMED_SIZED_REFRESH_PRIME.0.read(); assert_eq!(cache.cache_hits(), Some(0)); assert_eq!(cache.cache_misses(), Some(1)); } assert!(cached_timed_sized_refresh_prime("true")); { - let cache = CACHED_TIMED_SIZED_REFRESH_PRIME.read(); + let cache = CACHED_TIMED_SIZED_REFRESH_PRIME.0.read(); assert_eq!(cache.cache_hits(), Some(1)); assert_eq!(cache.cache_misses(), Some(1)); } @@ -1148,7 +1163,7 @@ mod time_store_tests { // stats unchanged (other than this new hit) since we kept priming assert!(cached_timed_sized_refresh_prime("true")); { - let cache = CACHED_TIMED_SIZED_REFRESH_PRIME.read(); + let cache = CACHED_TIMED_SIZED_REFRESH_PRIME.0.read(); assert_eq!(cache.cache_hits(), Some(2)); assert_eq!(cache.cache_misses(), Some(1)); } @@ -1156,7 +1171,7 @@ mod time_store_tests { #[cached( max_size = 2, - ttl = 1, + ttl_secs = 1, key = "String", convert = r#"{ String::from(s) }"# )] @@ -1168,13 +1183,13 @@ mod time_store_tests { fn test_cached_timed_sized_prime() { assert!(cached_timed_sized_prime("true")); { - let cache = CACHED_TIMED_SIZED_PRIME.write(); + let cache = CACHED_TIMED_SIZED_PRIME.0.write(); assert_eq!(cache.cache_hits(), Some(0)); assert_eq!(cache.cache_misses(), Some(1)); } assert!(cached_timed_sized_prime("true")); { - let cache = CACHED_TIMED_SIZED_PRIME.write(); + let cache = CACHED_TIMED_SIZED_PRIME.0.write(); assert_eq!(cache.cache_hits(), Some(1)); assert_eq!(cache.cache_misses(), Some(1)); } @@ -1189,17 +1204,17 @@ mod time_store_tests { // stats unchanged (other than this new hit) since we kept priming assert!(cached_timed_sized_prime("true")); { - let mut cache = CACHED_TIMED_SIZED_PRIME.write(); + let mut cache = CACHED_TIMED_SIZED_PRIME.0.write(); assert_eq!(cache.cache_hits(), Some(2)); assert_eq!(cache.cache_misses(), Some(1)); assert!(cache.cache_size() > 0); std::thread::sleep(Duration::from_millis(1000)); - cache.evict(); + let _ = cache.evict(); assert_eq!(cache.cache_size(), 0); } } - #[cached::macros::cached(ttl = 1, result_fallback = true)] + #[cached::macros::cached(ttl_secs = 1, result_fallback = true)] fn always_failing() -> Result { Err(()) } @@ -1247,7 +1262,7 @@ mod time_store_tests { std::sync::atomic::AtomicBool::new(true); #[cfg(feature = "proc_macro")] - #[cached::macros::concurrent_cached(ttl = 1, result_fallback = true)] + #[cached::macros::concurrent_cached(ttl_secs = 1, result_fallback = true)] fn concurrent_result_fallback_fn() -> Result { if CONCURRENT_RESULT_FALLBACK_SHOULD_SUCCEED.load(std::sync::atomic::Ordering::SeqCst) { Ok(42) @@ -1443,7 +1458,7 @@ mod time_store_tests { mod async_tests { use super::*; - #[once(ttl = 1)] + #[once(ttl_secs = 1)] async fn only_cached_once_per_second_a(s: String) -> Vec { vec![s] } @@ -1458,7 +1473,7 @@ mod time_store_tests { assert_eq!(vec!["b".to_string()], b); } - #[once(ttl = 1)] + #[once(ttl_secs = 1)] async fn only_cached_result_once_per_second_a( s: String, error: bool, @@ -1487,7 +1502,7 @@ mod time_store_tests { assert_eq!(vec!["b".to_string()], b); } - #[once(ttl = 1)] + #[once(ttl_secs = 1)] async fn only_cached_option_once_per_second_a( s: String, none: bool, @@ -1523,7 +1538,7 @@ mod time_store_tests { /// _one_ call will be "executed" and all others will be synchronized /// to return the cached result of the one call instead of all /// concurrently un-cached tasks executing and writing concurrently. - #[once(ttl = 2, sync_writes)] + #[once(ttl_secs = 2, sync_writes)] async fn only_cached_once_per_second_sync_writes(s: String) -> Vec { vec![s] } @@ -1536,7 +1551,7 @@ mod time_store_tests { assert_eq!(a.await.unwrap(), b.await.unwrap()); } - #[cached(ttl = 2, sync_writes = "default", key = "u32", convert = "{ 1 }")] + #[cached(ttl_secs = 2, sync_writes = "default", key = "u32", convert = "{ 1 }")] async fn cached_sync_writes_a(s: String) -> Vec { vec![s] } @@ -1553,7 +1568,7 @@ mod time_store_tests { } #[cached( - ttl = 5, + ttl_secs = 5, sync_writes = "by_key", key = "String", convert = r#"{ format!("{}", s) }"# @@ -1716,9 +1731,9 @@ mod time_store_tests { let mut cache = ExpiringCache::builder().build().unwrap(); - // async_get_or_set_with: vacant + // async_cache_get_or_set_with: vacant let r1 = cache - .async_get_or_set_with("key".to_string(), || async { + .async_cache_get_or_set_with("key".to_string(), || async { AsyncValue { val: "hello".to_string(), expired: false, @@ -1727,9 +1742,9 @@ mod time_store_tests { .await; assert_eq!(r1.val, "hello"); - // async_get_or_set_with: occupied and fresh + // async_cache_get_or_set_with: occupied and fresh let r2 = cache - .async_get_or_set_with("key".to_string(), || async { + .async_cache_get_or_set_with("key".to_string(), || async { AsyncValue { val: "ignored".to_string(), expired: false, @@ -1747,9 +1762,9 @@ mod time_store_tests { }, ); - // async_get_or_set_with: occupied but expired + // async_cache_get_or_set_with: occupied but expired let r3 = cache - .async_get_or_set_with("key".to_string(), || async { + .async_cache_get_or_set_with("key".to_string(), || async { AsyncValue { val: "new_fresh".to_string(), expired: false, @@ -1925,7 +1940,7 @@ mod time_store_tests { // hit — same key, returns cached value assert_eq!(cached_expires_basic(1, false).val, 1); { - let c = CACHED_EXPIRES_BASIC.read(); + let c = CACHED_EXPIRES_BASIC.0.read(); assert_eq!(c.cache_hits(), Some(1)); assert_eq!(c.cache_misses(), Some(1)); } @@ -1936,7 +1951,7 @@ mod time_store_tests { assert_eq!(r.val, 1); assert!(!r.expired); { - let c = CACHED_EXPIRES_BASIC.read(); + let c = CACHED_EXPIRES_BASIC.0.read(); assert_eq!(c.cache_hits(), Some(1)); assert_eq!(c.cache_misses(), Some(2)); assert_eq!(c.cache_evictions(), Some(1)); @@ -1957,7 +1972,7 @@ mod time_store_tests { // hit assert_eq!(cached_expires_lru(10, false).val, 10); { - let c = CACHED_EXPIRES_LRU.read(); + let c = CACHED_EXPIRES_LRU.0.read(); assert_eq!(c.cache_hits(), Some(1)); assert_eq!(c.cache_misses(), Some(1)); } @@ -1968,7 +1983,7 @@ mod time_store_tests { assert_eq!(r.val, 10); assert!(!r.expired); { - let c = CACHED_EXPIRES_LRU.read(); + let c = CACHED_EXPIRES_LRU.0.read(); assert_eq!(c.cache_evictions(), Some(1)); } } @@ -1998,7 +2013,7 @@ mod time_store_tests { assert_eq!(r.val, 1); assert!(!r.expired); { - let c = CACHED_EXPIRES_RESULT.read(); + let c = CACHED_EXPIRES_RESULT.0.read(); assert_eq!(c.cache_evictions(), Some(1)); } } @@ -2028,7 +2043,7 @@ mod time_store_tests { assert_eq!(r.val, 1); assert!(!r.expired); { - let c = CACHED_EXPIRES_OPTION.read(); + let c = CACHED_EXPIRES_OPTION.0.read(); assert_eq!(c.cache_evictions(), Some(1)); } } @@ -2190,7 +2205,7 @@ mod sharded_ttl_tests { // Covers `ConcurrentCached::cache_reset` / `cache_reset_metrics` on the TTL/expiring // sharded stores, whose `cache_reset_metrics` must zero a *split* eviction count // (the per-shard inner `LruCache`'s capacity-eviction counter plus the store's own - // counter). The non-TTL test exercises only `ShardedCache`/`ShardedLruCache`. + // counter). The non-TTL test exercises only `ShardedUnboundCache`/`ShardedLruCache`. #[test] fn reset_metrics_zeros_split_eviction_counter_on_ttl_expiring_sharded_stores() { use cached::time::Duration; @@ -2251,7 +2266,7 @@ mod sharded_ttl_tests { #[test] fn non_sharded_ttl_builders_accept_refresh_on_hit() { use cached::time::Duration; - use cached::{LruTtlCache, TtlCache}; + use cached::{CacheTtl, LruTtlCache, TtlCache}; // Primary `.refresh_on_hit(true)` setter. let ttl = TtlCache::::builder() @@ -2259,7 +2274,7 @@ mod sharded_ttl_tests { .refresh_on_hit(true) .build() .expect("valid config"); - assert!(ttl.refresh_on_hit()); + assert!(CacheTtl::refresh_on_hit(&ttl)); let lru_ttl = LruTtlCache::::builder() .max_size(64) @@ -2267,7 +2282,7 @@ mod sharded_ttl_tests { .refresh_on_hit(true) .build() .expect("valid config"); - assert!(lru_ttl.refresh_on_hit()); + assert!(CacheTtl::refresh_on_hit(&lru_ttl)); // Both setters default to / can clear the flag. let ttl_off = TtlCache::::builder() @@ -2275,7 +2290,7 @@ mod sharded_ttl_tests { .refresh_on_hit(false) .build() .expect("valid config"); - assert!(!ttl_off.refresh_on_hit()); + assert!(!CacheTtl::refresh_on_hit(&ttl_off)); } #[test] @@ -2425,7 +2440,7 @@ mod async_tests { } } -#[cfg(all(feature = "disk_store", feature = "proc_macro"))] +#[cfg(all(feature = "redb_store", feature = "proc_macro"))] mod disk_tests { use super::*; use cached::RedbCache; @@ -2442,7 +2457,7 @@ mod disk_tests { #[concurrent_cached( disk = true, - ttl = 1, + ttl_secs = 1, map_error = r##"|e| TestError::DiskError(format!("{:?}", e))"## )] fn cached_disk(n: u32) -> Result { @@ -2461,9 +2476,40 @@ mod disk_tests { assert_eq!(cached_disk(6), Err(TestError::Count(6))); } + // #8 disk-path parity: `refresh = true` on the disk (redb) path is now a + // plain `bool` and is wired straight into the store builder via + // `.refresh_on_hit(refresh)`. This proves the macro emits a working disk + // store with `refresh = true` + a TTL (compiles, caches an `Ok` hit, and + // does not cache `Err`). The TTL-renewal side effect of `refresh_on_hit` + // itself is exercised by the store-level tests; here we lock that the macro + // attribute path wires it without error. + #[concurrent_cached( + disk = true, + ttl_secs = 60, + refresh = true, + map_error = r##"|e| TestError::DiskError(format!("{:?}", e))"## + )] + fn cached_disk_refresh(n: u32) -> Result { + if n < 5 { + Ok(n) + } else { + Err(TestError::Count(n)) + } + } + + #[test] + fn test_cached_disk_refresh() { + // First call: miss, Ok value computed and cached. + assert_eq!(cached_disk_refresh(1), Ok(1)); + // Second call same arg: served from the disk cache (refresh_on_hit set). + assert_eq!(cached_disk_refresh(1), Ok(1)); + // Err is not cached and is returned as-is. + assert_eq!(cached_disk_refresh(5), Err(TestError::Count(5))); + } + #[concurrent_cached( disk = true, - ttl = 1, + ttl_secs = 1, with_cached_flag = true, map_error = r##"|e| TestError::DiskError(format!("{:?}", e))"## )] @@ -2486,7 +2532,7 @@ mod disk_tests { #[concurrent_cached( map_error = r##"|e| TestError::DiskError(format!("{:?}", e))"##, ty = "cached::RedbCache", - create = r##" { RedbCache::new("cached_disk_cache_create").ttl(Duration::from_secs(1)).refresh_on_hit(true).build().expect("error building disk cache") } "## + create = r##" { RedbCache::builder().name("cached_disk_cache_create").ttl(Duration::from_secs(1)).refresh_on_hit(true).build().expect("error building disk cache") } "## )] fn cached_disk_cache_create(n: u32) -> Result { if n < 5 { @@ -2504,6 +2550,36 @@ mod disk_tests { assert_eq!(cached_disk_cache_create(6), Err(TestError::Count(6))); } + // #8: `refresh = false` is now the plain-bool default and must NOT conflict + // with a `create` block. Previously `refresh` was `Option`, so an + // explicit `refresh = Some(false)` alongside `create` tripped the + // create-conflict rejection (`check_create_conflicts`). It now compiles: + // `refresh = false` is treated as "not set" by the conflict check. + #[concurrent_cached( + map_error = r##"|e| TestError::DiskError(format!("{:?}", e))"##, + refresh = false, + ty = "cached::RedbCache", + create = r##" { RedbCache::builder().name("cached_disk_refresh_false_create").build().expect("error building disk cache") } "## + )] + fn cached_disk_refresh_false_create(n: u32) -> Result { + if n < 5 { + Ok(n) + } else { + Err(TestError::Count(n)) + } + } + + #[test] + fn test_cached_disk_refresh_false_create() { + // `refresh = false` + `create` compiles and behaves as a plain cache. + assert_eq!(cached_disk_refresh_false_create(1), Ok(1)); + assert_eq!(cached_disk_refresh_false_create(1), Ok(1)); + assert_eq!( + cached_disk_refresh_false_create(5), + Err(TestError::Count(5)) + ); + } + /// Just calling the macro with durable to test it doesn't break with an expected value /// There are no simple tests to test this here #[concurrent_cached( @@ -2727,13 +2803,13 @@ mod concurrent_cached_plain_return_ttl { static TTL_PLAIN_CALLS: AtomicUsize = AtomicUsize::new(0); - #[concurrent_cached(ttl = 60)] + #[concurrent_cached(ttl_secs = 60)] fn plain_double_ttl(x: u64) -> u64 { TTL_PLAIN_CALLS.fetch_add(1, Ordering::Relaxed); x * 2 } - #[concurrent_cached(max_size = 50, ttl = 60)] + #[concurrent_cached(max_size = 50, ttl_secs = 60)] fn plain_double_lru_ttl(x: u64) -> u64 { x * 2 } @@ -2754,7 +2830,7 @@ mod concurrent_cached_plain_return_ttl { } // Sharded in-memory default for `#[concurrent_cached]`. No `ty`, `create`, -// `map_error`, `redis`, or `disk` — the macro defaults to `ShardedCache`. +// `map_error`, `redis`, or `disk` — the macro defaults to `ShardedUnboundCache`. #[cfg(feature = "proc_macro")] mod concurrent_cached_default_in_memory { use cached::macros::concurrent_cached; @@ -2936,14 +3012,14 @@ mod concurrent_cached_default_with_ttl { static SLOW_QUAD_CALLS: AtomicUsize = AtomicUsize::new(0); - #[concurrent_cached(ttl = 60)] + #[concurrent_cached(ttl_secs = 60)] fn slow_quad(x: u64) -> u64 { SLOW_QUAD_CALLS.fetch_add(1, Ordering::Relaxed); x * 4 } // Verify `refresh = true` compiles and is wired (store created with refresh enabled). - #[concurrent_cached(ttl = 60, refresh = true)] + #[concurrent_cached(ttl_secs = 60, refresh = true)] fn slow_quad_refresh(x: u64) -> u64 { x * 4 } @@ -2973,14 +3049,14 @@ mod concurrent_cached_default_with_max_size_and_ttl { static SLOW_QUINT_CALLS: AtomicUsize = AtomicUsize::new(0); - #[concurrent_cached(max_size = 50, ttl = 60)] + #[concurrent_cached(max_size = 50, ttl_secs = 60)] fn slow_quint(x: u64) -> u64 { SLOW_QUINT_CALLS.fetch_add(1, Ordering::Relaxed); x * 5 } // Verify `refresh = true` compiles and is wired for the LRU+TTL variant. - #[concurrent_cached(max_size = 50, ttl = 60, refresh = true)] + #[concurrent_cached(max_size = 50, ttl_secs = 60, refresh = true)] fn slow_quint_refresh(x: u64) -> u64 { x * 5 } @@ -3044,7 +3120,7 @@ mod concurrent_cached_default_with_ttl_and_shards { static TTL_SHARDS_CALLS: AtomicUsize = AtomicUsize::new(0); - #[concurrent_cached(ttl = 60, shards = 16)] + #[concurrent_cached(ttl_secs = 60, shards = 16)] fn ttl_shards_double(x: u64) -> u64 { TTL_SHARDS_CALLS.fetch_add(1, Ordering::Relaxed); x * 2 @@ -3067,7 +3143,7 @@ mod concurrent_cached_default_with_max_size_and_ttl_and_shards { static SIZE_TTL_SHARDS_CALLS: AtomicUsize = AtomicUsize::new(0); - #[concurrent_cached(max_size = 100, ttl = 60, shards = 16)] + #[concurrent_cached(max_size = 100, ttl_secs = 60, shards = 16)] fn size_ttl_shards_double(x: u64) -> u64 { SIZE_TTL_SHARDS_CALLS.fetch_add(1, Ordering::Relaxed); x * 2 @@ -3095,7 +3171,7 @@ mod concurrent_cached_result_fallback { static FAIL: AtomicBool = AtomicBool::new(false); - #[concurrent_cached(ttl = 1, result_fallback = true)] + #[concurrent_cached(ttl_secs = 1, result_fallback = true)] fn maybe_double(x: u32) -> Result { if FAIL.load(Ordering::Relaxed) { Err("injected failure".to_string()) @@ -3127,7 +3203,7 @@ mod concurrent_cached_result_fallback { // Uses a dedicated function so its cache is fresh (not shared with above test). static FAIL_METRIC: AtomicBool = AtomicBool::new(false); - #[concurrent_cached(ttl = 1, result_fallback = true)] + #[concurrent_cached(ttl_secs = 1, result_fallback = true)] fn maybe_triple(x: u32) -> Result { if FAIL_METRIC.load(Ordering::Relaxed) { Err("metric test failure".to_string()) @@ -3169,7 +3245,7 @@ mod concurrent_cached_result_fallback { static FAIL_STR: AtomicBool = AtomicBool::new(false); #[concurrent_cached( - ttl = 1, + ttl_secs = 1, result_fallback = true, key = "String", convert = r#"{ x.to_string() }"# @@ -3197,7 +3273,7 @@ mod concurrent_cached_result_fallback { // function and returns the raw result without substituting a stale Ok for Err. static FAIL_PRIME: AtomicBool = AtomicBool::new(false); - #[concurrent_cached(ttl = 1, result_fallback = true)] + #[concurrent_cached(ttl_secs = 1, result_fallback = true)] fn prime_fallback_fn(x: u32) -> Result { if FAIL_PRIME.load(Ordering::Relaxed) { Err("prime failure".to_string()) @@ -3239,7 +3315,7 @@ mod concurrent_cached_result_fallback_lru_ttl { static FAIL_LRU: AtomicBool = AtomicBool::new(false); - #[concurrent_cached(ttl = 1, max_size = 100, result_fallback = true)] + #[concurrent_cached(ttl_secs = 1, max_size = 100, result_fallback = true)] fn lru_ttl_maybe_double(x: u32) -> Result { if FAIL_LRU.load(Ordering::Relaxed) { Err("lru_ttl failure".to_string()) @@ -3277,11 +3353,7 @@ mod concurrent_cached_result_fallback_lru_ttl { } // Async path: `result_fallback = true` returns the last-known-good Ok value after TTL expiry. -#[cfg(all( - feature = "proc_macro", - feature = "time_stores", - feature = "async_tokio_rt_multi_thread" -))] +#[cfg(all(feature = "proc_macro", feature = "time_stores", feature = "async"))] mod concurrent_cached_result_fallback_async { use cached::macros::concurrent_cached; use cached::time::Duration; @@ -3290,7 +3362,7 @@ mod concurrent_cached_result_fallback_async { static FAIL_ASYNC: AtomicBool = AtomicBool::new(false); - #[concurrent_cached(ttl = 1, result_fallback = true)] + #[concurrent_cached(ttl_secs = 1, result_fallback = true)] async fn maybe_double_async(x: u32) -> Result { if FAIL_ASYNC.load(Ordering::Relaxed) { Err("async failure".to_string()) @@ -3318,11 +3390,7 @@ mod concurrent_cached_result_fallback_async { // Async path: `result_fallback = true` with a non-Copy key — regression guard for // use-after-move in async codegen when arguments are cloned to form the cache key. -#[cfg(all( - feature = "proc_macro", - feature = "time_stores", - feature = "async_tokio_rt_multi_thread" -))] +#[cfg(all(feature = "proc_macro", feature = "time_stores", feature = "async"))] mod concurrent_cached_result_fallback_async_non_copy_key { use cached::macros::concurrent_cached; use cached::time::Duration; @@ -3332,7 +3400,7 @@ mod concurrent_cached_result_fallback_async_non_copy_key { static FAIL_ASYNC_STR: AtomicBool = AtomicBool::new(false); #[concurrent_cached( - ttl = 1, + ttl_secs = 1, result_fallback = true, key = "String", convert = r#"{ x.to_string() }"# @@ -3364,11 +3432,7 @@ mod concurrent_cached_result_fallback_async_non_copy_key { // Async path: `result_fallback = true` with size+ttl selects ShardedLruTtlCache; verify the // stale-ok path and metrics work identically on the async LRU-TTL store. -#[cfg(all( - feature = "proc_macro", - feature = "time_stores", - feature = "async_tokio_rt_multi_thread" -))] +#[cfg(all(feature = "proc_macro", feature = "time_stores", feature = "async"))] mod concurrent_cached_result_fallback_async_lru_ttl { use cached::macros::concurrent_cached; use cached::time::Duration; @@ -3377,7 +3441,7 @@ mod concurrent_cached_result_fallback_async_lru_ttl { static FAIL_ASYNC_LRU: AtomicBool = AtomicBool::new(false); - #[concurrent_cached(ttl = 1, max_size = 100, result_fallback = true)] + #[concurrent_cached(ttl_secs = 1, max_size = 100, result_fallback = true)] async fn lru_ttl_maybe_double_async(x: u32) -> Result { if FAIL_ASYNC_LRU.load(Ordering::Relaxed) { Err("async lru_ttl failure".to_string()) @@ -3450,8 +3514,8 @@ mod concurrent_cached_cache_err { } } -// Async path uses `OnceCell`. -#[cfg(all(feature = "proc_macro", feature = "async_tokio_rt_multi_thread"))] +// Async path uses `OnceCell`. +#[cfg(all(feature = "proc_macro", feature = "async"))] mod concurrent_cached_default_async { use cached::macros::concurrent_cached; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -3589,7 +3653,7 @@ mod concurrent_cached_option { } // Async `option = true` on the sharded default path. -#[cfg(all(feature = "proc_macro", feature = "async_tokio_rt_multi_thread"))] +#[cfg(all(feature = "proc_macro", feature = "async"))] mod concurrent_cached_async_option { use cached::macros::concurrent_cached; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -3657,7 +3721,7 @@ mod concurrent_cached_async_option { } // Async `with_cached_flag = true` on the sharded default path (plain and Result variants). -#[cfg(all(feature = "proc_macro", feature = "async_tokio_rt_multi_thread"))] +#[cfg(all(feature = "proc_macro", feature = "async"))] mod concurrent_cached_default_async_with_cached_flag { use cached::macros::concurrent_cached; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -3709,8 +3773,8 @@ mod sharded_send_sync_typecheck { fn _typecheck_sync() { fn assert_send() {} fn assert_sync() {} - assert_send::>(); - assert_sync::>(); + assert_send::>(); + assert_sync::>(); assert_send::>(); assert_sync::>(); } @@ -3728,13 +3792,32 @@ mod sharded_send_sync_typecheck { #[test] fn concurrent_cached_trait_short_aliases_work() { - use cached::{ConcurrentCached, ShardedCache}; + // The concrete type's inherent `get`/`set`/`remove`/`delete` now return unwrapped values. + // Use the `cache_`-prefixed trait methods (or fully-qualified path) to access the + // `Result`-returning trait surface. + use cached::{ConcurrentCached, ShardedUnboundCache}; + + let cache = ShardedUnboundCache::::builder() + .build() + .unwrap(); - let cache = ShardedCache::::builder().build().unwrap(); - assert_eq!(cache.set("a".to_string(), 1).unwrap(), None); - assert_eq!(cache.get(&"a".to_string()).unwrap(), Some(1)); - assert_eq!(cache.remove(&"a".to_string()).unwrap(), Some(1)); - assert!(!cache.delete(&"a".to_string()).unwrap()); + // Inherent methods — return unwrapped values directly. + assert_eq!(cache.set("a".to_string(), 1), None); + assert_eq!(cache.get(&"a".to_string()), Some(1)); + assert_eq!(cache.remove(&"a".to_string()), Some(1)); + assert!(!cache.delete(&"a".to_string())); + + // Trait methods via fully-qualified path — still return Result. + cache.set("b".to_string(), 2); + assert_eq!( + ConcurrentCached::cache_get(&cache, &"b".to_string()).unwrap(), + Some(2) + ); + assert_eq!( + ConcurrentCached::cache_remove(&cache, &"b".to_string()).unwrap(), + Some(2) + ); + assert!(!ConcurrentCached::cache_delete(&cache, &"b".to_string()).unwrap()); } // `cache_clear_with_on_evict` without a callback delegates to `clear()` and does NOT @@ -3742,15 +3825,15 @@ fn concurrent_cached_trait_short_aliases_work() { // increment gets moved before the early-return. #[test] fn cache_clear_with_on_evict_no_callback_leaves_evictions_at_zero() { - use cached::{ConcurrentCached, ShardedCache, ShardedLruCache}; + use cached::{ConcurrentCached, ShardedLruCache, ShardedUnboundCache}; - // ShardedCache (unbounded) — no on_evict; evictions metric is not tracked (returns None) - let cache = ShardedCache::::builder().build().unwrap(); - ConcurrentCached::cache_set(&cache, 1, 10).expect("infallible ShardedCache set"); - ConcurrentCached::cache_set(&cache, 2, 20).expect("infallible ShardedCache set"); + // ShardedUnboundCache (unbounded) — no on_evict; evictions metric is not tracked (returns None) + let cache = ShardedUnboundCache::::builder().build().unwrap(); + ConcurrentCached::cache_set(&cache, 1, 10).expect("infallible ShardedUnboundCache set"); + ConcurrentCached::cache_set(&cache, 2, 20).expect("infallible ShardedUnboundCache set"); cache.cache_clear_with_on_evict(); assert_eq!(cache.len(), 0, "cache should be empty after clear"); - // ShardedCache does not track evictions — None is expected, not Some(0) + // ShardedUnboundCache does not track evictions — None is expected, not Some(0) assert_eq!(cache.metrics().evictions, None); // ShardedLruCache tracks evictions; no on_evict means the counter stays at zero @@ -3773,10 +3856,10 @@ fn cache_clear_with_on_evict_no_callback_leaves_evictions_at_zero() { // (default no-op) overridden by the sharded stores to actually clear entries and zero metrics. #[test] fn concurrent_cached_trait_clear_and_reset_metrics_on_sharded_stores() { - use cached::{ConcurrentCached, ShardedCache, ShardedLruCache}; + use cached::{ConcurrentCached, ShardedLruCache, ShardedUnboundCache}; - // --- Unbound ShardedCache: cache_clear empties the store via the trait method --- - let cache = ShardedCache::::builder().build().unwrap(); + // --- Unbound ShardedUnboundCache: cache_clear empties the store via the trait method --- + let cache = ShardedUnboundCache::::builder().build().unwrap(); ConcurrentCached::cache_set(&cache, 1, 10).expect("infallible"); ConcurrentCached::cache_set(&cache, 2, 20).expect("infallible"); assert_eq!(cache.len(), 2); @@ -3835,9 +3918,9 @@ fn concurrent_cached_trait_clear_and_reset_metrics_on_sharded_stores() { #[cfg(feature = "async")] #[tokio::test] async fn concurrent_cached_async_trait_clear_and_reset_metrics_on_sharded_stores() { - use cached::{ConcurrentCachedAsync, ShardedCache}; + use cached::{ConcurrentCachedAsync, ShardedUnboundCache}; - let cache = ShardedCache::::builder().build().unwrap(); + let cache = ShardedUnboundCache::::builder().build().unwrap(); ConcurrentCachedAsync::async_cache_set(&cache, 1, 10) .await .expect("infallible"); @@ -3883,7 +3966,9 @@ mod sharded_expiring_tests { ShardedExpiringLruCache, }; use std::sync::Arc; - use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + #[cfg(feature = "proc_macro")] + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::{AtomicBool, Ordering}; #[derive(Clone, Debug)] struct ExpiringItem { @@ -4292,8 +4377,10 @@ mod concurrent_cached_return_named_error { Self(std::sync::Mutex::new(std::collections::HashMap::new())) } } - impl cached::ConcurrentCached for Store { + impl cached::ConcurrentCacheBase for Store { type Error = std::convert::Infallible; + } + impl cached::ConcurrentCached for Store { fn cache_get(&self, k: &String) -> Result, Self::Error> { Ok(self.0.lock().unwrap().get(k).cloned()) } @@ -4306,8 +4393,12 @@ mod concurrent_cached_return_named_error { fn cache_remove_entry(&self, k: &String) -> Result, Self::Error> { Ok(self.0.lock().unwrap().remove_entry(k)) } - fn set_refresh_on_hit(&self, _r: bool) -> bool { - false + fn cache_clear(&self) -> Result<(), Self::Error> { + self.0.lock().unwrap().clear(); + Ok(()) + } + fn cache_reset(&self) -> Result<(), Self::Error> { + self.cache_clear() } } @@ -4347,7 +4438,7 @@ mod redis_tests { #[concurrent_cached( redis = true, - ttl = 1, + ttl_secs = 1, cache_prefix_block = "{ \"__cached_redis_proc_macro_test_fn_cached_redis\" }", map_error = r##"|e| TestError::RedisError(format!("{:?}", e))"## )] @@ -4369,7 +4460,7 @@ mod redis_tests { #[concurrent_cached( redis = true, - ttl = 1, + ttl_secs = 1, with_cached_flag = true, map_error = r##"|e| TestError::RedisError(format!("{:?}", e))"## )] @@ -4392,7 +4483,7 @@ mod redis_tests { #[concurrent_cached( map_error = r##"|e| TestError::RedisError(format!("{:?}", e))"##, ty = "cached::RedisCache", - create = r##" { RedisCache::new("cache_redis_test_cache_create", Duration::from_secs(1)).refresh_on_hit(true).build().expect("error building redis cache") } "## + create = r##" { RedisCache::builder().prefix("cache_redis_test_cache_create").ttl(Duration::from_secs(1)).refresh_on_hit(true).build().expect("error building redis cache") } "## )] fn cached_redis_cache_create(n: u32) -> Result { if n < 5 { @@ -4416,7 +4507,7 @@ mod redis_tests { #[concurrent_cached( redis = true, - ttl = 1, + ttl_secs = 1, cache_prefix_block = "{ \"__cached_redis_proc_macro_test_fn_async_cached_redis\" }", map_error = r##"|e| TestError::RedisError(format!("{:?}", e))"## )] @@ -4438,7 +4529,7 @@ mod redis_tests { #[concurrent_cached( redis = true, - ttl = 1, + ttl_secs = 1, with_cached_flag = true, map_error = r##"|e| TestError::RedisError(format!("{:?}", e))"## )] @@ -4462,7 +4553,7 @@ mod redis_tests { #[concurrent_cached( map_error = r##"|e| TestError::RedisError(format!("{:?}", e))"##, ty = "cached::AsyncRedisCache", - create = r##" { AsyncRedisCache::new("async_cached_redis_test_cache_create", Duration::from_secs(1)).refresh_on_hit(true).build().await.expect("error building async redis cache") } "## + create = r##" { AsyncRedisCache::builder().prefix("async_cached_redis_test_cache_create").ttl(Duration::from_secs(1)).refresh_on_hit(true).build().await.expect("error building async redis cache") } "## )] async fn async_cached_redis_cache_create(n: u32) -> Result { if n < 5 { @@ -4488,177 +4579,985 @@ mod redis_tests { #[tokio::test] async fn async_redis_builder_aliases_and_zero_ttl_validation() { - let result = cached::AsyncRedisCache::::builder( - "async-zero-ttl", - Duration::ZERO, - ) - .build() - .await; + let result = cached::AsyncRedisCache::::builder() + .prefix("async-zero-ttl") + .ttl(Duration::ZERO) + .build() + .await; assert!(matches!( result, - Err(cached::RedisCacheBuildError::InvalidTtl(..)) + Err(cached::RedisCacheBuildError::Build( + cached::BuildError::InvalidValue { field: "ttl", .. } + )) )); } - } -} -#[cfg(feature = "proc_macro")] -#[derive(Clone)] -pub struct NewsArticle { - slug: String, - is_expired: bool, -} + // I2 (async): set_ttl(0) disables expiry — keys written afterward are + // persistent (raw TTL == -1), and set_ttl(nonzero) resumes expiry. + #[cfg(feature = "redis_tokio")] + #[tokio::test] + async fn async_redis_set_ttl_zero_writes_key_without_expiry() { + use cached::{ConcurrentCacheTtl, ConcurrentCachedAsync}; + + let prefix = "async_test_set_ttl_zero_no_expiry"; + let cache = AsyncRedisCache::::builder() + .prefix(prefix) + .ttl(Duration::from_secs(30)) + .namespace("") + .build() + .await + .expect("build async cache"); + cache.async_cache_clear().await.expect("clear"); + + // Probe the raw Redis TTL synchronously (-1 == persistent, no expiry). + let conn_str = cache.connection_string(); + let raw_ttl = |key: &str| -> i64 { + let client = redis::Client::open(conn_str.reveal()) + .expect("open redis client for TTL probe"); + let mut conn = client + .get_connection() + .expect("redis connection for TTL probe"); + redis::cmd("TTL") + .arg(format!("{prefix}:{key}")) + .query(&mut conn) + .expect("TTL query") + }; + + // Baseline: a real ttl writes the key with a positive TTL. + cache + .async_cache_set("k_live".to_string(), "v".to_string()) + .await + .expect("set k_live"); + assert!( + raw_ttl("k_live") > 0, + "a non-zero ttl must write the key with a positive TTL" + ); -#[cfg(feature = "proc_macro")] -impl Expires for NewsArticle { - fn is_expired(&self) -> bool { - self.is_expired - } -} + // Disable expiry. + let prev = ConcurrentCacheTtl::set_ttl(&cache, Duration::ZERO); + assert_eq!(prev, Some(Duration::from_secs(30))); + assert_eq!(ConcurrentCacheTtl::ttl(&cache), None); -#[cfg(feature = "proc_macro")] -const EXPIRED_SLUG: &str = "expired_slug"; -#[cfg(feature = "proc_macro")] -const UNEXPIRED_SLUG: &str = "unexpired_slug"; + cache + .async_cache_set("k_persist".to_string(), "v".to_string()) + .await + .expect("set k_persist"); + assert_eq!( + raw_ttl("k_persist"), + -1, + "set_ttl(0) must write the key WITHOUT any expiry (persistent)" + ); + assert_eq!( + cache + .async_cache_get(&"k_persist".to_string()) + .await + .expect("get"), + Some("v".to_string()) + ); -#[cfg(feature = "proc_macro")] -#[cached( - ty = "ExpiringLruCache", - create = "{ ExpiringLruCache::builder().max_size(3).build().unwrap() }" -)] -fn fetch_article(slug: String) -> Result { - match slug.as_str() { - EXPIRED_SLUG => Ok(NewsArticle { - slug: String::from(EXPIRED_SLUG), - is_expired: true, - }), - UNEXPIRED_SLUG => Ok(NewsArticle { - slug: String::from(UNEXPIRED_SLUG), - is_expired: false, - }), - _ => Err(()), - } -} + // Re-arm a real ttl. + ConcurrentCacheTtl::set_ttl(&cache, Duration::from_secs(30)); + cache + .async_cache_set("k_rearm".to_string(), "v".to_string()) + .await + .expect("set k_rearm"); + assert!( + raw_ttl("k_rearm") > 0, + "set_ttl(nonzero) must resume writing keys with a TTL" + ); -#[cfg(feature = "proc_macro")] -#[test] -#[serial(ExpiringCacheTest)] -fn test_expiring_value_expired_article_returned_with_miss() { - { - let mut cache = FETCH_ARTICLE.write(); - cache.cache_reset(); - cache.cache_reset_metrics(); - } - let expired_article = fetch_article(EXPIRED_SLUG.to_string()); + cache.async_cache_clear().await.expect("clean up"); + } - assert!(expired_article.is_ok()); - assert_eq!(EXPIRED_SLUG, expired_article.unwrap().slug.as_str()); + // gap 2 (async): `async_cache_set_ref` (SerializeCachedAsync) must honor + // the disabled-ttl path — write WITHOUT expiry when ttl is zero, and WITH + // expiry when nonzero. Only `async_cache_set` was previously covered. + #[cfg(feature = "redis_tokio")] + #[tokio::test] + async fn async_redis_set_ref_zero_ttl_writes_key_without_expiry() { + use cached::{ConcurrentCacheTtl, ConcurrentCachedAsync, SerializeCachedAsync}; + + let prefix = "async_test_set_ref_zero_ttl"; + let cache = AsyncRedisCache::::builder() + .prefix(prefix) + .ttl(Duration::from_secs(30)) + .namespace("") + .build() + .await + .expect("build async cache"); + cache.async_cache_clear().await.expect("clear"); + + let conn_str = cache.connection_string(); + let raw_ttl = move |key: &str| -> i64 { + let client = redis::Client::open(conn_str.reveal()) + .expect("open redis client for TTL probe"); + let mut conn = client + .get_connection() + .expect("redis connection for TTL probe"); + redis::cmd("TTL") + .arg(format!("{prefix}:{key}")) + .query(&mut conn) + .expect("TTL query") + }; + + // Baseline: a real ttl via set_ref writes with a positive TTL. + let prev = cache + .async_cache_set_ref(&"k_live".to_string(), &"v".to_string()) + .await + .expect("set_ref k_live"); + assert_eq!(prev, None, "first set_ref must return None"); + assert!( + raw_ttl("k_live") > 0, + "set_ref under a real ttl must write a positive TTL" + ); - // The article was fetched due to a cache miss and the result cached. - { - let cache = FETCH_ARTICLE.write(); - assert_eq!(1, cache.cache_size()); - assert_eq!(cache.cache_hits(), Some(0)); - assert_eq!(cache.cache_misses(), Some(1)); - } + // Disable expiry: set_ref must write the key WITHOUT any expiry. + ConcurrentCacheTtl::set_ttl(&cache, Duration::ZERO); + let prev2 = cache + .async_cache_set_ref(&"k_persist".to_string(), &"v".to_string()) + .await + .expect("set_ref k_persist"); + assert_eq!(prev2, None); + assert_eq!( + raw_ttl("k_persist"), + -1, + "set_ref under disabled ttl must write the key WITHOUT expiry (persistent)" + ); + assert_eq!( + cache + .async_cache_get(&"k_persist".to_string()) + .await + .expect("get k_persist"), + Some("v".to_string()) + ); - let _ = fetch_article(EXPIRED_SLUG.to_string()); + // Re-arm a real ttl: set_ref resumes writing a TTL, and returns the prior value. + ConcurrentCacheTtl::set_ttl(&cache, Duration::from_secs(30)); + let prev3 = cache + .async_cache_set_ref(&"k_persist".to_string(), &"v2".to_string()) + .await + .expect("set_ref k_persist overwrite"); + assert_eq!( + prev3, + Some("v".to_string()), + "set_ref overwrite must return the previous value" + ); + assert!( + raw_ttl("k_persist") > 0, + "set_ref under a re-enabled ttl must write a positive TTL" + ); - // The article was fetched again as it had expired. - { - let cache = FETCH_ARTICLE.write(); - assert_eq!(1, cache.cache_size()); - assert_eq!(cache.cache_hits(), Some(0)); - assert_eq!(cache.cache_misses(), Some(2)); - } -} + cache.async_cache_clear().await.expect("clean up"); + } -#[cfg(feature = "proc_macro")] -#[test] -#[serial(ExpiringCacheTest)] -fn test_expiring_value_unexpired_article_returned_with_hit() { - { - let mut cache = FETCH_ARTICLE.write(); - cache.cache_reset(); - cache.cache_reset_metrics(); - } - let unexpired_article = fetch_article(UNEXPIRED_SLUG.to_string()); + // gap 1 (async): refresh-on-hit skip-EXPIRE under disabled ttl, and the + // reverse (re-enabled ttl adds EXPIRE to a previously persistent key). + #[cfg(feature = "redis_tokio")] + #[tokio::test] + async fn async_redis_refresh_on_hit_disabled_then_reenabled_ttl() { + use cached::{ConcurrentCacheTtl, ConcurrentCachedAsync}; + + let prefix = "async_test_refresh_disabled_reenabled"; + let cache = AsyncRedisCache::::builder() + .prefix(prefix) + .ttl(Duration::from_secs(100)) + .namespace("") + .refresh_on_hit(true) + .build() + .await + .expect("build async cache"); + cache.async_cache_clear().await.expect("clear"); + + let conn_str = cache.connection_string(); + let raw_ttl = move |key: &str| -> i64 { + let client = redis::Client::open(conn_str.reveal()) + .expect("open redis client for TTL probe"); + let mut conn = client + .get_connection() + .expect("redis connection for TTL probe"); + redis::cmd("TTL") + .arg(format!("{prefix}:{key}")) + .query(&mut conn) + .expect("TTL query") + }; + + // Key written WITH a TTL. + cache + .async_cache_set("k".to_string(), "v".to_string()) + .await + .expect("set k"); + let ttl_before = raw_ttl("k"); + assert!(ttl_before > 0, "key must start with a positive TTL"); - assert!(unexpired_article.is_ok()); - assert_eq!(UNEXPIRED_SLUG, unexpired_article.unwrap().slug.as_str()); + // Disable expiry, refresh-on-hit GET: prior TTL must remain intact + // (skip-EXPIRE: not renewed, not PERSISTed). + ConcurrentCacheTtl::set_ttl(&cache, Duration::ZERO); + assert_eq!( + cache + .async_cache_get(&"k".to_string()) + .await + .expect("get k"), + Some("v".to_string()) + ); + let ttl_after_disable = raw_ttl("k"); + assert!( + ttl_after_disable > 0, + "skip-EXPIRE must leave the prior TTL intact (not PERSIST), got {ttl_after_disable}" + ); + assert!( + ttl_after_disable <= ttl_before, + "skip-EXPIRE must not renew the prior TTL: before={ttl_before} after={ttl_after_disable}" + ); - // The article was fetched due to a cache miss and the result cached. - { - let cache = FETCH_ARTICLE.write(); - assert_eq!(1, cache.cache_size()); - assert_eq!(cache.cache_hits(), Some(0)); - assert_eq!(cache.cache_misses(), Some(1)); - } + // Write a persistent key under disabled ttl, re-arm, then refresh-on-hit + // GET must add a TTL. + cache + .async_cache_set("p".to_string(), "v".to_string()) + .await + .expect("set p"); + assert_eq!( + raw_ttl("p"), + -1, + "persistent key written under disabled ttl" + ); + ConcurrentCacheTtl::set_ttl(&cache, Duration::from_secs(50)); + assert_eq!( + cache + .async_cache_get(&"p".to_string()) + .await + .expect("get p"), + Some("v".to_string()) + ); + assert!( + raw_ttl("p") > 0, + "refresh-on-hit under a re-enabled ttl must add a TTL to the persistent key" + ); - let cached_article = fetch_article(UNEXPIRED_SLUG.to_string()); - assert!(cached_article.is_ok()); - assert_eq!(UNEXPIRED_SLUG, cached_article.unwrap().slug.as_str()); + cache.async_cache_clear().await.expect("clean up"); + } - // The article was not fetched but returned as a hit from the cache. - { - let cache = FETCH_ARTICLE.write(); - assert_eq!(1, cache.cache_size()); - assert_eq!(cache.cache_hits(), Some(1)); - assert_eq!(cache.cache_misses(), Some(1)); - } -} + // The author flagged the non-zero `try_set_ttl` success path as untested on the + // async Redis store. `ConcurrentCacheTtl::try_set_ttl` is a defaulted method: + // zero -> Err(ZeroTtl) (no store mutation), non-zero -> Ok(prev) where `prev` + // is whatever `set_ttl` returned (the PRIOR ttl). This pins both arms on + // `AsyncRedisCache`, including that a rejected zero leaves the ttl untouched and + // that the returned prior value is correct across consecutive calls. + #[cfg(feature = "redis_tokio")] + #[tokio::test] + async fn async_redis_try_set_ttl_zero_and_nonzero() { + use cached::{ConcurrentCacheTtl, SetTtlError}; + + let cache = AsyncRedisCache::::builder() + .prefix("async_test_try_set_ttl") + .ttl(Duration::from_secs(30)) + .namespace("") + .build() + .await + .expect("build async cache"); -#[test] -fn test_sized_cache_on_evict() { - use std::sync::Arc; - use std::sync::atomic::{AtomicU32, Ordering}; - let evicted_count = Arc::new(AtomicU32::new(0)); - let evicted_count_clone = evicted_count.clone(); - let mut cache = cached::LruCache::builder() - .max_size(2) - .on_evict(move |_k, _v| { - evicted_count_clone.fetch_add(1, Ordering::Relaxed); - }) - .build() - .unwrap(); + // Zero is rejected and the ttl is left untouched. + assert_eq!(cache.try_set_ttl(Duration::ZERO), Err(SetTtlError::ZeroTtl)); + assert_eq!( + ConcurrentCacheTtl::ttl(&cache), + Some(Duration::from_secs(30)), + "rejected try_set_ttl must not change the ttl" + ); - cache.set(1, 10); - cache.set(2, 20); - assert_eq!(evicted_count.load(Ordering::Relaxed), 0); - cache.set(3, 30); - assert_eq!(evicted_count.load(Ordering::Relaxed), 1); -} + // Non-zero succeeds and returns the PRIOR ttl (the build ttl). + assert_eq!( + cache.try_set_ttl(Duration::from_secs(60)), + Ok(Some(Duration::from_secs(30))), + "non-zero try_set_ttl must return the previous ttl" + ); + assert_eq!( + ConcurrentCacheTtl::ttl(&cache), + Some(Duration::from_secs(60)) + ); -#[test] -#[cfg(feature = "time_stores")] -fn test_timed_cache_on_evict() { - use std::sync::Arc; - use std::sync::atomic::{AtomicU32, Ordering}; - let evicted_count = Arc::new(AtomicU32::new(0)); - let evicted_count_clone = evicted_count.clone(); - let mut cache = cached::TtlCache::builder() - .ttl(cached::time::Duration::from_millis(100)) - .on_evict(move |_k, _v| { - evicted_count_clone.fetch_add(1, Ordering::Relaxed); - }) - .build() - .unwrap(); + // A second non-zero try_set_ttl returns the value just installed. + assert_eq!( + cache.try_set_ttl(Duration::from_secs(10)), + Ok(Some(Duration::from_secs(60))) + ); + } - cache.set(1, 10); - assert_eq!(evicted_count.load(Ordering::Relaxed), 0); - std::thread::sleep(cached::time::Duration::from_millis(200)); - assert_eq!(cache.evict(), 1); - assert_eq!(evicted_count.load(Ordering::Relaxed), 1); - assert_eq!(cache.cache_evictions(), Some(1)); -} + // `ConcurrentCacheTtl::refresh_on_hit` on AsyncRedisCache reads the real + // AtomicBool through trait dispatch (previously the trait default always + // returned false even after set_refresh_on_hit(true)). + #[cfg(feature = "redis_tokio")] + #[tokio::test] + async fn async_redis_refresh_on_hit_trait_getter_reflects_setter() { + use cached::ConcurrentCacheTtl; + + let cache = AsyncRedisCache::::builder() + .prefix("async_test_refresh_getter") + .ttl(Duration::from_secs(30)) + .namespace("") + .build() + .await + .expect("build async cache"); -#[test] -#[cfg(feature = "time_stores")] -fn test_cache_evict_trait_returns_count() { - use cached::CacheEvict; + assert!(!ConcurrentCacheTtl::refresh_on_hit(&cache)); + let prev = ConcurrentCacheTtl::set_refresh_on_hit(&cache, true); + assert!(!prev, "previous flag must be false"); + assert!( + ConcurrentCacheTtl::refresh_on_hit(&cache), + "trait getter must reflect set_refresh_on_hit(true)" + ); + let prev = ConcurrentCacheTtl::set_refresh_on_hit(&cache, false); + assert!(prev, "previous flag must be true"); + assert!(!ConcurrentCacheTtl::refresh_on_hit(&cache)); + } - let mut cache = cached::TtlCache::builder() - .ttl(cached::time::Duration::from_millis(20)) + // ConcurrentCacheBase::cache_size / len / is_empty on the ASYNC Redis store + // (called via the sync base methods, as the task specifies for async stores). + // Like the sync Redis store, the count is structurally unknown, so all three + // must answer Ok(None) — never Ok(Some(0)) / Ok(Some(true)). + #[cfg(feature = "redis_tokio")] + #[tokio::test] + async fn async_redis_cache_size_len_is_empty_unknown() { + use cached::{ConcurrentCacheBase, ConcurrentCachedAsync}; + + let cache = AsyncRedisCache::::builder() + .prefix("async_test_cache_size_unknown") + .ttl(Duration::from_secs(30)) + .namespace("") + .build() + .await + .expect("build async cache"); + cache.async_cache_clear().await.expect("clear"); + + // RedisCacheError does not implement PartialEq; unwrap and compare payloads. + assert_eq!( + ConcurrentCacheBase::cache_size(&cache).expect("cache_size"), + None + ); + assert_eq!(ConcurrentCacheBase::len(&cache).expect("len"), None); + assert_eq!( + ConcurrentCacheBase::is_empty(&cache).expect("is_empty"), + None + ); + + cache + .async_cache_set("k".to_string(), "v".to_string()) + .await + .expect("set k"); + assert_eq!( + ConcurrentCacheBase::cache_size(&cache).expect("cache_size"), + None + ); + assert_eq!(ConcurrentCacheBase::len(&cache).expect("len"), None); + assert_eq!( + ConcurrentCacheBase::is_empty(&cache).expect("is_empty"), + None + ); + + cache.async_cache_clear().await.expect("clean up"); + } + } + + // Requires a live Redis server (provided by CI). + use cached::{ConcurrentCacheTtl, ConcurrentCached, SerializeCached}; + + #[test] + fn test_redis_cache_clear_scoped() { + // Build two caches with different prefixes under an empty namespace so + // only the SCAN scope (prefix) distinguishes them. + let cache_a = RedisCache::::builder() + .prefix("test_clear_scope_a") + .ttl(Duration::from_secs(30)) + .namespace("") + .build() + .expect("build cache_a"); + + let cache_b = RedisCache::::builder() + .prefix("test_clear_scope_b") + .ttl(Duration::from_secs(30)) + .namespace("") + .build() + .expect("build cache_b"); + + // Seed both caches. + cache_a + .cache_set("k1".to_string(), "v1".to_string()) + .expect("cache_a set k1"); + cache_a + .cache_set("k2".to_string(), "v2".to_string()) + .expect("cache_a set k2"); + cache_b + .cache_set("kb".to_string(), "vb".to_string()) + .expect("cache_b set kb"); + + // Clearing cache_a must remove its keys. + cache_a.cache_clear().expect("cache_a clear"); + assert_eq!( + cache_a + .cache_get(&"k1".to_string()) + .expect("cache_a get k1"), + None, + "k1 must be gone after cache_clear" + ); + assert_eq!( + cache_a + .cache_get(&"k2".to_string()) + .expect("cache_a get k2"), + None, + "k2 must be gone after cache_clear" + ); + + // cache_b's key must still be present. + assert_eq!( + cache_b + .cache_get(&"kb".to_string()) + .expect("cache_b get kb"), + Some("vb".to_string()), + "cache_b key must survive cache_a clear" + ); + + // Clean up. + cache_b.cache_clear().expect("cache_b clear"); + } + + #[test] + fn test_redis_cache_set_ref_round_trip() { + let cache = RedisCache::::builder() + .prefix("test_set_ref_rt") + .ttl(Duration::from_secs(30)) + .namespace("") + .build() + .expect("build cache"); + + cache.cache_clear().unwrap(); + + let key = "ref_key".to_string(); + let val = "ref_val".to_string(); + let val2 = "ref_val_overwrite".to_string(); + + // First insert must return None (no previous entry). + let prev = cache + .cache_set_ref(&key, &val) + .expect("first cache_set_ref"); + assert_eq!(prev, None, "first cache_set_ref must return None"); + + let got = cache.cache_get(&key).expect("cache_get after set_ref"); + assert_eq!( + got, + Some(val.clone()), + "cache_get must return the value written by cache_set_ref" + ); + + // Overwrite with a different value; must return the previous value. + let prev2 = cache + .cache_set_ref(&key, &val2) + .expect("second cache_set_ref"); + assert_eq!( + prev2, + Some(val), + "second cache_set_ref must return the previous value" + ); + + // Overwrite must be visible via cache_get. + let got2 = cache.cache_get(&key).expect("cache_get after overwrite"); + assert_eq!( + got2, + Some(val2), + "cache_get must return the overwritten value" + ); + + cache.cache_clear().expect("clean up"); + } + + // Read the raw Redis `TTL` (in seconds) for the namespace-less key + // `{prefix}:{key}` directly via the redis client. Returns -1 for a + // persistent (no-expiry) key, -2 if the key is absent, or the remaining + // seconds otherwise. + fn raw_ttl_secs(cache: &RedisCache, prefix: &str, key: &str) -> i64 { + let conn_str = cache.connection_string(); + let client = + redis::Client::open(conn_str.reveal()).expect("open redis client for TTL probe"); + let mut conn = client + .get_connection() + .expect("redis connection for TTL probe"); + let full_key = format!("{prefix}:{key}"); + redis::cmd("TTL") + .arg(full_key) + .query(&mut conn) + .expect("TTL query") + } + + // I2: set_ttl(0) disables expiry. A key written afterward must have NO TTL + // (persistent, raw TTL == -1), and set_ttl(nonzero) must resume expiry. + #[test] + fn test_redis_set_ttl_zero_writes_key_without_expiry() { + let prefix = "test_set_ttl_zero_no_expiry"; + let cache = RedisCache::::builder() + .prefix(prefix) + .ttl(Duration::from_secs(30)) + .namespace("") + .build() + .expect("build cache"); + cache.cache_clear().expect("clear"); + + // Baseline: a freshly-built cache writes with a real TTL. + cache + .cache_set("k_live".to_string(), "v".to_string()) + .expect("set k_live"); + let live_ttl = raw_ttl_secs(&cache, prefix, "k_live"); + assert!( + live_ttl > 0, + "a non-zero ttl must write the key with a positive TTL, got {live_ttl}" + ); + + // Disable expiry: ttl() now resolves to None. + let prev = cache.set_ttl(Duration::ZERO); + assert_eq!(prev, Some(Duration::from_secs(30))); + assert_eq!(cache.ttl(), None, "set_ttl(0) disables expiry"); + + // A key written under the disabled ttl must be persistent (raw TTL == -1). + cache + .cache_set("k_persist".to_string(), "v".to_string()) + .expect("set k_persist"); + assert_eq!( + raw_ttl_secs(&cache, prefix, "k_persist"), + -1, + "set_ttl(0) must write the key WITHOUT any expiry (persistent)" + ); + // ...and it is readable. + assert_eq!( + cache.cache_get(&"k_persist".to_string()).expect("get"), + Some("v".to_string()) + ); + + // Re-arm a real ttl: subsequent writes carry a TTL again. + cache.set_ttl(Duration::from_secs(30)); + cache + .cache_set("k_rearm".to_string(), "v".to_string()) + .expect("set k_rearm"); + assert!( + raw_ttl_secs(&cache, prefix, "k_rearm") > 0, + "set_ttl(nonzero) must resume writing keys with a TTL" + ); + + cache.cache_clear().expect("clean up"); + } + + // unset_ttl() is equivalent to set_ttl(0) on the Redis store: keys written + // afterward are persistent. + #[test] + fn test_redis_unset_ttl_writes_key_without_expiry() { + let prefix = "test_unset_ttl_no_expiry"; + let cache = RedisCache::::builder() + .prefix(prefix) + .ttl(Duration::from_secs(30)) + .namespace("") + .build() + .expect("build cache"); + cache.cache_clear().expect("clear"); + + let prev = cache.unset_ttl(); + assert_eq!(prev, Some(Duration::from_secs(30))); + assert_eq!(cache.ttl(), None, "unset_ttl disables expiry"); + + cache + .cache_set("k".to_string(), "v".to_string()) + .expect("set k"); + assert_eq!( + raw_ttl_secs(&cache, prefix, "k"), + -1, + "unset_ttl must write the key WITHOUT any expiry (persistent)" + ); + + cache.cache_clear().expect("clean up"); + } + + // gap 1 (sync): refresh-on-hit interaction with the disabled-ttl write path. + // + // The implementor chose to SKIP `EXPIRE` on a refresh-on-hit GET when the ttl + // is disabled. So a key written WITH a TTL, then read after `set_ttl(0)`, must + // KEEP its prior TTL (the refresh path neither renews it nor PERSISTs it). + // Conversely a key written WITHOUT a TTL (disabled), then read after + // `set_ttl(nonzero)`, must GAIN a TTL via the refresh `EXPIRE`. + #[test] + fn test_redis_refresh_on_hit_disabled_ttl_skips_expire_preexisting_key() { + let prefix = "test_refresh_disabled_skips_expire"; + let cache = RedisCache::::builder() + .prefix(prefix) + .ttl(Duration::from_secs(100)) + .namespace("") + .refresh_on_hit(true) + .build() + .expect("build cache"); + cache.cache_clear().expect("clear"); + + // Write a key WITH a TTL while expiry is enabled. + cache + .cache_set("k".to_string(), "v".to_string()) + .expect("set k"); + let ttl_before = raw_ttl_secs(&cache, prefix, "k"); + assert!( + ttl_before > 0, + "key written under a real ttl must have a positive TTL, got {ttl_before}" + ); + + // Disable expiry, then refresh-on-hit GET the pre-existing key. + assert_eq!( + cache.set_ttl(Duration::ZERO), + Some(Duration::from_secs(100)) + ); + assert_eq!( + cache.cache_get(&"k".to_string()).expect("get k"), + Some("v".to_string()), + "refresh-on-hit get under disabled ttl must still return the value" + ); + + // The skip-EXPIRE choice: the prior TTL must remain INTACT (not renewed, + // not PERSISTed). It must still be a positive, non-increased TTL. + let ttl_after = raw_ttl_secs(&cache, prefix, "k"); + assert!( + ttl_after > 0, + "skip-EXPIRE on a disabled-ttl refresh must leave the prior TTL intact \ + (not PERSIST it); got {ttl_after}" + ); + assert!( + ttl_after <= ttl_before, + "skip-EXPIRE must NOT renew/extend the prior TTL: before={ttl_before} after={ttl_after}" + ); + + cache.cache_clear().expect("clean up"); + } + + #[test] + fn test_redis_refresh_on_hit_reenabled_ttl_adds_expire_to_persistent_key() { + let prefix = "test_refresh_reenabled_adds_expire"; + let cache = RedisCache::::builder() + .prefix(prefix) + .ttl(Duration::ZERO) // start disabled — strict build path is exercised elsewhere + .namespace("") + .refresh_on_hit(true) + .build(); + // build() rejects a zero ttl, so construct with a real ttl then disable. + assert!( + cache.is_err(), + "build() must reject a zero ttl even on the refresh path" + ); + let cache = RedisCache::::builder() + .prefix(prefix) + .ttl(Duration::from_secs(100)) + .namespace("") + .refresh_on_hit(true) + .build() + .expect("build cache"); + cache.cache_clear().expect("clear"); + + // Disable expiry and write a persistent (no-TTL) key. + cache.set_ttl(Duration::ZERO); + cache + .cache_set("k".to_string(), "v".to_string()) + .expect("set k"); + assert_eq!( + raw_ttl_secs(&cache, prefix, "k"), + -1, + "key written under disabled ttl must be persistent" + ); + + // Re-arm a real ttl, then refresh-on-hit GET: the key must GAIN a TTL. + cache.set_ttl(Duration::from_secs(50)); + assert_eq!( + cache.cache_get(&"k".to_string()).expect("get k"), + Some("v".to_string()) + ); + let ttl_after = raw_ttl_secs(&cache, prefix, "k"); + assert!( + ttl_after > 0, + "refresh-on-hit under a re-enabled ttl must EXPIRE the previously \ + persistent key (give it a TTL); got {ttl_after}" + ); + + cache.cache_clear().expect("clean up"); + } + + // Behavior parity for the moved `ConcurrentCacheTtl` knobs on the SYNC Redis store. + // The author covered `set_ttl(0)`/`unset_ttl` (the disable paths) with raw-TTL + // probes, but not the plain non-zero round-trip: `ttl()` reflects the build ttl, + // `set_ttl(nonzero)` returns the PRIOR ttl and updates the live value, and + // `unset_ttl()` returns the prior ttl and resolves `ttl()` to None. A regression in + // the moved getter/setter would be caught here without inspecting raw Redis state. + #[test] + fn test_redis_set_ttl_nonzero_round_trip() { + let cache = RedisCache::::builder() + .prefix("test_set_ttl_nonzero_round_trip") + .ttl(Duration::from_secs(30)) + .namespace("") + .build() + .expect("build cache"); + + // ttl() reflects the configured build ttl. + assert_eq!(cache.ttl(), Some(Duration::from_secs(30))); + + // set_ttl(nonzero) returns the PREVIOUS ttl and installs the new one. + let prev = cache.set_ttl(Duration::from_secs(60)); + assert_eq!(prev, Some(Duration::from_secs(30))); + assert_eq!(cache.ttl(), Some(Duration::from_secs(60))); + + // A second set_ttl returns the value just installed. + let prev2 = cache.set_ttl(Duration::from_secs(10)); + assert_eq!(prev2, Some(Duration::from_secs(60))); + + // unset_ttl returns the prior ttl and disables expiry (ttl -> None). + let prev3 = cache.unset_ttl(); + assert_eq!(prev3, Some(Duration::from_secs(10))); + assert_eq!(cache.ttl(), None); + + // unset_ttl on an already-disabled store returns None. + assert_eq!(cache.unset_ttl(), None); + } + + // ConcurrentCacheBase::cache_size / len / is_empty on the SYNC Redis store. + // The author noted `cache_size() == Ok(None)` was only asserted for `RedbCache`. + // RedisCache cannot answer its own entry count cheaply (a server-side DBSIZE/SCAN + // over a shared keyspace), so it must return Ok(None) — and the `len`/`is_empty` + // defaults must forward through (len -> cache_size, is_empty -> None map). This + // holds even with live entries present, since the size is structurally unknown. + #[test] + fn test_redis_cache_size_len_is_empty_unknown() { + use cached::ConcurrentCacheBase; + + let cache = RedisCache::::builder() + .prefix("test_redis_cache_size_unknown") + .ttl(Duration::from_secs(30)) + .namespace("") + .build() + .expect("build cache"); + cache.cache_clear().expect("clear"); + + // Empty store: size is still structurally unknown (None), NOT Some(0). + // RedisCacheError does not implement PartialEq, so unwrap and compare payloads. + assert_eq!( + ConcurrentCacheBase::cache_size(&cache).expect("cache_size"), + None + ); + assert_eq!(ConcurrentCacheBase::len(&cache).expect("len"), None); + assert_eq!( + ConcurrentCacheBase::is_empty(&cache).expect("is_empty"), + None, + "unknown size must map to is_empty == None, not Some(true)" + ); + + // With a live entry, the answer is still None (no implicit DBSIZE/SCAN). + cache + .cache_set("k".to_string(), "v".to_string()) + .expect("set k"); + assert_eq!( + ConcurrentCacheBase::cache_size(&cache).expect("cache_size"), + None + ); + assert_eq!(ConcurrentCacheBase::len(&cache).expect("len"), None); + assert_eq!( + ConcurrentCacheBase::is_empty(&cache).expect("is_empty"), + None + ); + + cache.cache_clear().expect("clean up"); + } + + // The `ConcurrentCacheTtl` impl on the sync Redis store now provides a truthful + // `refresh_on_hit` getter that reads the internal `AtomicBool` set by + // `set_refresh_on_hit`. Previously the getter relied on the trait default and + // always returned `false` even after `set_refresh_on_hit(true)` — a latent bug. + // Making the trait method required forces a real getter, so the trait-level value + // now reflects the setter through trait dispatch. + #[test] + fn test_redis_refresh_on_hit_trait_getter_reflects_setter() { + let cache = RedisCache::::builder() + .prefix("test_redis_refresh_getter_reflects_setter") + .ttl(Duration::from_secs(30)) + .namespace("") + .build() + .expect("build cache"); + + // Trait getter starts false (builder default). + assert!(!ConcurrentCacheTtl::refresh_on_hit(&cache)); + + // set_refresh_on_hit returns the previous flag (the AtomicBool swap value). + let prev = ConcurrentCacheTtl::set_refresh_on_hit(&cache, true); + assert!(!prev, "previous flag must be false"); + + // Trait getter now reports the value set via trait dispatch. + assert!( + ConcurrentCacheTtl::refresh_on_hit(&cache), + "trait-level refresh_on_hit getter must reflect set_refresh_on_hit(true)" + ); + + // Round-trip back to false: getter reflects it, swap reports the real prior value. + let prev2 = ConcurrentCacheTtl::set_refresh_on_hit(&cache, false); + assert!( + prev2, + "set_refresh_on_hit must report the real prior flag (true)" + ); + assert!( + !ConcurrentCacheTtl::refresh_on_hit(&cache), + "trait-level refresh_on_hit getter must reflect set_refresh_on_hit(false)" + ); + } +} + +#[cfg(feature = "proc_macro")] +#[derive(Clone)] +pub struct NewsArticle { + slug: String, + is_expired: bool, +} + +#[cfg(feature = "proc_macro")] +impl Expires for NewsArticle { + fn is_expired(&self) -> bool { + self.is_expired + } +} + +#[cfg(feature = "proc_macro")] +const EXPIRED_SLUG: &str = "expired_slug"; +#[cfg(feature = "proc_macro")] +const UNEXPIRED_SLUG: &str = "unexpired_slug"; + +#[cfg(feature = "proc_macro")] +#[cached( + ty = "ExpiringLruCache", + create = "{ ExpiringLruCache::builder().max_size(3).build().unwrap() }" +)] +fn fetch_article(slug: String) -> Result { + match slug.as_str() { + EXPIRED_SLUG => Ok(NewsArticle { + slug: String::from(EXPIRED_SLUG), + is_expired: true, + }), + UNEXPIRED_SLUG => Ok(NewsArticle { + slug: String::from(UNEXPIRED_SLUG), + is_expired: false, + }), + _ => Err(()), + } +} + +#[cfg(feature = "proc_macro")] +#[test] +#[serial(ExpiringCacheTest)] +fn test_expiring_value_expired_article_returned_with_miss() { + { + let mut cache = FETCH_ARTICLE.0.write(); + cache.cache_reset(); + cache.cache_reset_metrics(); + } + let expired_article = fetch_article(EXPIRED_SLUG.to_string()); + + assert!(expired_article.is_ok()); + assert_eq!(EXPIRED_SLUG, expired_article.unwrap().slug.as_str()); + + // The article was fetched due to a cache miss and the result cached. + { + let cache = FETCH_ARTICLE.0.write(); + assert_eq!(1, cache.cache_size()); + assert_eq!(cache.cache_hits(), Some(0)); + assert_eq!(cache.cache_misses(), Some(1)); + } + + let _ = fetch_article(EXPIRED_SLUG.to_string()); + + // The article was fetched again as it had expired. + { + let cache = FETCH_ARTICLE.0.write(); + assert_eq!(1, cache.cache_size()); + assert_eq!(cache.cache_hits(), Some(0)); + assert_eq!(cache.cache_misses(), Some(2)); + } +} + +#[cfg(feature = "proc_macro")] +#[test] +#[serial(ExpiringCacheTest)] +fn test_expiring_value_unexpired_article_returned_with_hit() { + { + let mut cache = FETCH_ARTICLE.0.write(); + cache.cache_reset(); + cache.cache_reset_metrics(); + } + let unexpired_article = fetch_article(UNEXPIRED_SLUG.to_string()); + + assert!(unexpired_article.is_ok()); + assert_eq!(UNEXPIRED_SLUG, unexpired_article.unwrap().slug.as_str()); + + // The article was fetched due to a cache miss and the result cached. + { + let cache = FETCH_ARTICLE.0.write(); + assert_eq!(1, cache.cache_size()); + assert_eq!(cache.cache_hits(), Some(0)); + assert_eq!(cache.cache_misses(), Some(1)); + } + + let cached_article = fetch_article(UNEXPIRED_SLUG.to_string()); + assert!(cached_article.is_ok()); + assert_eq!(UNEXPIRED_SLUG, cached_article.unwrap().slug.as_str()); + + // The article was not fetched but returned as a hit from the cache. + { + let cache = FETCH_ARTICLE.0.write(); + assert_eq!(1, cache.cache_size()); + assert_eq!(cache.cache_hits(), Some(1)); + assert_eq!(cache.cache_misses(), Some(1)); + } +} + +#[test] +fn test_sized_cache_on_evict() { + use std::sync::Arc; + use std::sync::atomic::{AtomicU32, Ordering}; + let evicted_count = Arc::new(AtomicU32::new(0)); + let evicted_count_clone = evicted_count.clone(); + let mut cache = cached::LruCache::builder() + .max_size(2) + .on_evict(move |_k, _v| { + evicted_count_clone.fetch_add(1, Ordering::Relaxed); + }) + .build() + .unwrap(); + + cache.set(1, 10); + cache.set(2, 20); + assert_eq!(evicted_count.load(Ordering::Relaxed), 0); + cache.set(3, 30); + assert_eq!(evicted_count.load(Ordering::Relaxed), 1); +} + +#[test] +#[cfg(feature = "time_stores")] +fn test_timed_cache_on_evict() { + use std::sync::Arc; + use std::sync::atomic::{AtomicU32, Ordering}; + let evicted_count = Arc::new(AtomicU32::new(0)); + let evicted_count_clone = evicted_count.clone(); + let mut cache = cached::TtlCache::builder() + .ttl(cached::time::Duration::from_millis(100)) + .on_evict(move |_k, _v| { + evicted_count_clone.fetch_add(1, Ordering::Relaxed); + }) + .build() + .unwrap(); + + cache.set(1, 10); + assert_eq!(evicted_count.load(Ordering::Relaxed), 0); + std::thread::sleep(cached::time::Duration::from_millis(200)); + assert_eq!(cache.evict(), 1); + assert_eq!(evicted_count.load(Ordering::Relaxed), 1); + assert_eq!(cache.cache_evictions(), Some(1)); +} + +#[test] +#[cfg(feature = "time_stores")] +fn test_cache_evict_trait_returns_count() { + use cached::CacheEvict; + + let mut cache = cached::TtlCache::builder() + .ttl(cached::time::Duration::from_millis(20)) .build() .unwrap(); cache.cache_set(1, 10); @@ -4710,8 +5609,6 @@ fn test_timed_sized_expired_get_does_not_pollute_inner_metrics() { assert!(cache.cache_get(&1).is_none()); assert_eq!(cache.cache_hits(), Some(0)); assert_eq!(cache.cache_misses(), Some(1)); - assert_eq!(cache.store().cache_hits(), Some(0)); - assert_eq!(cache.store().cache_misses(), Some(0)); } #[test] @@ -4893,8 +5790,11 @@ fn test_fallible_builders_return_build_error() { .ttl(cached::time::Duration::ZERO) .build(); assert!( - matches!(zero_ttl.unwrap_err(), cached::BuildError::InvalidTtl { .. }), - "expected InvalidTtl" + matches!( + zero_ttl.unwrap_err(), + cached::BuildError::InvalidValue { field: "ttl", .. } + ), + "expected InvalidValue(ttl)" ); let zero_lru_ttl = cached::LruTtlCache::::builder() @@ -4904,9 +5804,9 @@ fn test_fallible_builders_return_build_error() { assert!( matches!( zero_lru_ttl.unwrap_err(), - cached::BuildError::InvalidTtl { .. } + cached::BuildError::InvalidValue { field: "ttl", .. } ), - "expected InvalidTtl" + "expected InvalidValue(ttl)" ); let zero_sorted_ttl = cached::TtlSortedCache::::builder() @@ -4915,13 +5815,13 @@ fn test_fallible_builders_return_build_error() { assert!( matches!( zero_sorted_ttl.unwrap_err(), - cached::BuildError::InvalidTtl { .. } + cached::BuildError::InvalidValue { field: "ttl", .. } ), - "expected InvalidTtl" + "expected InvalidValue(ttl)" ); } - let sharded_unbound = cached::ShardedCache::::builder() + let sharded_unbound = cached::ShardedUnboundCache::::builder() .shards(0) .build(); assert!( @@ -4936,38 +5836,34 @@ fn test_fallible_builders_return_build_error() { ); } -#[cfg(feature = "disk_store")] +#[cfg(feature = "redb_store")] #[test] -fn disk_cache_builder_aliases_and_zero_ttl_validation() { - // Canonical `RedbCache` name. - let result = cached::RedbCache::::builder("zero-ttl") +fn redb_cache_builder_zero_ttl_validation() { + // `RedbCache` rejects a zero TTL at build time. + let result = cached::RedbCache::::builder() + .name("zero-ttl") .ttl(cached::time::Duration::ZERO) .build(); assert!(matches!( result, - Err(cached::RedbCacheBuildError::InvalidTtl(..)) - )); - - // The kept `DiskCache` alias (and its `DiskCacheBuildError` alias) still - // compile and behave identically. - let result = cached::DiskCache::::builder("zero-ttl") - .ttl(cached::time::Duration::ZERO) - .build(); - assert!(matches!( - result, - Err(cached::DiskCacheBuildError::InvalidTtl(..)) + Err(cached::RedbCacheBuildError::Build( + cached::BuildError::InvalidValue { field: "ttl", .. } + )) )); } #[cfg(feature = "redis_store")] #[test] fn redis_cache_builder_aliases_and_zero_ttl_validation() { - let result = - cached::RedisCache::::builder("zero-ttl", cached::time::Duration::ZERO) - .build(); + let result = cached::RedisCache::::builder() + .prefix("zero-ttl") + .ttl(cached::time::Duration::ZERO) + .build(); assert!(matches!( result, - Err(cached::RedisCacheBuildError::InvalidTtl(..)) + Err(cached::RedisCacheBuildError::Build( + cached::BuildError::InvalidValue { field: "ttl", .. } + )) )); } @@ -5084,7 +5980,7 @@ async fn test_timed_cache_async_on_evict_fires() { cache.cache_set(1, 10); tokio::time::sleep(cached::time::Duration::from_millis(100)).await; - let val = CachedAsync::async_get_or_set_with(&mut cache, 1, || async { 99u32 }).await; + let val = CachedAsync::async_cache_get_or_set_with(&mut cache, 1, || async { 99u32 }).await; assert_eq!(*val, 99); assert_eq!( evicted_count.load(Ordering::Relaxed), @@ -5198,11 +6094,11 @@ fn test_expiring_value_cache_on_evict_fires_on_cache_get() { #[cfg(feature = "proc_macro")] #[test] fn test_unsync_reads_unbound_cache() { - UNSYNC_DOUBLE.write().cache_reset(); + UNSYNC_DOUBLE.0.write().cache_reset(); assert_eq!(4, unsync_double(2)); assert_eq!(4, unsync_double(2)); assert_eq!(10, unsync_double(5)); - let cache = UNSYNC_DOUBLE.read(); + let cache = UNSYNC_DOUBLE.0.read(); assert_eq!(2, cache.cache_size()); assert_eq!(1, cache.cache_hits().unwrap()); assert_eq!(2, cache.cache_misses().unwrap()); @@ -5246,14 +6142,14 @@ mod unsync_reads_ttl_sorted { #[test] fn test_unsync_reads_ttl_sorted_cache() { CALL_COUNT.store(0, Ordering::SeqCst); - UNSYNC_TTL_SORTED.write().cache_reset(); + UNSYNC_TTL_SORTED.0.write().cache_reset(); assert_eq!(unsync_ttl_sorted(4), 12); assert_eq!(unsync_ttl_sorted(4), 12); // cache hit — body not re-run assert_eq!(unsync_ttl_sorted(5), 15); assert_eq!(CALL_COUNT.load(Ordering::SeqCst), 2); - let cache = UNSYNC_TTL_SORTED.read(); + let cache = UNSYNC_TTL_SORTED.0.read(); assert_eq!(cache.cache_size(), 2); assert_eq!(cache.cache_hits(), Some(1)); assert_eq!(cache.cache_misses(), Some(2)); @@ -5273,7 +6169,10 @@ fn test_ttl_cache_zero_ttl() { .ttl(Duration::from_nanos(0)) .build() .unwrap_err(); - assert!(matches!(err, cached::BuildError::InvalidTtl { .. })); + assert!(matches!( + err, + cached::BuildError::InvalidValue { field: "ttl", .. } + )); } #[cfg(feature = "time_stores")] @@ -5286,7 +6185,10 @@ fn test_lru_ttl_cache_zero_ttl() { .ttl(Duration::from_nanos(0)) .build() .unwrap_err(); - assert!(matches!(err, cached::BuildError::InvalidTtl { .. })); + assert!(matches!( + err, + cached::BuildError::InvalidValue { field: "ttl", .. } + )); } #[cfg(feature = "time_stores")] @@ -5412,13 +6314,13 @@ fn test_unbound_cache_on_evict_fires_on_remove() { cache.cache_set(2, 200); assert_eq!(fired.load(Ordering::Relaxed), 0); - cache.cache_remove(&1u32); + let _ = cache.cache_remove(&1u32); assert_eq!(fired.load(Ordering::Relaxed), 1); - cache.cache_remove(&99u32); // not present — on_evict should NOT fire + let _ = cache.cache_remove(&99u32); // not present — on_evict should NOT fire assert_eq!(fired.load(Ordering::Relaxed), 1); - cache.cache_remove(&2u32); + let _ = cache.cache_remove(&2u32); assert_eq!(fired.load(Ordering::Relaxed), 2); } @@ -5525,7 +6427,7 @@ fn test_ttl_sorted_cache_clone_cached() { assert!(!expired); } -#[cfg(all(feature = "time_stores", feature = "async_tokio_rt_multi_thread"))] +#[cfg(all(feature = "time_stores", feature = "async"))] #[tokio::test] async fn test_ttl_sorted_cache_cached_async() { use cached::CachedAsync; @@ -5536,15 +6438,15 @@ async fn test_ttl_sorted_cache_cached_async() { .build() .unwrap(); - let val = CachedAsync::async_get_or_set_with(&mut cache, 1u32, || async { 42u32 }).await; + let val = CachedAsync::async_cache_get_or_set_with(&mut cache, 1u32, || async { 42u32 }).await; assert_eq!(*val, 42); // Second call returns cached value. - let val2 = CachedAsync::async_get_or_set_with(&mut cache, 1u32, || async { 99u32 }).await; + let val2 = CachedAsync::async_cache_get_or_set_with(&mut cache, 1u32, || async { 99u32 }).await; assert_eq!(*val2, 42); } -#[cfg(feature = "async_tokio_rt_multi_thread")] +#[cfg(feature = "async")] #[tokio::test] async fn test_expiring_lru_cache_cached_async() { use cached::CachedAsync; @@ -5563,12 +6465,14 @@ async fn test_expiring_lru_cache_cached_async() { .unwrap(); let val = - CachedAsync::async_get_or_set_with(&mut cache, 1u32, || async { NeverExpires(42) }).await; + CachedAsync::async_cache_get_or_set_with(&mut cache, 1u32, || async { NeverExpires(42) }) + .await; assert_eq!(val.0, 42); // Cache hit: factory not called. let val2 = - CachedAsync::async_get_or_set_with(&mut cache, 1u32, || async { NeverExpires(99) }).await; + CachedAsync::async_cache_get_or_set_with(&mut cache, 1u32, || async { NeverExpires(99) }) + .await; assert_eq!(val2.0, 42); assert_eq!(cache.cache_hits(), Some(1)); @@ -5627,7 +6531,7 @@ fn test_ttl_cache_builder_build() { #[test] #[cfg(feature = "time_stores")] fn test_lru_ttl_cache_builder_build() { - use cached::{Cached, LruTtlCache}; + use cached::{CacheTtl, Cached, LruTtlCache}; let mut cache = LruTtlCache::::builder() .max_size(4) .ttl(Duration::from_secs(60)) @@ -5636,7 +6540,7 @@ fn test_lru_ttl_cache_builder_build() { .unwrap(); cache.cache_set(1, 10); assert_eq!(cache.cache_get(&1), Some(&10)); - assert!(cache.refresh_on_hit()); + assert!(CacheTtl::refresh_on_hit(&cache)); } #[test] @@ -5652,47 +6556,116 @@ fn test_ttl_sorted_cache_builder_build() { assert_eq!(cache.cache_get(&1), Some(&10)); } -// ── `store()` getter ─────────────────────────────────────────────────────────── +// ── `store()` getter removed; public API covers the same assertions ──────────── #[test] #[cfg(feature = "time_stores")] -fn test_ttl_cache_store_getter() { +fn test_ttl_cache_size_and_get() { use cached::{Cached, TtlCache}; let mut cache = TtlCache::::builder() .ttl(Duration::from_secs(60)) .build() .unwrap(); cache.cache_set(1, 10); - // store() gives direct access to the underlying HashMap> - assert_eq!(cache.store().len(), 1); - assert!(cache.store().contains_key(&1)); + // cache_size() and cache_get() replace direct store() introspection. + assert_eq!(cache.cache_size(), 1); + assert_eq!(cache.cache_get(&1), Some(&10)); } #[test] -fn test_unbound_cache_store_getter() { +fn test_unbound_cache_size() { use cached::Cached; let mut cache = UnboundCache::::builder().build().unwrap(); cache.cache_set(1, 10); cache.cache_set(2, 20); - assert_eq!(cache.store().len(), 2); + assert_eq!(cache.cache_size(), 2); } -// ── `refresh_on_hit()` getter and `set_refresh_on_hit()` setter ────────────── +// ── `CacheTtl::refresh_on_hit()` and `CacheTtl::set_refresh_on_hit()` ──────── +// Confirms the inherent shadowing methods are gone and the trait methods work. +// `set_refresh_on_hit` now returns the PREVIOUS value (trait contract). #[test] #[cfg(feature = "time_stores")] fn test_ttl_cache_refresh_getter_and_setter() { - use cached::TtlCache; + use cached::{CacheTtl, TtlCache}; let mut cache = TtlCache::::builder() .ttl(Duration::from_secs(60)) .refresh_on_hit(false) .build() .unwrap(); - assert!(!cache.refresh_on_hit()); - cache.set_refresh_on_hit(true); - assert!(cache.refresh_on_hit()); - cache.set_refresh_on_hit(false); - assert!(!cache.refresh_on_hit()); + assert!(!CacheTtl::refresh_on_hit(&cache)); + // set_refresh_on_hit returns the PREVIOUS value. + let prev = CacheTtl::set_refresh_on_hit(&mut cache, true); + assert!(!prev, "previous value was false"); + assert!(CacheTtl::refresh_on_hit(&cache)); + let prev = CacheTtl::set_refresh_on_hit(&mut cache, false); + assert!(prev, "previous value was true"); + assert!(!CacheTtl::refresh_on_hit(&cache)); +} + +// Same contract for LruTtlCache. +#[test] +#[cfg(feature = "time_stores")] +fn test_lru_ttl_cache_refresh_getter_and_setter() { + use cached::{CacheTtl, LruTtlCache}; + let mut cache = LruTtlCache::::builder() + .max_size(4) + .ttl(Duration::from_secs(60)) + .refresh_on_hit(false) + .build() + .unwrap(); + assert!(!CacheTtl::refresh_on_hit(&cache)); + // set_refresh_on_hit returns the PREVIOUS value. + let prev = CacheTtl::set_refresh_on_hit(&mut cache, true); + assert!(!prev, "previous value was false"); + assert!(CacheTtl::refresh_on_hit(&cache)); + let prev = CacheTtl::set_refresh_on_hit(&mut cache, false); + assert!(prev, "previous value was true"); + assert!(!CacheTtl::refresh_on_hit(&cache)); +} + +// Builder-time `refresh_on_hit(true)` must be reflected by the getter on BOTH +// timed stores (the round-trip tests above start from `false`; this pins the +// `true` builder default through to `CacheTtl::refresh_on_hit`). +#[test] +#[cfg(feature = "time_stores")] +fn test_refresh_on_hit_builder_true_reflected_on_both_stores() { + use cached::{CacheTtl, LruTtlCache, TtlCache}; + + let ttl = TtlCache::::builder() + .ttl(Duration::from_secs(60)) + .refresh_on_hit(true) + .build() + .unwrap(); + assert!( + CacheTtl::refresh_on_hit(&ttl), + "TtlCache builder refresh_on_hit(true) must be reflected" + ); + + let lru_ttl = LruTtlCache::::builder() + .max_size(4) + .ttl(Duration::from_secs(60)) + .refresh_on_hit(true) + .build() + .unwrap(); + assert!( + CacheTtl::refresh_on_hit(&lru_ttl), + "LruTtlCache builder refresh_on_hit(true) must be reflected" + ); + + // And the unset builder default is `false` on both. + let ttl_default = TtlCache::::builder() + .ttl(Duration::from_secs(60)) + .build() + .unwrap(); + assert!(!CacheTtl::refresh_on_hit(&ttl_default)); + let lru_default = LruTtlCache::::builder() + .max_size(4) + .ttl(Duration::from_secs(60)) + .build() + .unwrap(); + assert!(!CacheTtl::refresh_on_hit(&lru_default)); } // ── CachedIter ──────────────────────────────────────────────────────────────── @@ -6124,8 +7097,10 @@ mod generic_where_tests { } } - impl cached::ConcurrentCached for TestStore { + impl cached::ConcurrentCacheBase for TestStore { type Error = std::convert::Infallible; + } + impl cached::ConcurrentCached for TestStore { fn cache_get(&self, k: &String) -> Result, Self::Error> { Ok(self.inner.lock().unwrap().get(k).cloned()) } @@ -6138,8 +7113,12 @@ mod generic_where_tests { fn cache_remove_entry(&self, k: &String) -> Result, Self::Error> { Ok(self.inner.lock().unwrap().remove_entry(k)) } - fn set_refresh_on_hit(&self, _refresh: bool) -> bool { - false + fn cache_clear(&self) -> Result<(), Self::Error> { + self.inner.lock().unwrap().clear(); + Ok(()) + } + fn cache_reset(&self) -> Result<(), Self::Error> { + self.cache_clear() } } @@ -6263,22 +7242,17 @@ fn test_ttl_sorted_cache_try_size_limit() { // Error: size of zero is invalid let err = cache.try_set_max_size(0); - assert!(err.is_err(), "zero size limit must fail"); - assert_eq!(err.unwrap_err().kind(), std::io::ErrorKind::InvalidInput); + assert_eq!(err, Err(cached::SetMaxSizeError::ZeroSize)); } // ── result_fallback async ───────────────────────────────────────────────────── -#[cfg(all( - feature = "proc_macro", - feature = "time_stores", - feature = "async_tokio_rt_multi_thread" -))] +#[cfg(all(feature = "proc_macro", feature = "time_stores", feature = "async"))] mod result_fallback_async_tests { use super::sleep; use cached::time::Duration; - #[cached::macros::cached(ttl = 1, result_fallback = true)] + #[cached::macros::cached(ttl_secs = 1, result_fallback = true)] async fn async_always_failing() -> Result { Err(()) } @@ -6397,9 +7371,6 @@ fn test_expiring_lru_cache_get_does_not_inflate_inner_metrics() { assert!(cache.cache_get(&1).is_some()); assert_eq!(cache.cache_hits(), Some(1)); assert_eq!(cache.cache_misses(), Some(0)); - // Inner LruCache metrics must not be incremented — ExpiringLruCache manages its own. - assert_eq!(cache.store().cache_hits(), Some(0)); - assert_eq!(cache.store().cache_misses(), Some(0)); } #[test] @@ -6446,9 +7417,9 @@ mod macro_arg_pairwise { use super::*; use std::sync::atomic::{AtomicUsize, Ordering}; - // name + unbound: custom static identifier, explicit unbound store. + // name: custom static identifier with the default UnboundCache. // Default sync_lock is RwLock, so the named static is read via `.write()`. - #[cached(name = "PAIRWISE_NAMED_UNBOUND", unbound)] + #[cached(name = "PAIRWISE_NAMED_UNBOUND")] fn named_unbound(n: u32) -> u32 { n + 1 } @@ -6457,7 +7428,7 @@ mod macro_arg_pairwise { fn test_name_with_unbound() { assert_eq!(named_unbound(2), 3); assert_eq!(named_unbound(2), 3); - let cache = PAIRWISE_NAMED_UNBOUND.write(); + let cache = PAIRWISE_NAMED_UNBOUND.0.write(); assert_eq!(cache.cache_hits(), Some(1)); assert_eq!(cache.cache_misses(), Some(1)); } @@ -6472,7 +7443,7 @@ mod macro_arg_pairwise { fn test_size_with_sync_lock_mutex() { assert_eq!(sized_mutex(3), 6); assert_eq!(sized_mutex(3), 6); - let cache = SIZED_MUTEX.lock(); + let cache = SIZED_MUTEX.0.lock(); assert_eq!(cache.cache_hits(), Some(1)); assert_eq!(cache.cache_misses(), Some(1)); } @@ -6516,8 +7487,8 @@ mod macro_arg_pairwise { assert_eq!(sync_lock_snake(1), 2); // `.write()` only exists on the RwLock wrapper; compiling+passing here // proves both spellings resolved to RwLock. - assert_eq!(SYNC_LOCK_DOC_SPELLING.write().cache_misses(), Some(1)); - assert_eq!(SYNC_LOCK_SNAKE_SPELLING.write().cache_misses(), Some(1)); + assert_eq!(SYNC_LOCK_DOC_SPELLING.0.write().cache_misses(), Some(1)); + assert_eq!(SYNC_LOCK_SNAKE_SPELLING.0.write().cache_misses(), Some(1)); } // sync_writes = "by_key" + explicit non-default sync_writes_buckets. @@ -6598,7 +7569,7 @@ mod macro_arg_pairwise { // once + name + ttl (pairwise; the TTL store requires `time_stores`). #[cfg(feature = "time_stores")] - #[once(name = "PAIRWISE_ONCE_NAMED_TTL", ttl = 100)] + #[once(name = "PAIRWISE_ONCE_NAMED_TTL", ttl_secs = 100)] fn once_named_ttl(n: u32) -> u32 { n + 3 } @@ -6633,7 +7604,7 @@ mod async_cache_store_tests { let calls = Arc::new(AtomicUsize::new(0)); let calls_clone = calls.clone(); let val = cache - .async_get_or_set_with(1, || { + .async_cache_get_or_set_with(1, || { let calls = calls_clone.clone(); async move { calls.fetch_add(1, Ordering::Relaxed); @@ -6646,7 +7617,7 @@ mod async_cache_store_tests { // Get from cache let val = cache - .async_get_or_set_with(1, || async { + .async_cache_get_or_set_with(1, || async { calls.fetch_add(1, Ordering::Relaxed); "world".to_string() }) @@ -6658,7 +7629,7 @@ mod async_cache_store_tests { tokio::time::sleep(tokio::time::Duration::from_millis(60)).await; let val = cache - .async_get_or_set_with(1, || async { + .async_cache_get_or_set_with(1, || async { calls.fetch_add(1, Ordering::Relaxed); "world".to_string() }) @@ -6680,7 +7651,7 @@ mod async_cache_store_tests { .unwrap(); let val = cache - .async_try_get_or_set_with(1, || async { Ok::<_, ()>("hello".to_string()) }) + .async_cache_try_get_or_set_with(1, || async { Ok::<_, ()>("hello".to_string()) }) .await .unwrap(); assert_eq!(val, "hello"); @@ -6691,7 +7662,7 @@ mod async_cache_store_tests { // Try get or set with a new value, triggers evict on old expired value let val = cache - .async_try_get_or_set_with(1, || async { Ok::<_, ()>("world".to_string()) }) + .async_cache_try_get_or_set_with(1, || async { Ok::<_, ()>("world".to_string()) }) .await .unwrap(); assert_eq!(val, "world"); @@ -6712,16 +7683,16 @@ mod async_cache_store_tests { .unwrap(); cache - .async_get_or_set_with(1, || async { "one".to_string() }) + .async_cache_get_or_set_with(1, || async { "one".to_string() }) .await; cache - .async_get_or_set_with(2, || async { "two".to_string() }) + .async_cache_get_or_set_with(2, || async { "two".to_string() }) .await; assert_eq!(evicted.load(Ordering::Relaxed), 0); // Trigger LRU eviction by size limit cache - .async_get_or_set_with(3, || async { "three".to_string() }) + .async_cache_get_or_set_with(3, || async { "three".to_string() }) .await; assert_eq!(evicted.load(Ordering::Relaxed), 1); @@ -6730,7 +7701,7 @@ mod async_cache_store_tests { // Trigger evict on expired value cache - .async_get_or_set_with(3, || async { "new_three".to_string() }) + .async_cache_get_or_set_with(3, || async { "new_three".to_string() }) .await; assert_eq!(evicted.load(Ordering::Relaxed), 2); } @@ -6748,14 +7719,14 @@ mod async_cache_store_tests { .unwrap(); cache - .async_get_or_set_with(1, || async { "one".to_string() }) + .async_cache_get_or_set_with(1, || async { "one".to_string() }) .await; assert_eq!(evicted.load(Ordering::Relaxed), 0); tokio::time::sleep(tokio::time::Duration::from_millis(60)).await; cache - .async_get_or_set_with(1, || async { "new_one".to_string() }) + .async_cache_get_or_set_with(1, || async { "new_one".to_string() }) .await; assert_eq!(evicted.load(Ordering::Relaxed), 1); } @@ -6783,13 +7754,13 @@ mod async_cache_store_tests { .unwrap(); cache - .async_get_or_set_with(1, || async { ExpiringVal { expired: true } }) + .async_cache_get_or_set_with(1, || async { ExpiringVal { expired: true } }) .await; assert_eq!(evicted.load(Ordering::Relaxed), 0); // Fetching it when expired triggers eviction cache - .async_get_or_set_with(1, || async { ExpiringVal { expired: false } }) + .async_cache_get_or_set_with(1, || async { ExpiringVal { expired: false } }) .await; assert_eq!(evicted.load(Ordering::Relaxed), 1); } @@ -6798,8 +7769,346 @@ mod async_cache_store_tests { async fn test_unbound_cache_async() { let mut cache = UnboundCache::builder().build().unwrap(); let val = cache - .async_get_or_set_with(1, || async { "hello".to_string() }) + .async_cache_get_or_set_with(1, || async { "hello".to_string() }) .await; assert_eq!(val, "hello"); } } + +// --- len / iter / evict contract tests (spec 0002) --- +// +// These tests assert the documented contract: +// - `len()` returns the raw stored count; on lazy-eviction stores it may include +// expired-but-not-yet-swept entries. +// - `iter().count()` omits expired entries but does not remove them. +// - `evict()` physically removes expired entries; afterwards `len()` reflects only +// live entries. + +#[cfg(feature = "time_stores")] +mod len_iter_evict_contract { + use cached::time::Duration; + use cached::{CachedExt, CachedIter, LruTtlCache, TtlCache, TtlSortedCache}; + + /// TtlCache: an expired entry is visible in `len()` but omitted from `iter()`; + /// `evict()` removes it so `len()` drops to the live count. + #[test] + fn ttl_cache_len_iter_evict() { + let mut cache: TtlCache = TtlCache::builder() + .ttl(Duration::from_millis(1)) + .build() + .unwrap(); + cache.set(1, 10); + cache.set(2, 20); + + // Wait for entries to expire. + std::thread::sleep(std::time::Duration::from_millis(10)); + + // len() counts both expired entries - no eviction scan. + assert_eq!( + cache.len(), + 2, + "len() must count expired-but-unswept entries" + ); + + // iter() omits expired entries without removing them. + assert_eq!( + cache.iter().count(), + 0, + "iter().count() must omit expired entries" + ); + + // len() is still 2 because iter() did not remove anything. + assert_eq!( + cache.len(), + 2, + "len() must remain unchanged after iter() - iter does not remove entries" + ); + + // evict() physically removes the expired entries. + let removed = cache.evict(); + assert_eq!( + removed, 2, + "evict() must return the number of removed entries" + ); + assert_eq!( + cache.len(), + 0, + "len() must reflect only live entries after evict()" + ); + } + + /// LruTtlCache: same contract as TtlCache. + #[test] + fn lru_ttl_cache_len_iter_evict() { + let mut cache: LruTtlCache = LruTtlCache::builder() + .max_size(10) + .ttl(Duration::from_millis(1)) + .build() + .unwrap(); + cache.set(1, 10); + cache.set(2, 20); + + std::thread::sleep(std::time::Duration::from_millis(10)); + + assert_eq!( + cache.len(), + 2, + "len() must count expired-but-unswept entries" + ); + assert_eq!( + cache.iter().count(), + 0, + "iter().count() must omit expired entries" + ); + assert_eq!(cache.len(), 2, "len() must remain unchanged after iter()"); + + let removed = cache.evict(); + assert_eq!(removed, 2); + assert_eq!( + cache.len(), + 0, + "len() must reflect only live entries after evict()" + ); + } + + /// TtlSortedCache: same contract. + #[test] + fn ttl_sorted_cache_len_iter_evict() { + let mut cache: TtlSortedCache = TtlSortedCache::builder() + .ttl(Duration::from_millis(1)) + .build() + .unwrap(); + cache.set(1, 10); + cache.set(2, 20); + + std::thread::sleep(std::time::Duration::from_millis(10)); + + assert_eq!( + cache.len(), + 2, + "len() must count expired-but-unswept entries" + ); + assert_eq!( + cache.iter().count(), + 0, + "iter().count() must omit expired entries" + ); + assert_eq!(cache.len(), 2, "len() must remain unchanged after iter()"); + + let removed = cache.evict(); + assert_eq!(removed, 2); + assert_eq!( + cache.len(), + 0, + "len() must reflect only live entries after evict()" + ); + } +} + +mod len_iter_evict_contract_expiring { + use cached::{CachedExt, CachedIter, Expires, ExpiringCache, ExpiringLruCache}; + + #[derive(Clone)] + struct Val { + expired: bool, + } + + impl Expires for Val { + fn is_expired(&self) -> bool { + self.expired + } + } + + /// ExpiringCache: a value that reports `is_expired() == true` is visible in `len()` + /// but omitted from `iter()`; `evict()` removes it. + #[test] + fn expiring_cache_len_iter_evict() { + let mut cache: ExpiringCache = ExpiringCache::builder().build().unwrap(); + cache.set(1, Val { expired: false }); // live + cache.set(2, Val { expired: true }); // already expired + + // Both entries are stored; len() reports 2. + assert_eq!( + cache.len(), + 2, + "len() must count expired-but-unswept entries" + ); + + // iter() omits the expired entry without removing it. + assert_eq!( + cache.iter().count(), + 1, + "iter().count() must omit expired entries" + ); + assert_eq!(cache.len(), 2, "len() must remain unchanged after iter()"); + + // evict() removes the one expired entry. + let removed = cache.evict(); + assert_eq!(removed, 1, "evict() must return count of removed entries"); + assert_eq!( + cache.len(), + 1, + "len() must reflect only live entries after evict()" + ); + assert_eq!( + cache.iter().count(), + 1, + "iter().count() must match len() after evict()" + ); + } + + /// ExpiringLruCache: same contract. + #[test] + fn expiring_lru_cache_len_iter_evict() { + let mut cache: ExpiringLruCache = + ExpiringLruCache::builder().max_size(10).build().unwrap(); + cache.set(1, Val { expired: false }); + cache.set(2, Val { expired: true }); + + assert_eq!( + cache.len(), + 2, + "len() must count expired-but-unswept entries" + ); + assert_eq!( + cache.iter().count(), + 1, + "iter().count() must omit expired entries" + ); + assert_eq!(cache.len(), 2, "len() must remain unchanged after iter()"); + + let removed = cache.evict(); + assert_eq!(removed, 1); + assert_eq!( + cache.len(), + 1, + "len() must reflect only live entries after evict()" + ); + } +} + +#[cfg(feature = "time_stores")] +mod len_iter_evict_contract_sharded { + use cached::time::Duration; + use cached::{ShardedLruTtlCache, ShardedTtlCache}; + + /// ShardedTtlCache: `len()` on the inherent method may count expired-but-unswept + /// entries; `evict()` removes them and the inherent `len()` then drops to the live count. + #[test] + fn sharded_ttl_cache_len_evict() { + let cache: ShardedTtlCache = ShardedTtlCache::builder() + .ttl(Duration::from_millis(1)) + .build() + .unwrap(); + cache.set(1, 10); + cache.set(2, 20); + + std::thread::sleep(std::time::Duration::from_millis(10)); + + // Inherent len() counts all stored entries regardless of expiry. + assert_eq!( + cache.len(), + 2, + "len() must count expired-but-unswept entries" + ); + + let removed = cache.evict(); + assert_eq!(removed, 2, "evict() must return count of removed entries"); + assert_eq!( + cache.len(), + 0, + "len() must reflect only live entries after evict()" + ); + } + + /// ShardedLruTtlCache: same contract. + #[test] + fn sharded_lru_ttl_cache_len_evict() { + let cache: ShardedLruTtlCache = ShardedLruTtlCache::builder() + .max_size(10) + .ttl(Duration::from_millis(1)) + .build() + .unwrap(); + cache.set(1, 10); + cache.set(2, 20); + + std::thread::sleep(std::time::Duration::from_millis(10)); + + assert_eq!( + cache.len(), + 2, + "len() must count expired-but-unswept entries" + ); + + let removed = cache.evict(); + assert_eq!(removed, 2); + assert_eq!( + cache.len(), + 0, + "len() must reflect only live entries after evict()" + ); + } +} + +mod len_iter_evict_contract_sharded_expiring { + use cached::{Expires, ShardedExpiringCache, ShardedExpiringLruCache}; + + #[derive(Clone)] + struct Val { + expired: bool, + } + + impl Expires for Val { + fn is_expired(&self) -> bool { + self.expired + } + } + + /// ShardedExpiringCache: `len()` counts expired-but-unswept entries; `evict()` removes them. + #[test] + fn sharded_expiring_cache_len_evict() { + let cache: ShardedExpiringCache = + ShardedExpiringCache::builder().build().unwrap(); + cache.set(1, Val { expired: false }); + cache.set(2, Val { expired: true }); + + assert_eq!( + cache.len(), + 2, + "len() must count expired-but-unswept entries" + ); + + let removed = cache.evict(); + assert_eq!(removed, 1, "evict() must remove only expired entries"); + assert_eq!( + cache.len(), + 1, + "len() must reflect only live entries after evict()" + ); + } + + /// ShardedExpiringLruCache: same contract. + #[test] + fn sharded_expiring_lru_cache_len_evict() { + let cache: ShardedExpiringLruCache = ShardedExpiringLruCache::builder() + .max_size(10) + .build() + .unwrap(); + cache.set(1, Val { expired: false }); + cache.set(2, Val { expired: true }); + + assert_eq!( + cache.len(), + 2, + "len() must count expired-but-unswept entries" + ); + + let removed = cache.evict(); + assert_eq!(removed, 1); + assert_eq!( + cache.len(), + 1, + "len() must reflect only live entries after evict()" + ); + } +} diff --git a/tests/proc_macro_v3_ui.rs b/tests/proc_macro_v3_ui.rs new file mode 100644 index 00000000..ef97d22d --- /dev/null +++ b/tests/proc_macro_v3_ui.rs @@ -0,0 +1,36 @@ +/*! +Trybuild compile-fail goldens for the 3.0 proc-macro-crate attribute validations. + +Covers: +- I7: `#[cached(refresh = true)]` rejected when no TTL is set. +- I6: the remaining `#[cached]`-only attributes rejected on `#[once]` + (`result_fallback`, `refresh`, `max_size`, `ty`, `create`, `key`, `convert`). +- 0013: concurrent-store-only attributes (`disk`, `redis`, `map_error`) rejected + on `#[cached]` and `#[once]` with a friendly message pointing to + `#[concurrent_cached]`. + +All fire during macro expansion before any feature-gated store type is emitted, +so `proc_macro` alone is sufficient (no `time_stores` needed). +*/ + +#![cfg(feature = "proc_macro")] + +#[test] +fn compile_fail_proc_macro_v3() { + let t = trybuild::TestCases::new(); + // I7: refresh requires a TTL on `#[cached]`. + t.compile_fail("tests/ui/cached_refresh_requires_ttl.rs"); + // I6: `#[cached]`-only attributes rejected on `#[once]`. + t.compile_fail("tests/ui/once_result_fallback_rejected.rs"); + t.compile_fail("tests/ui/once_refresh_rejected.rs"); + t.compile_fail("tests/ui/once_max_size_rejected.rs"); + t.compile_fail("tests/ui/once_ty_rejected.rs"); + t.compile_fail("tests/ui/once_create_rejected.rs"); + t.compile_fail("tests/ui/once_key_rejected.rs"); + t.compile_fail("tests/ui/once_convert_rejected.rs"); + // 0013: concurrent-store-only attributes rejected on `#[cached]` and `#[once]`. + t.compile_fail("tests/ui/cached_disk_concurrent_only.rs"); + t.compile_fail("tests/ui/cached_redis_concurrent_only.rs"); + t.compile_fail("tests/ui/once_disk_concurrent_only.rs"); + t.compile_fail("tests/ui/once_redis_concurrent_only.rs"); +} diff --git a/tests/serialize_set_dispatch.rs b/tests/serialize_set_dispatch.rs new file mode 100644 index 00000000..4934b899 --- /dev/null +++ b/tests/serialize_set_dispatch.rs @@ -0,0 +1,604 @@ +//! Proves that the `#[concurrent_cached]` autoref shim (`cached::__set_dispatch`) +//! picks the borrowed `SerializeCached::cache_set_ref` arm for stores that implement +//! `SerializeCached`, and falls back to the owned `ConcurrentCached::cache_set` (cloning +//! the value) for stores that do not. Clone counts are the observable signal. +//! +//! Part 3: sync clone-elision proof with custom in-memory stores. +//! Part 4: async clone-elision proof with custom `SerializeCachedAsync` vs +//! `ConcurrentCachedAsync`-only stores (0 clones on the borrowed arm, 1 on the fallback). + +#![cfg(feature = "proc_macro")] + +use std::sync::atomic::{AtomicUsize, Ordering}; + +static CLONES: AtomicUsize = AtomicUsize::new(0); + +/// A value type that counts every Clone invocation via the global `CLONES` counter. +#[derive(PartialEq, Debug)] +struct Counted(u32); + +impl Clone for Counted { + fn clone(&self) -> Self { + CLONES.fetch_add(1, Ordering::SeqCst); + Counted(self.0) + } +} + +// Manual serialization: just store the u32 as a string. No serde needed. +fn counted_to_string(v: &Counted) -> String { + v.0.to_string() +} + +fn counted_from_str(s: &str) -> Counted { + Counted(s.parse().expect("parse Counted")) +} + +// --------------------------------------------------------------------------- +// SerStore: implements both ConcurrentCached AND SerializeCached. +// Backing storage is HashMap; cache_set_ref serializes from &V +// without ever cloning Counted. +// --------------------------------------------------------------------------- + +mod stores { + use super::{Counted, counted_from_str, counted_to_string}; + use cached::{ConcurrentCacheBase, ConcurrentCached, SerializeCached}; + use std::collections::HashMap; + use std::convert::Infallible; + use std::sync::Mutex; + + pub struct SerStore { + map: Mutex>, + } + + impl SerStore { + pub fn new() -> Self { + SerStore { + map: Mutex::new(HashMap::new()), + } + } + } + + impl ConcurrentCacheBase for SerStore { + type Error = Infallible; + } + + impl ConcurrentCached for SerStore { + fn cache_get(&self, k: &u32) -> Result, Infallible> { + let map = self.map.lock().unwrap(); + Ok(map.get(k).map(|s| counted_from_str(s))) + } + + fn cache_set(&self, k: u32, v: Counted) -> Result, Infallible> { + let s = counted_to_string(&v); + let mut map = self.map.lock().unwrap(); + Ok(map.insert(k, s).map(|s| counted_from_str(&s))) + } + + fn cache_remove(&self, k: &u32) -> Result, Infallible> { + let mut map = self.map.lock().unwrap(); + Ok(map.remove(k).map(|s| counted_from_str(&s))) + } + + fn cache_remove_entry(&self, k: &u32) -> Result, Infallible> { + let mut map = self.map.lock().unwrap(); + Ok(map.remove_entry(k).map(|(k, s)| (k, counted_from_str(&s)))) + } + + fn cache_clear(&self) -> Result<(), Infallible> { + self.map.lock().unwrap().clear(); + Ok(()) + } + + fn cache_reset(&self) -> Result<(), Infallible> { + self.cache_clear() + } + } + + impl SerializeCached for SerStore { + /// Serialize from &Counted — never clones Counted. + fn cache_set_ref(&self, k: &u32, v: &Counted) -> Result, Infallible> { + let s = counted_to_string(v); + let mut map = self.map.lock().unwrap(); + Ok(map.insert(*k, s).map(|s| counted_from_str(&s))) + } + } + + // --------------------------------------------------------------------------- + // OwnedStore: implements ONLY ConcurrentCached (NOT SerializeCached). + // Backing storage is HashMap; cache_set stores the owned value. + // Since SerializeCached is not implemented, the shim falls back to the owned + // arm which clones the value once before calling cache_set. + // --------------------------------------------------------------------------- + + pub struct OwnedStore { + map: Mutex>, + } + + impl OwnedStore { + pub fn new() -> Self { + OwnedStore { + map: Mutex::new(HashMap::new()), + } + } + } + + impl ConcurrentCacheBase for OwnedStore { + type Error = Infallible; + } + + impl ConcurrentCached for OwnedStore { + fn cache_get(&self, k: &u32) -> Result, Infallible> { + // Reading requires a Clone to return an owned value from the locked map. + // We reset CLONES before each test and only care about the set-path clone. + let map = self.map.lock().unwrap(); + Ok(map.get(k).cloned()) + } + + fn cache_set(&self, k: u32, v: Counted) -> Result, Infallible> { + let mut map = self.map.lock().unwrap(); + Ok(map.insert(k, v)) + } + + fn cache_remove(&self, k: &u32) -> Result, Infallible> { + let mut map = self.map.lock().unwrap(); + Ok(map.remove(k)) + } + + fn cache_remove_entry(&self, k: &u32) -> Result, Infallible> { + let mut map = self.map.lock().unwrap(); + Ok(map.remove_entry(k)) + } + + fn cache_clear(&self) -> Result<(), Infallible> { + self.map.lock().unwrap().clear(); + Ok(()) + } + + fn cache_reset(&self) -> Result<(), Infallible> { + self.cache_clear() + } + } + + // OwnedStore intentionally does NOT impl SerializeCached. +} + +// --------------------------------------------------------------------------- +// Memoized functions using the two stores. +// --------------------------------------------------------------------------- + +mod fns { + use super::Counted; + use super::stores::{OwnedStore, SerStore}; + use cached::macros::concurrent_cached; + + #[concurrent_cached( + ty = "SerStore", + create = "{ SerStore::new() }", + map_error = "|e| e", + key = "u32", + convert = "{ n }" + )] + pub fn via_serialize(n: u32) -> Result { + Ok(Counted(n)) + } + + #[concurrent_cached( + ty = "OwnedStore", + create = "{ OwnedStore::new() }", + map_error = "|e| e", + key = "u32", + convert = "{ n }" + )] + pub fn via_owned(n: u32) -> Result { + Ok(Counted(n)) + } +} + +// Serialize each clone-counting test to avoid interference from parallel test +// execution sharing the global CLONES counter. +mod clone_count_tests { + use super::*; + use cached::ConcurrentCached; + use fns::{VIA_OWNED, VIA_SERIALIZE, via_owned, via_serialize}; + use std::sync::Mutex; + + // A global mutex to serialize the two clone-counting tests so they don't + // interleave and corrupt the shared CLONES counter. + static CLONE_TEST_LOCK: Mutex<()> = Mutex::new(()); + + /// Proves the borrowed arm (SerializeCached) is taken: no Clone of Counted occurs + /// at the set site, so CLONES stays 0 after a cache miss. + #[test] + fn serialize_store_skips_value_clone() { + let _guard = CLONE_TEST_LOCK.lock().unwrap(); + CLONES.store(0, Ordering::SeqCst); + VIA_SERIALIZE.cache_clear().unwrap(); + + // First call: cache miss; body runs, result stored via cache_set_ref (&Counted). + let v = via_serialize(7).unwrap(); + assert_eq!(v, Counted(7)); + + // The borrowed setter serialized from &Counted — no Clone. + assert_eq!( + CLONES.load(Ordering::SeqCst), + 0, + "SerializeCached path must not clone the value at the set site" + ); + + // Second call: cache hit; deserializes to a fresh Counted (via counted_from_str, + // not Clone), so CLONES is still 0. + CLONES.store(0, Ordering::SeqCst); + let hit = via_serialize(7).unwrap(); + assert_eq!(hit, Counted(7)); + assert_eq!( + CLONES.load(Ordering::SeqCst), + 0, + "Cache hit on SerializeCached path must not clone" + ); + } + + /// Proves the owned fallback arm is taken on a cache miss: the shim clones + /// the value exactly once (to call the owned cache_set), so CLONES == 1 + /// after the first (miss) call. The hit-path clone count is an implementation + /// detail of OwnedStore::cache_get and is not asserted here; only the + /// returned value is checked for correctness. + #[test] + fn owned_store_clones_once() { + let _guard = CLONE_TEST_LOCK.lock().unwrap(); + CLONES.store(0, Ordering::SeqCst); + VIA_OWNED.cache_clear().unwrap(); + + // First call: cache miss; body runs, result stored via owned cache_set. + // The shim does `value.clone()` before calling cache_set. + let v = via_owned(7).unwrap(); + assert_eq!(v, Counted(7)); + + assert_eq!( + CLONES.load(Ordering::SeqCst), + 1, + "Owned fallback path must clone the value exactly once at the set site" + ); + + // Second call: cache hit; the set-site shim is not invoked. + // Assert only the returned value — the clone count on the hit path is + // an OwnedStore implementation detail and not part of the dispatch contract. + CLONES.store(0, Ordering::SeqCst); + let hit = via_owned(7).unwrap(); + assert_eq!(hit, Counted(7), "Cache hit must return the correct value"); + } +} + +// --------------------------------------------------------------------------- +// Part 4: async custom SerializeCachedAsync store round-trip. +// --------------------------------------------------------------------------- + +#[cfg(feature = "async")] +mod async_serialize_store { + use cached::{ConcurrentCacheBase, ConcurrentCachedAsync, SerializeCachedAsync}; + use std::collections::HashMap; + use std::convert::Infallible; + use std::sync::Mutex; + use std::sync::atomic::{AtomicUsize, Ordering}; + + // Counts every Clone of AsyncVal, so the borrowed-vs-owned async arm is observable + // the same way `CLONES` makes it observable for the sync path. + static ASYNC_CLONES: AtomicUsize = AtomicUsize::new(0); + + #[derive(PartialEq, Debug)] + pub struct AsyncVal(u32); + + impl Clone for AsyncVal { + fn clone(&self) -> Self { + ASYNC_CLONES.fetch_add(1, Ordering::SeqCst); + AsyncVal(self.0) + } + } + + fn async_val_to_string(v: &AsyncVal) -> String { + v.0.to_string() + } + + fn async_val_from_str(s: &str) -> AsyncVal { + AsyncVal(s.parse().expect("parse AsyncVal")) + } + + pub struct AsyncSerStore { + map: Mutex>, + } + + impl AsyncSerStore { + pub fn new() -> Self { + AsyncSerStore { + map: Mutex::new(HashMap::new()), + } + } + } + + impl ConcurrentCacheBase for AsyncSerStore { + type Error = Infallible; + } + + impl ConcurrentCachedAsync for AsyncSerStore { + fn async_cache_get( + &self, + k: &u32, + ) -> impl std::future::Future, Infallible>> + Send + { + let result = { + let map = self.map.lock().unwrap(); + map.get(k).map(|s| async_val_from_str(s)) + }; + async move { Ok(result) } + } + + fn async_cache_set( + &self, + k: u32, + v: AsyncVal, + ) -> impl std::future::Future, Infallible>> + Send + { + let s = async_val_to_string(&v); + let prev = { + let mut map = self.map.lock().unwrap(); + map.insert(k, s) + }; + let prev_val = prev.map(|s| async_val_from_str(&s)); + async move { Ok(prev_val) } + } + + fn async_cache_remove( + &self, + k: &u32, + ) -> impl std::future::Future, Infallible>> + Send + { + let result = { + let mut map = self.map.lock().unwrap(); + map.remove(k).map(|s| async_val_from_str(&s)) + }; + async move { Ok(result) } + } + + fn async_cache_remove_entry( + &self, + k: &u32, + ) -> impl std::future::Future, Infallible>> + Send + { + let result = { + let mut map = self.map.lock().unwrap(); + map.remove_entry(k) + .map(|(k, s)| (k, async_val_from_str(&s))) + }; + async move { Ok(result) } + } + + fn async_cache_clear( + &self, + ) -> impl std::future::Future> + Send + where + Self: Sync, + { + self.map.lock().unwrap().clear(); + async move { Ok(()) } + } + + fn async_cache_reset( + &self, + ) -> impl std::future::Future> + Send + where + Self: Sync, + { + self.map.lock().unwrap().clear(); + async move { Ok(()) } + } + } + + impl SerializeCachedAsync for AsyncSerStore { + fn async_cache_set_ref( + &self, + k: &u32, + v: &AsyncVal, + ) -> impl std::future::Future, Infallible>> + Send + { + let s = async_val_to_string(v); + let k = *k; + let prev = { + let mut map = self.map.lock().unwrap(); + map.insert(k, s) + }; + let prev_val = prev.map(|s| async_val_from_str(&s)); + async move { Ok(prev_val) } + } + } + + // AsyncOwnedStore: implements ONLY ConcurrentCachedAsync (NOT SerializeCachedAsync). + // Backing storage is HashMap; the shim must take the owned fallback arm, + // cloning the value once before async_cache_set. + pub struct AsyncOwnedStore { + map: Mutex>, + } + + impl AsyncOwnedStore { + pub fn new() -> Self { + AsyncOwnedStore { + map: Mutex::new(HashMap::new()), + } + } + } + + impl ConcurrentCacheBase for AsyncOwnedStore { + type Error = Infallible; + } + + impl ConcurrentCachedAsync for AsyncOwnedStore { + fn async_cache_get( + &self, + k: &u32, + ) -> impl std::future::Future, Infallible>> + Send + { + // Reading clones to return an owned value; the clone tests reset the counter + // immediately before the set-triggering call, so a miss's get (None) does not + // perturb the asserted set-path count. + let result = { + let map = self.map.lock().unwrap(); + map.get(k).cloned() + }; + async move { Ok(result) } + } + + fn async_cache_set( + &self, + k: u32, + v: AsyncVal, + ) -> impl std::future::Future, Infallible>> + Send + { + let prev = { + let mut map = self.map.lock().unwrap(); + map.insert(k, v) + }; + async move { Ok(prev) } + } + + fn async_cache_remove( + &self, + k: &u32, + ) -> impl std::future::Future, Infallible>> + Send + { + let result = { + let mut map = self.map.lock().unwrap(); + map.remove(k) + }; + async move { Ok(result) } + } + + fn async_cache_remove_entry( + &self, + k: &u32, + ) -> impl std::future::Future, Infallible>> + Send + { + let result = { + let mut map = self.map.lock().unwrap(); + map.remove_entry(k) + }; + async move { Ok(result) } + } + + fn async_cache_clear( + &self, + ) -> impl std::future::Future> + Send + where + Self: Sync, + { + self.map.lock().unwrap().clear(); + async move { Ok(()) } + } + + fn async_cache_reset( + &self, + ) -> impl std::future::Future> + Send + where + Self: Sync, + { + self.map.lock().unwrap().clear(); + async move { Ok(()) } + } + } + + // AsyncOwnedStore intentionally does NOT impl SerializeCachedAsync. + + use cached::macros::concurrent_cached; + + #[concurrent_cached( + ty = "AsyncSerStore", + create = "{ AsyncSerStore::new() }", + map_error = "|e| e", + key = "u32", + convert = "{ n }" + )] + async fn async_via_serialize(n: u32) -> Result { + Ok(AsyncVal(n)) + } + + #[concurrent_cached( + ty = "AsyncOwnedStore", + create = "{ AsyncOwnedStore::new() }", + map_error = "|e| e", + key = "u32", + convert = "{ n }" + )] + async fn async_via_owned(n: u32) -> Result { + Ok(AsyncVal(n)) + } + + // Both async clone-counting tests share the global ASYNC_CLONES counter; serialize + // them with serial_test so a concurrent reset cannot corrupt the asserted count. + #[tokio::test] + #[serial_test::serial(async_clones)] + async fn async_serialize_store_skips_value_clone() { + ASYNC_CLONES.store(0, Ordering::SeqCst); + // The store is a lazily-initialized OnceCell; clear it only if a prior + // test already initialized it, so the first call below is provably a miss. + if let Some(store) = ASYNC_VIA_SERIALIZE.get() { + store.async_cache_clear().await.unwrap(); + } + + // First call: cache miss, body runs, stored via async_cache_set_ref (&AsyncVal). + let first = async_via_serialize(42).await.unwrap(); + assert_eq!(first, AsyncVal(42)); + + // The borrowed async setter serialized from &AsyncVal -- no Clone at the set site. + assert_eq!( + ASYNC_CLONES.load(Ordering::SeqCst), + 0, + "SerializeCachedAsync path must not clone the value at the set site" + ); + + // Second call: cache hit, body not re-run; deserializes (no Clone). + ASYNC_CLONES.store(0, Ordering::SeqCst); + let second = async_via_serialize(42).await.unwrap(); + assert_eq!(second, AsyncVal(42)); + assert_eq!( + ASYNC_CLONES.load(Ordering::SeqCst), + 0, + "Cache hit on SerializeCachedAsync path must not clone" + ); + + // A different key causes the body to run again, still via the borrowed setter. + ASYNC_CLONES.store(0, Ordering::SeqCst); + let other = async_via_serialize(99).await.unwrap(); + assert_eq!(other, AsyncVal(99)); + assert_eq!(ASYNC_CLONES.load(Ordering::SeqCst), 0); + } + + /// Proves the owned async fallback arm is taken on a cache miss: the shim + /// clones the value exactly once before async_cache_set, so ASYNC_CLONES == 1 + /// after the first (miss) call. The hit-path clone count is an implementation + /// detail of AsyncOwnedStore::async_cache_get and is not asserted here; only + /// the returned value is checked for correctness. + #[tokio::test] + #[serial_test::serial(async_clones)] + async fn async_owned_store_clones_once() { + ASYNC_CLONES.store(0, Ordering::SeqCst); + // The store is a lazily-initialized OnceCell; clear it only if a prior + // test already initialized it, so the first call below is provably a miss. + if let Some(store) = ASYNC_VIA_OWNED.get() { + store.async_cache_clear().await.unwrap(); + } + + // First call: cache miss; the shim does `value.clone()` before async_cache_set. + let v = async_via_owned(7).await.unwrap(); + assert_eq!(v, AsyncVal(7)); + + assert_eq!( + ASYNC_CLONES.load(Ordering::SeqCst), + 1, + "Owned async fallback path must clone the value exactly once at the set site" + ); + + // Second call: cache hit; the set-site shim is not invoked. + // Assert only the returned value — the clone count on the hit path is + // an AsyncOwnedStore implementation detail and not part of the dispatch contract. + ASYNC_CLONES.store(0, Ordering::SeqCst); + let hit = async_via_owned(7).await.unwrap(); + assert_eq!(hit, AsyncVal(7), "Cache hit must return the correct value"); + } +} diff --git a/tests/ui/cached_cache_err_result_fallback_exclusive.rs b/tests/ui/cached_cache_err_result_fallback_exclusive.rs index c8f3b4ce..b1e58c56 100644 --- a/tests/ui/cached_cache_err_result_fallback_exclusive.rs +++ b/tests/ui/cached_cache_err_result_fallback_exclusive.rs @@ -1,6 +1,6 @@ use cached::macros::cached; -#[cached(ttl = 1, cache_err = true, result_fallback = true)] +#[cached(ttl_secs = 1, cache_err = true, result_fallback = true)] fn my_fn(k: i32) -> Result { Ok(k) } diff --git a/tests/ui/cached_cache_none_with_cached_flag_exclusive.stderr b/tests/ui/cached_cache_none_with_cached_flag_exclusive.stderr index eceb372e..504187fb 100644 --- a/tests/ui/cached_cache_none_with_cached_flag_exclusive.stderr +++ b/tests/ui/cached_cache_none_with_cached_flag_exclusive.stderr @@ -1,4 +1,4 @@ -error: `cache_none = true` and `with_cached_flag = true` are structurally incompatible on `Option` returns: `with_cached_flag` stores the inner `T` from `Return` while `cache_none = true` stores `Option` as the cached value — the same cache entry cannot hold both types. Use `with_cached_flag = true` alone (to get cache-state flags; `None` is not cached by default), or use `cache_none = true` alone (to force-cache `None` values). +error: `cache_none = true` and `with_cached_flag = true` are structurally incompatible on `Option` returns: `with_cached_flag` stores the inner `T` from `Return` while `cache_none = true` stores `Option` as the cached value - the same cache entry cannot hold both types. Use `with_cached_flag = true` alone (to get cache-state flags; `None` is not cached by default), or use `cache_none = true` alone (to force-cache `None` values). --> tests/ui/cached_cache_none_with_cached_flag_exclusive.rs:4:4 | 4 | fn my_fn(k: i32) -> Option> { diff --git a/tests/ui/cached_const_generic_requires_convert.rs b/tests/ui/cached_const_generic_requires_convert.rs new file mode 100644 index 00000000..5bf78b88 --- /dev/null +++ b/tests/ui/cached_const_generic_requires_convert.rs @@ -0,0 +1,12 @@ +use cached::macros::cached; + +// A const-generic `#[cached]` free function without `key`/`convert` hits the +// generic rejection: the cache is a single monomorphic static and cannot name +// the function's const parameter, so the default-key path cannot compile. +#[cached] +fn f(x: i32) -> i32 { + let _ = N; + x +} + +fn main() {} diff --git a/tests/ui/cached_const_generic_requires_convert.stderr b/tests/ui/cached_const_generic_requires_convert.stderr new file mode 100644 index 00000000..57207d44 --- /dev/null +++ b/tests/ui/cached_const_generic_requires_convert.stderr @@ -0,0 +1,5 @@ +error: #[cached] on a generic function requires `key` + `convert` to pin the cache key to a concrete type: the cache is a single monomorphic static shared across all instantiations and cannot name the function's type parameters. Provide `key`/`convert` (and `ty`/`create` if the value type is also generic), or wrap the generic function in a non-generic `#[cached]` function per concrete type. + --> tests/ui/cached_const_generic_requires_convert.rs:7:4 + | +7 | fn f(x: i32) -> i32 { + | ^ diff --git a/tests/ui/cached_convert_malformed_unquoted.rs b/tests/ui/cached_convert_malformed_unquoted.rs new file mode 100644 index 00000000..5daaec96 --- /dev/null +++ b/tests/ui/cached_convert_malformed_unquoted.rs @@ -0,0 +1,10 @@ +use cached::macros::cached; + +// Malformed unquoted convert block: `let x =` is not a valid expression, +// so darling rejects it during attribute parsing. +#[cached(key = "u32", convert = { let x = })] +fn my_fn(k: u32) -> u32 { + k +} + +fn main() {} diff --git a/tests/ui/cached_convert_malformed_unquoted.stderr b/tests/ui/cached_convert_malformed_unquoted.stderr new file mode 100644 index 00000000..eb2c75f9 --- /dev/null +++ b/tests/ui/cached_convert_malformed_unquoted.stderr @@ -0,0 +1,5 @@ +error: unexpected end of input, expected an expression + --> tests/ui/cached_convert_malformed_unquoted.rs:5:43 + | +5 | #[cached(key = "u32", convert = { let x = })] + | ^ diff --git a/tests/ui/cached_convert_unparseable.rs b/tests/ui/cached_convert_unparseable.rs new file mode 100644 index 00000000..3a651357 --- /dev/null +++ b/tests/ui/cached_convert_unparseable.rs @@ -0,0 +1,11 @@ +use cached::macros::cached; + +// `key` parses fine as a type, but `convert` is not a valid brace-delimited +// block (unterminated block), so it surfaces the contextual +// "unable to parse `convert` as a block" error (Tier3). +#[cached(key = "u32", convert = "{ this is not a block ")] +fn my_fn(k: u32) -> u32 { + k +} + +fn main() {} diff --git a/tests/ui/cached_convert_unparseable.stderr b/tests/ui/cached_convert_unparseable.stderr new file mode 100644 index 00000000..3b4013cf --- /dev/null +++ b/tests/ui/cached_convert_unparseable.stderr @@ -0,0 +1,5 @@ +error: Unknown literal value `{ this is not a block ` + --> tests/ui/cached_convert_unparseable.rs:6:33 + | +6 | #[cached(key = "u32", convert = "{ this is not a block ")] + | ^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/cached_disk_concurrent_only.rs b/tests/ui/cached_disk_concurrent_only.rs new file mode 100644 index 00000000..8107cee3 --- /dev/null +++ b/tests/ui/cached_disk_concurrent_only.rs @@ -0,0 +1,8 @@ +use cached::macros::cached; + +#[cached(disk = true)] +fn load(id: u64) -> u64 { + id +} + +fn main() {} diff --git a/tests/ui/cached_disk_concurrent_only.stderr b/tests/ui/cached_disk_concurrent_only.stderr new file mode 100644 index 00000000..cca365cf --- /dev/null +++ b/tests/ui/cached_disk_concurrent_only.stderr @@ -0,0 +1,5 @@ +error: `disk` is not supported on `#[cached]`; `disk` selects the redb disk-backed concurrent store. Use `#[concurrent_cached(disk = true)]` instead. + --> tests/ui/cached_disk_concurrent_only.rs:3:10 + | +3 | #[cached(disk = true)] + | ^^^^ diff --git a/tests/ui/cached_expires_and_ttl_millis_exclusive.rs b/tests/ui/cached_expires_and_ttl_millis_exclusive.rs new file mode 100644 index 00000000..b3b56a8e --- /dev/null +++ b/tests/ui/cached_expires_and_ttl_millis_exclusive.rs @@ -0,0 +1,8 @@ +use cached::macros::cached; + +#[cached(expires = true, ttl_millis = 500)] +fn f(x: i32) -> i32 { + x +} + +fn main() {} diff --git a/tests/ui/cached_expires_and_ttl_millis_exclusive.stderr b/tests/ui/cached_expires_and_ttl_millis_exclusive.stderr new file mode 100644 index 00000000..9fb37196 --- /dev/null +++ b/tests/ui/cached_expires_and_ttl_millis_exclusive.stderr @@ -0,0 +1,5 @@ +error: `expires` and `ttl_millis` are mutually exclusive - `expires` delegates expiry to the value via the `Expires` trait; `ttl_millis` applies a uniform millisecond TTL to all entries + --> tests/ui/cached_expires_and_ttl_millis_exclusive.rs:4:4 + | +4 | fn f(x: i32) -> i32 { + | ^ diff --git a/tests/ui/cached_expires_cache_err_exclusive.stderr b/tests/ui/cached_expires_cache_err_exclusive.stderr index 7f62d381..4f4566c1 100644 --- a/tests/ui/cached_expires_cache_err_exclusive.stderr +++ b/tests/ui/cached_expires_cache_err_exclusive.stderr @@ -1,4 +1,4 @@ -error: `expires = true` and `cache_err = true` are incompatible — `expires` requires the cache value type to implement `Expires`, but `cache_err = true` stores `Result` as the value, which does not implement `Expires`. Remove `cache_err = true` (Err values are not cached by default with `expires = true`). +error: `expires = true` and `cache_err = true` are incompatible - `expires` requires the cache value type to implement `Expires`, but `cache_err = true` stores `Result` as the value, which does not implement `Expires`. Remove `cache_err = true` (Err values are not cached by default with `expires = true`). --> tests/ui/cached_expires_cache_err_exclusive.rs:10:4 | 10 | fn my_fn(x: u32) -> Result { diff --git a/tests/ui/cached_expires_cache_none_exclusive.stderr b/tests/ui/cached_expires_cache_none_exclusive.stderr index eefd6a8d..749bfbc0 100644 --- a/tests/ui/cached_expires_cache_none_exclusive.stderr +++ b/tests/ui/cached_expires_cache_none_exclusive.stderr @@ -1,4 +1,4 @@ -error: `expires = true` and `cache_none = true` are incompatible — `expires` requires the cache value type to implement `Expires`, but `cache_none = true` stores `Option` as the value, which does not implement `Expires`. Remove `cache_none = true` (None values are not cached by default with `expires = true`). +error: `expires = true` and `cache_none = true` are incompatible - `expires` requires the cache value type to implement `Expires`, but `cache_none = true` stores `Option` as the value, which does not implement `Expires`. Remove `cache_none = true` (None values are not cached by default with `expires = true`). --> tests/ui/cached_expires_cache_none_exclusive.rs:10:4 | 10 | fn my_fn(x: u32) -> Option { diff --git a/tests/ui/cached_expires_create_exclusive.stderr b/tests/ui/cached_expires_create_exclusive.stderr index 88a46854..1022723c 100644 --- a/tests/ui/cached_expires_create_exclusive.stderr +++ b/tests/ui/cached_expires_create_exclusive.stderr @@ -1,4 +1,4 @@ -error: `expires` and `create` are mutually exclusive — `expires` generates the store constructor automatically +error: `expires` and `create` are mutually exclusive - `expires` generates the store constructor automatically --> tests/ui/cached_expires_create_exclusive.rs:6:4 | 6 | fn my_fn(x: u32) -> u32 { diff --git a/tests/ui/cached_expires_malformed_ttl.rs b/tests/ui/cached_expires_malformed_ttl.rs new file mode 100644 index 00000000..71f6d2f5 --- /dev/null +++ b/tests/ui/cached_expires_malformed_ttl.rs @@ -0,0 +1,12 @@ +use cached::macros::cached; + +// expires = true combined with a malformed ttl expression. +// The macro must fire the "mutually exclusive" error for expires+ttl BEFORE +// attempting to parse the ttl string. Old code order would emit a parse error +// for the malformed ttl; new code emits the exclusion error. +#[cached(expires = true, ttl = "core::time::Duration::from_secs(")] +fn my_fn(x: u32) -> u32 { + x +} + +fn main() {} diff --git a/tests/ui/cached_expires_malformed_ttl.stderr b/tests/ui/cached_expires_malformed_ttl.stderr new file mode 100644 index 00000000..1b582ade --- /dev/null +++ b/tests/ui/cached_expires_malformed_ttl.stderr @@ -0,0 +1,5 @@ +error: `expires` and `ttl` are mutually exclusive - `expires` delegates expiry to the value via the `Expires` trait; `ttl` applies a uniform time-based TTL to all entries + --> tests/ui/cached_expires_malformed_ttl.rs:8:4 + | +8 | fn my_fn(x: u32) -> u32 { + | ^^^^^ diff --git a/tests/ui/cached_expires_non_expires_type.stderr b/tests/ui/cached_expires_non_expires_type.stderr index c3e09932..764e05e8 100644 --- a/tests/ui/cached_expires_non_expires_type.stderr +++ b/tests/ui/cached_expires_non_expires_type.stderr @@ -7,6 +7,6 @@ error[E0277]: the trait bound `u32: Expires` is not satisfied note: required by a bound in `ExpiringCache` --> src/stores/expiring.rs | - | pub struct ExpiringCache { + | pub struct ExpiringCache { | ^^^^^^^ required by this bound in `ExpiringCache` = note: this error originates in the attribute macro `cached` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/tests/ui/cached_expires_refresh_exclusive.stderr b/tests/ui/cached_expires_refresh_exclusive.stderr index f1ed9c7e..265d892f 100644 --- a/tests/ui/cached_expires_refresh_exclusive.stderr +++ b/tests/ui/cached_expires_refresh_exclusive.stderr @@ -1,4 +1,4 @@ -error: `expires` and `refresh` are mutually exclusive — `refresh` renews a TTL on cache hit, but `ExpiringCache` and `ExpiringLruCache` have no TTL to refresh; expiry is controlled by the value +error: `expires` and `refresh` are mutually exclusive - `refresh` renews a TTL on cache hit, but `ExpiringCache` and `ExpiringLruCache` have no TTL to refresh; expiry is controlled by the value --> tests/ui/cached_expires_refresh_exclusive.rs:13:4 | 13 | fn my_fn(x: u32) -> MyVal { diff --git a/tests/ui/cached_expires_ttl_exclusive.rs b/tests/ui/cached_expires_ttl_exclusive.rs index 62cee35d..a45a0498 100644 --- a/tests/ui/cached_expires_ttl_exclusive.rs +++ b/tests/ui/cached_expires_ttl_exclusive.rs @@ -1,6 +1,6 @@ use cached::macros::cached; -#[cached(expires = true, ttl = 60)] +#[cached(expires = true, ttl = "core::time::Duration::from_secs(60)")] fn my_fn(x: u32) -> u32 { x } diff --git a/tests/ui/cached_expires_ttl_exclusive.stderr b/tests/ui/cached_expires_ttl_exclusive.stderr index e99654ab..6dcb0166 100644 --- a/tests/ui/cached_expires_ttl_exclusive.stderr +++ b/tests/ui/cached_expires_ttl_exclusive.stderr @@ -1,4 +1,4 @@ -error: `expires` and `ttl` are mutually exclusive — `expires` delegates expiry to the value via the `Expires` trait; `ttl` applies a uniform time-based TTL to all entries +error: `expires` and `ttl` are mutually exclusive - `expires` delegates expiry to the value via the `Expires` trait; `ttl` applies a uniform time-based TTL to all entries --> tests/ui/cached_expires_ttl_exclusive.rs:4:4 | 4 | fn my_fn(x: u32) -> u32 { diff --git a/tests/ui/cached_expires_type_exclusive.stderr b/tests/ui/cached_expires_type_exclusive.stderr index 3ad136ab..98df39a3 100644 --- a/tests/ui/cached_expires_type_exclusive.stderr +++ b/tests/ui/cached_expires_type_exclusive.stderr @@ -1,4 +1,4 @@ -error: `expires` and `ty` are mutually exclusive — `expires` generates the store type automatically +error: `expires` and `ty` are mutually exclusive - `expires` generates the store type automatically --> tests/ui/cached_expires_type_exclusive.rs:6:4 | 6 | fn my_fn(x: u32) -> u32 { diff --git a/tests/ui/cached_expires_unbound_exclusive.rs b/tests/ui/cached_expires_unbound_exclusive.rs deleted file mode 100644 index 30269ac3..00000000 --- a/tests/ui/cached_expires_unbound_exclusive.rs +++ /dev/null @@ -1,17 +0,0 @@ -use cached::macros::cached; -use cached::Expires; - -#[derive(Clone)] -struct MyVal; -impl Expires for MyVal { - fn is_expired(&self) -> bool { - false - } -} - -#[cached(expires = true, unbound)] -fn my_fn(x: u32) -> MyVal { - MyVal -} - -fn main() {} diff --git a/tests/ui/cached_expires_unbound_exclusive.stderr b/tests/ui/cached_expires_unbound_exclusive.stderr deleted file mode 100644 index 285049e7..00000000 --- a/tests/ui/cached_expires_unbound_exclusive.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: `expires` and `unbound` are mutually exclusive — `ExpiringCache` (the default store for `expires`) is already unbounded; use `expires = true` alone for an unbounded expiring cache - --> tests/ui/cached_expires_unbound_exclusive.rs:13:4 - | -13 | fn my_fn(x: u32) -> MyVal { - | ^^^^^ diff --git a/tests/ui/cached_expires_unsync_reads_exclusive.stderr b/tests/ui/cached_expires_unsync_reads_exclusive.stderr index 51c93cab..e4ce395f 100644 --- a/tests/ui/cached_expires_unsync_reads_exclusive.stderr +++ b/tests/ui/cached_expires_unsync_reads_exclusive.stderr @@ -1,4 +1,4 @@ -error: `expires` and `unsync_reads` are mutually exclusive — `ExpiringCache` and `ExpiringLruCache` do not implement `CachedRead` +error: `expires` and `unsync_reads` are mutually exclusive - `ExpiringCache` and `ExpiringLruCache` do not implement `CachedRead` --> tests/ui/cached_expires_unsync_reads_exclusive.rs:13:4 | 13 | fn my_fn(x: u32) -> MyVal { diff --git a/tests/ui/cached_expires_with_cached_flag_exclusive.stderr b/tests/ui/cached_expires_with_cached_flag_exclusive.stderr index 3a9c8ced..3fe4eb89 100644 --- a/tests/ui/cached_expires_with_cached_flag_exclusive.stderr +++ b/tests/ui/cached_expires_with_cached_flag_exclusive.stderr @@ -1,4 +1,4 @@ -error: `expires` and `with_cached_flag` are mutually exclusive — the `Return` wrapper does not implement `Expires` +error: `expires` and `with_cached_flag` are mutually exclusive - the `Return` wrapper does not implement `Expires` --> tests/ui/cached_expires_with_cached_flag_exclusive.rs:6:4 | 6 | fn my_fn(x: u32) -> Return { diff --git a/tests/ui/cached_force_refresh_non_string.rs b/tests/ui/cached_force_refresh_non_string.rs new file mode 100644 index 00000000..c87a529d --- /dev/null +++ b/tests/ui/cached_force_refresh_non_string.rs @@ -0,0 +1,8 @@ +use cached::macros::cached; + +#[cached(ttl_secs = 60, force_refresh = true)] +fn f(k: i32) -> i32 { + k +} + +fn main() {} diff --git a/tests/ui/cached_force_refresh_non_string.stderr b/tests/ui/cached_force_refresh_non_string.stderr new file mode 100644 index 00000000..f805cdf7 --- /dev/null +++ b/tests/ui/cached_force_refresh_non_string.stderr @@ -0,0 +1,5 @@ +error: `force_refresh` takes a curly-brace block string, e.g. `force_refresh = "{ id == 0 }"` + --> tests/ui/cached_force_refresh_non_string.rs:3:41 + | +3 | #[cached(ttl_secs = 60, force_refresh = true)] + | ^^^^ diff --git a/tests/ui/cached_force_refresh_unparseable.rs b/tests/ui/cached_force_refresh_unparseable.rs new file mode 100644 index 00000000..8034eab4 --- /dev/null +++ b/tests/ui/cached_force_refresh_unparseable.rs @@ -0,0 +1,8 @@ +use cached::macros::cached; + +#[cached(force_refresh = "{ this is not ; an expr }")] +fn f(x: i32) -> i32 { + x +} + +fn main() {} diff --git a/tests/ui/cached_force_refresh_unparseable.stderr b/tests/ui/cached_force_refresh_unparseable.stderr new file mode 100644 index 00000000..d7541c80 --- /dev/null +++ b/tests/ui/cached_force_refresh_unparseable.stderr @@ -0,0 +1,5 @@ +error: Unknown literal value `{ this is not ; an expr }` + --> tests/ui/cached_force_refresh_unparseable.rs:3:26 + | +3 | #[cached(force_refresh = "{ this is not ; an expr }")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/cached_generic_requires_convert.rs b/tests/ui/cached_generic_requires_convert.rs new file mode 100644 index 00000000..d240b871 --- /dev/null +++ b/tests/ui/cached_generic_requires_convert.rs @@ -0,0 +1,12 @@ +use cached::macros::cached; + +// A generic `#[cached]` free function without `key`/`convert` hits the generic +// rejection: the cache is a single monomorphic static and cannot name the +// function's type parameter, so the default-key path cannot compile. +#[cached] +fn f(x: T) -> usize { + let _ = x; + 0 +} + +fn main() {} diff --git a/tests/ui/cached_generic_requires_convert.stderr b/tests/ui/cached_generic_requires_convert.stderr new file mode 100644 index 00000000..3b96dd08 --- /dev/null +++ b/tests/ui/cached_generic_requires_convert.stderr @@ -0,0 +1,5 @@ +error: #[cached] on a generic function requires `key` + `convert` to pin the cache key to a concrete type: the cache is a single monomorphic static shared across all instantiations and cannot name the function's type parameters. Provide `key`/`convert` (and `ty`/`create` if the value type is also generic), or wrap the generic function in a non-generic `#[cached]` function per concrete type. + --> tests/ui/cached_generic_requires_convert.rs:7:4 + | +7 | fn f(x: T) -> usize { + | ^ diff --git a/tests/ui/cached_in_impl_generic_requires_convert.rs b/tests/ui/cached_in_impl_generic_requires_convert.rs new file mode 100644 index 00000000..e85903c5 --- /dev/null +++ b/tests/ui/cached_in_impl_generic_requires_convert.rs @@ -0,0 +1,16 @@ +use cached::macros::cached; + +struct S; + +// A generic `in_impl` method without `key`/`convert` hits the same generic +// rejection as a generic free function: the generic check runs before any +// `in_impl` handling, so the cache key cannot name the type parameter. +impl S { + #[cached(in_impl = true)] + fn f(&self, x: T) -> usize { + let _ = x; + 0 + } +} + +fn main() {} diff --git a/tests/ui/cached_in_impl_generic_requires_convert.stderr b/tests/ui/cached_in_impl_generic_requires_convert.stderr new file mode 100644 index 00000000..6475b266 --- /dev/null +++ b/tests/ui/cached_in_impl_generic_requires_convert.stderr @@ -0,0 +1,5 @@ +error: #[cached] on a generic function requires `key` + `convert` to pin the cache key to a concrete type: the cache is a single monomorphic static shared across all instantiations and cannot name the function's type parameters. Provide `key`/`convert` (and `ty`/`create` if the value type is also generic), or wrap the generic function in a non-generic `#[cached]` function per concrete type. + --> tests/ui/cached_in_impl_generic_requires_convert.rs:10:8 + | +10 | fn f(&self, x: T) -> usize { + | ^ diff --git a/tests/ui/cached_in_impl_requires_self.rs b/tests/ui/cached_in_impl_requires_self.rs new file mode 100644 index 00000000..aa6efa59 --- /dev/null +++ b/tests/ui/cached_in_impl_requires_self.rs @@ -0,0 +1,15 @@ +use cached::macros::cached; + +struct S; + +// `in_impl = true` on an associated function with NO `self` receiver is rejected: +// the generated `{fn}_no_cache(args)` call inside the impl cannot resolve without +// a `Self::` qualifier, so the macro requires a `self` receiver under `in_impl`. +impl S { + #[cached(in_impl = true)] + fn f(x: usize) -> usize { + x + } +} + +fn main() {} diff --git a/tests/ui/cached_in_impl_requires_self.stderr b/tests/ui/cached_in_impl_requires_self.stderr new file mode 100644 index 00000000..3ecd1dfc --- /dev/null +++ b/tests/ui/cached_in_impl_requires_self.stderr @@ -0,0 +1,5 @@ +error: in_impl = true requires a method with a `self` receiver; for a free function or an associated function without `self`, remove in_impl. + --> tests/ui/cached_in_impl_requires_self.rs:10:8 + | +10 | fn f(x: usize) -> usize { + | ^ diff --git a/tests/ui/cached_key_unparseable.rs b/tests/ui/cached_key_unparseable.rs new file mode 100644 index 00000000..52015b02 --- /dev/null +++ b/tests/ui/cached_key_unparseable.rs @@ -0,0 +1,8 @@ +use cached::macros::cached; + +#[cached(key = "not a type !!", convert = "{ k }")] +fn my_fn(k: i32) -> i32 { + k +} + +fn main() {} diff --git a/tests/ui/cached_key_unparseable.stderr b/tests/ui/cached_key_unparseable.stderr new file mode 100644 index 00000000..32becb1c --- /dev/null +++ b/tests/ui/cached_key_unparseable.stderr @@ -0,0 +1,7 @@ +error: unable to parse `key` as a type: unexpected token; `key` must be a Rust type, e.g. `key = "String"` or `key = "(u32, String)"` + --> tests/ui/cached_key_unparseable.rs:3:1 + | +3 | #[cached(key = "not a type !!", convert = "{ k }")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: this error originates in the attribute macro `cached` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/tests/ui/cached_name_invalid_ident.rs b/tests/ui/cached_name_invalid_ident.rs new file mode 100644 index 00000000..cba27e3d --- /dev/null +++ b/tests/ui/cached_name_invalid_ident.rs @@ -0,0 +1,8 @@ +use cached::macros::cached; + +#[cached(name = "bad-name")] +fn f(x: i32) -> i32 { + x +} + +fn main() {} diff --git a/tests/ui/cached_name_invalid_ident.stderr b/tests/ui/cached_name_invalid_ident.stderr new file mode 100644 index 00000000..613614cd --- /dev/null +++ b/tests/ui/cached_name_invalid_ident.stderr @@ -0,0 +1,5 @@ +error: `name` must be a valid Rust identifier + --> tests/ui/cached_name_invalid_ident.rs:4:4 + | +4 | fn f(x: i32) -> i32 { + | ^ diff --git a/tests/ui/cached_name_keyword.rs b/tests/ui/cached_name_keyword.rs new file mode 100644 index 00000000..9d86e88a --- /dev/null +++ b/tests/ui/cached_name_keyword.rs @@ -0,0 +1,11 @@ +// `name` must be a valid Rust identifier: a reserved keyword (`fn`) parses as a +// keyword token, not an `Ident`, so it must be rejected with the spanned error +// (and must not slip through to `Ident::new`, which would panic on a keyword). +use cached::macros::cached; + +#[cached(name = "fn")] +fn f(x: i32) -> i32 { + x +} + +fn main() {} diff --git a/tests/ui/cached_name_keyword.stderr b/tests/ui/cached_name_keyword.stderr new file mode 100644 index 00000000..16546c0d --- /dev/null +++ b/tests/ui/cached_name_keyword.stderr @@ -0,0 +1,5 @@ +error: `name` must be a valid Rust identifier + --> tests/ui/cached_name_keyword.rs:7:4 + | +7 | fn f(x: i32) -> i32 { + | ^ diff --git a/tests/ui/cached_name_leading_digit.rs b/tests/ui/cached_name_leading_digit.rs new file mode 100644 index 00000000..18de7b63 --- /dev/null +++ b/tests/ui/cached_name_leading_digit.rs @@ -0,0 +1,11 @@ +// `name` must be a valid Rust identifier: a leading digit (`123`) is not an +// identifier and must be rejected with the spanned error, even though it is a +// "word" with no dashes (unlike the `bad-name` fixture). +use cached::macros::cached; + +#[cached(name = "123")] +fn f(x: i32) -> i32 { + x +} + +fn main() {} diff --git a/tests/ui/cached_name_leading_digit.stderr b/tests/ui/cached_name_leading_digit.stderr new file mode 100644 index 00000000..ee87e250 --- /dev/null +++ b/tests/ui/cached_name_leading_digit.stderr @@ -0,0 +1,5 @@ +error: `name` must be a valid Rust identifier + --> tests/ui/cached_name_leading_digit.rs:7:4 + | +7 | fn f(x: i32) -> i32 { + | ^ diff --git a/tests/ui/cached_redis_concurrent_only.rs b/tests/ui/cached_redis_concurrent_only.rs new file mode 100644 index 00000000..7763cb7a --- /dev/null +++ b/tests/ui/cached_redis_concurrent_only.rs @@ -0,0 +1,8 @@ +use cached::macros::cached; + +#[cached(redis = true)] +fn load(id: u64) -> u64 { + id +} + +fn main() {} diff --git a/tests/ui/cached_redis_concurrent_only.stderr b/tests/ui/cached_redis_concurrent_only.stderr new file mode 100644 index 00000000..3f57af94 --- /dev/null +++ b/tests/ui/cached_redis_concurrent_only.stderr @@ -0,0 +1,5 @@ +error: `redis` is not supported on `#[cached]`; `redis` selects the Redis-backed concurrent store. Use `#[concurrent_cached(redis = true)]` instead. + --> tests/ui/cached_redis_concurrent_only.rs:3:10 + | +3 | #[cached(redis = true)] + | ^^^^^ diff --git a/tests/ui/cached_refresh_create_conflict.rs b/tests/ui/cached_refresh_create_conflict.rs new file mode 100644 index 00000000..3e2cf335 --- /dev/null +++ b/tests/ui/cached_refresh_create_conflict.rs @@ -0,0 +1,16 @@ +use cached::macros::cached; + +// `create` fully constructs the store, so `refresh` (which the macro would +// otherwise wire via `refresh_on_hit`) would be silently ignored - the macro +// must reject it with a specific message. Mirrors `#[concurrent_cached]`, whose +// `check_create_conflicts` already flags `refresh`. +#[cached( + ty = "cached::UnboundCache", + refresh = true, + create = "{ cached::UnboundCache::new() }" +)] +fn my_fn(k: i32) -> i32 { + k +} + +fn main() {} diff --git a/tests/ui/cached_refresh_create_conflict.stderr b/tests/ui/cached_refresh_create_conflict.stderr new file mode 100644 index 00000000..95d47601 --- /dev/null +++ b/tests/ui/cached_refresh_create_conflict.stderr @@ -0,0 +1,5 @@ +error: cannot specify `refresh` when passing a `create` block - `create` fully constructs the store, so these store-builder attributes would be silently ignored + --> tests/ui/cached_refresh_create_conflict.rs:12:4 + | +12 | fn my_fn(k: i32) -> i32 { + | ^^^^^ diff --git a/tests/ui/cached_refresh_requires_ttl.rs b/tests/ui/cached_refresh_requires_ttl.rs new file mode 100644 index 00000000..c692fc88 --- /dev/null +++ b/tests/ui/cached_refresh_requires_ttl.rs @@ -0,0 +1,8 @@ +use cached::macros::cached; + +#[cached(refresh = true)] +fn f(k: i32) -> i32 { + k +} + +fn main() {} diff --git a/tests/ui/cached_refresh_requires_ttl.stderr b/tests/ui/cached_refresh_requires_ttl.stderr new file mode 100644 index 00000000..b22c256f --- /dev/null +++ b/tests/ui/cached_refresh_requires_ttl.stderr @@ -0,0 +1,5 @@ +error: `refresh` requires a TTL (`ttl`/`ttl_secs`/`ttl_millis`) to be set - `refresh` renews a TTL on cache hit, but the default `UnboundCache`/`LruCache` stores have no TTL to renew + --> tests/ui/cached_refresh_requires_ttl.rs:4:4 + | +4 | fn f(k: i32) -> i32 { + | ^ diff --git a/tests/ui/cached_result_fallback_sync_writes.rs b/tests/ui/cached_result_fallback_sync_writes.rs index 2a1d8c3a..cb6cd4e5 100644 --- a/tests/ui/cached_result_fallback_sync_writes.rs +++ b/tests/ui/cached_result_fallback_sync_writes.rs @@ -1,6 +1,6 @@ use cached::macros::cached; -#[cached(ttl = 1, sync_writes = "default", result_fallback = true)] +#[cached(ttl_secs = 1, sync_writes = "default", result_fallback = true)] fn my_fn(k: i32) -> Result { Ok(k) } diff --git a/tests/ui/cached_result_fallback_sync_writes_by_key.rs b/tests/ui/cached_result_fallback_sync_writes_by_key.rs new file mode 100644 index 00000000..6274feea --- /dev/null +++ b/tests/ui/cached_result_fallback_sync_writes_by_key.rs @@ -0,0 +1,11 @@ +use cached::macros::cached; + +// Explicit sync_writes = "by_key" combined with result_fallback must error. +// (Implicit/default sync_writes with result_fallback is allowed and silently +// selects Disabled; only the explicit case errors.) +#[cached(ttl_secs = 1, sync_writes = "by_key", result_fallback = true)] +fn my_fn(k: i32) -> Result { + Ok(k) +} + +fn main() {} diff --git a/tests/ui/cached_result_fallback_sync_writes_by_key.stderr b/tests/ui/cached_result_fallback_sync_writes_by_key.stderr new file mode 100644 index 00000000..01fd1ed8 --- /dev/null +++ b/tests/ui/cached_result_fallback_sync_writes_by_key.stderr @@ -0,0 +1,5 @@ +error: `result_fallback` and `sync_writes` are mutually exclusive + --> tests/ui/cached_result_fallback_sync_writes_by_key.rs:7:4 + | +7 | fn my_fn(k: i32) -> Result { + | ^^^^^ diff --git a/tests/ui/cached_self_method.stderr b/tests/ui/cached_self_method.stderr index 69e5feeb..ed299be8 100644 --- a/tests/ui/cached_self_method.stderr +++ b/tests/ui/cached_self_method.stderr @@ -1,4 +1,4 @@ -error: #[cached] cannot be applied to methods that take `self` +error: #[cached] cannot be applied to methods that take `self`. Set `in_impl = true` to cache the method inside its `impl` block (a `convert` block alone is not sufficient: the generated cache static cannot live at `impl` scope). --> tests/ui/cached_self_method.rs:4:4 | 4 | fn my_fn(&self, k: i32) -> i32 { diff --git a/tests/ui/cached_store_types_exclusive.rs b/tests/ui/cached_store_types_exclusive.rs index 1d59b71b..fd19cc87 100644 --- a/tests/ui/cached_store_types_exclusive.rs +++ b/tests/ui/cached_store_types_exclusive.rs @@ -1,6 +1,8 @@ use cached::macros::cached; -#[cached(unbound, max_size = 1)] +// `max_size` + `ty` (without `create`) falls into the catch-all arm +// because it does not match any of the recognized store-type combinations. +#[cached(max_size = 5, ty = "cached::UnboundCache")] fn my_fn(k: i32) -> i32 { k } diff --git a/tests/ui/cached_store_types_exclusive.stderr b/tests/ui/cached_store_types_exclusive.stderr index 66f0c9b6..6017fdd8 100644 --- a/tests/ui/cached_store_types_exclusive.stderr +++ b/tests/ui/cached_store_types_exclusive.stderr @@ -1,5 +1,5 @@ -error: cache types (`unbound`, `max_size` and/or `ttl`, or `ty` and `create`) are mutually exclusive - --> tests/ui/cached_store_types_exclusive.rs:4:4 +error: cache types (`max_size` and/or `ttl`, or `ty` and `create`) are mutually exclusive + --> tests/ui/cached_store_types_exclusive.rs:6:4 | -4 | fn my_fn(k: i32) -> i32 { +6 | fn my_fn(k: i32) -> i32 { | ^^^^^ diff --git a/tests/ui/cached_ttl_and_ttl_millis_exclusive.rs b/tests/ui/cached_ttl_and_ttl_millis_exclusive.rs new file mode 100644 index 00000000..95d6feb2 --- /dev/null +++ b/tests/ui/cached_ttl_and_ttl_millis_exclusive.rs @@ -0,0 +1,8 @@ +use cached::macros::cached; + +#[cached(ttl = "core::time::Duration::from_secs(1)", ttl_millis = 500)] +fn f(x: i32) -> i32 { + x +} + +fn main() {} diff --git a/tests/ui/cached_ttl_and_ttl_millis_exclusive.stderr b/tests/ui/cached_ttl_and_ttl_millis_exclusive.stderr new file mode 100644 index 00000000..e5684eb1 --- /dev/null +++ b/tests/ui/cached_ttl_and_ttl_millis_exclusive.stderr @@ -0,0 +1,5 @@ +error: `ttl`, `ttl_secs`, and `ttl_millis` are mutually exclusive - `ttl` takes a `Duration` expression, `ttl_secs` whole seconds, `ttl_millis` milliseconds; use exactly one + --> tests/ui/cached_ttl_and_ttl_millis_exclusive.rs:4:4 + | +4 | fn f(x: i32) -> i32 { + | ^ diff --git a/tests/ui/cached_ttl_integer_migration.rs b/tests/ui/cached_ttl_integer_migration.rs new file mode 100644 index 00000000..1bdd657d --- /dev/null +++ b/tests/ui/cached_ttl_integer_migration.rs @@ -0,0 +1,8 @@ +use cached::macros::cached; + +#[cached(ttl = 60)] +fn f(x: i32) -> i32 { + x +} + +fn main() {} diff --git a/tests/ui/cached_ttl_integer_migration.stderr b/tests/ui/cached_ttl_integer_migration.stderr new file mode 100644 index 00000000..778c64f3 --- /dev/null +++ b/tests/ui/cached_ttl_integer_migration.stderr @@ -0,0 +1,5 @@ +error: `ttl` now takes a Duration expression (e.g. `ttl = "Duration::from_secs(60)"`); for whole seconds use `ttl_secs = 60`, for milliseconds use `ttl_millis = 500`. + --> tests/ui/cached_ttl_integer_migration.rs:3:16 + | +3 | #[cached(ttl = 60)] + | ^^ diff --git a/tests/ui/cached_ttl_millis_create_conflict.rs b/tests/ui/cached_ttl_millis_create_conflict.rs new file mode 100644 index 00000000..6d76f2ad --- /dev/null +++ b/tests/ui/cached_ttl_millis_create_conflict.rs @@ -0,0 +1,17 @@ +use cached::macros::cached; + +// `create` fully constructs the store, so `ttl_millis` would be silently +// ignored - the macro must reject it with a specific message rather than +// falling through to the generic "cache types are mutually exclusive" error +// (#149). Without the conflict check the store-type match never reaches the +// `create` arm (because `ttl_millis` sets `has_ttl`) and the TTL is dropped. +#[cached( + ty = "cached::UnboundCache", + ttl_millis = 500, + create = "{ cached::UnboundCache::new() }" +)] +fn my_fn(k: i32) -> i32 { + k +} + +fn main() {} diff --git a/tests/ui/cached_ttl_millis_create_conflict.stderr b/tests/ui/cached_ttl_millis_create_conflict.stderr new file mode 100644 index 00000000..58081763 --- /dev/null +++ b/tests/ui/cached_ttl_millis_create_conflict.stderr @@ -0,0 +1,5 @@ +error: cannot specify `ttl_millis` when passing a `create` block - `create` fully constructs the store, so these store-builder attributes would be silently ignored + --> tests/ui/cached_ttl_millis_create_conflict.rs:13:4 + | +13 | fn my_fn(k: i32) -> i32 { + | ^^^^^ diff --git a/tests/ui/cached_ttl_millis_zero.rs b/tests/ui/cached_ttl_millis_zero.rs new file mode 100644 index 00000000..e08662bb --- /dev/null +++ b/tests/ui/cached_ttl_millis_zero.rs @@ -0,0 +1,8 @@ +use cached::macros::cached; + +#[cached(ttl_millis = 0)] +fn f(x: i32) -> i32 { + x +} + +fn main() {} diff --git a/tests/ui/cached_ttl_millis_zero.stderr b/tests/ui/cached_ttl_millis_zero.stderr new file mode 100644 index 00000000..5796d545 --- /dev/null +++ b/tests/ui/cached_ttl_millis_zero.stderr @@ -0,0 +1,5 @@ +error: `ttl_millis` must be >= 1 + --> tests/ui/cached_ttl_millis_zero.rs:4:4 + | +4 | fn f(x: i32) -> i32 { + | ^ diff --git a/tests/ui/cached_ttl_secs_and_ttl_millis_exclusive.rs b/tests/ui/cached_ttl_secs_and_ttl_millis_exclusive.rs new file mode 100644 index 00000000..edd09070 --- /dev/null +++ b/tests/ui/cached_ttl_secs_and_ttl_millis_exclusive.rs @@ -0,0 +1,8 @@ +use cached::macros::cached; + +#[cached(ttl_secs = 1, ttl_millis = 500)] +fn f(x: i32) -> i32 { + x +} + +fn main() {} diff --git a/tests/ui/cached_ttl_secs_and_ttl_millis_exclusive.stderr b/tests/ui/cached_ttl_secs_and_ttl_millis_exclusive.stderr new file mode 100644 index 00000000..3a6913cb --- /dev/null +++ b/tests/ui/cached_ttl_secs_and_ttl_millis_exclusive.stderr @@ -0,0 +1,5 @@ +error: `ttl`, `ttl_secs`, and `ttl_millis` are mutually exclusive - `ttl` takes a `Duration` expression, `ttl_secs` whole seconds, `ttl_millis` milliseconds; use exactly one + --> tests/ui/cached_ttl_secs_and_ttl_millis_exclusive.rs:4:4 + | +4 | fn f(x: i32) -> i32 { + | ^ diff --git a/tests/ui/cached_ttl_ttl_secs_exclusive.rs b/tests/ui/cached_ttl_ttl_secs_exclusive.rs new file mode 100644 index 00000000..eb6c6e69 --- /dev/null +++ b/tests/ui/cached_ttl_ttl_secs_exclusive.rs @@ -0,0 +1,8 @@ +use cached::macros::cached; + +#[cached(ttl = "core::time::Duration::from_secs(1)", ttl_secs = 1)] +fn f(x: i32) -> i32 { + x +} + +fn main() {} diff --git a/tests/ui/cached_ttl_ttl_secs_exclusive.stderr b/tests/ui/cached_ttl_ttl_secs_exclusive.stderr new file mode 100644 index 00000000..443d069b --- /dev/null +++ b/tests/ui/cached_ttl_ttl_secs_exclusive.stderr @@ -0,0 +1,5 @@ +error: `ttl`, `ttl_secs`, and `ttl_millis` are mutually exclusive - `ttl` takes a `Duration` expression, `ttl_secs` whole seconds, `ttl_millis` milliseconds; use exactly one + --> tests/ui/cached_ttl_ttl_secs_exclusive.rs:4:4 + | +4 | fn f(x: i32) -> i32 { + | ^ diff --git a/tests/ui/cached_ttl_unparseable_duration.rs b/tests/ui/cached_ttl_unparseable_duration.rs new file mode 100644 index 00000000..1cd4d46c --- /dev/null +++ b/tests/ui/cached_ttl_unparseable_duration.rs @@ -0,0 +1,8 @@ +use cached::macros::cached; + +#[cached(ttl = "core::time::Duration::from_secs(")] +fn f() -> u32 { + 0 +} + +fn main() {} diff --git a/tests/ui/cached_ttl_unparseable_duration.stderr b/tests/ui/cached_ttl_unparseable_duration.stderr new file mode 100644 index 00000000..b0e954f3 --- /dev/null +++ b/tests/ui/cached_ttl_unparseable_duration.stderr @@ -0,0 +1,5 @@ +error: unable to parse `ttl` as a Duration expression: cannot parse string into token stream; `ttl` takes a `Duration` expression as a string literal, e.g. `ttl = "core::time::Duration::from_secs(60)"` + --> tests/ui/cached_ttl_unparseable_duration.rs:3:16 + | +3 | #[cached(ttl = "core::time::Duration::from_secs(")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/cached_ttl_zero.rs b/tests/ui/cached_ttl_zero.rs index 1704a28b..755b05af 100644 --- a/tests/ui/cached_ttl_zero.rs +++ b/tests/ui/cached_ttl_zero.rs @@ -1,6 +1,6 @@ use cached::cached; -#[cached(ttl = 0)] +#[cached(ttl_secs = 0)] fn my_fn(k: i32) -> i32 { k } diff --git a/tests/ui/cached_ttl_zero.stderr b/tests/ui/cached_ttl_zero.stderr index 1dfd9475..8acc35d5 100644 --- a/tests/ui/cached_ttl_zero.stderr +++ b/tests/ui/cached_ttl_zero.stderr @@ -1,4 +1,4 @@ -error: `ttl` must be >= 1 +error: `ttl_secs` must be >= 1 --> tests/ui/cached_ttl_zero.rs:4:4 | 4 | fn my_fn(k: i32) -> i32 { diff --git a/tests/ui/cached_unbound_attr_removed.rs b/tests/ui/cached_unbound_attr_removed.rs new file mode 100644 index 00000000..466e9431 --- /dev/null +++ b/tests/ui/cached_unbound_attr_removed.rs @@ -0,0 +1,8 @@ +use cached::macros::cached; + +#[cached(unbound)] +fn my_fn(x: u32) -> u32 { + x +} + +fn main() {} diff --git a/tests/ui/cached_unbound_attr_removed.stderr b/tests/ui/cached_unbound_attr_removed.stderr new file mode 100644 index 00000000..acbd01e1 --- /dev/null +++ b/tests/ui/cached_unbound_attr_removed.stderr @@ -0,0 +1,5 @@ +error: the `unbound` attribute has been removed. The default store (no `max_size`, `ttl`, or `expires`) is already an `UnboundCache`, so use `#[cached]` without `unbound`. + --> tests/ui/cached_unbound_attr_removed.rs:4:4 + | +4 | fn my_fn(x: u32) -> u32 { + | ^^^^^ diff --git a/tests/ui/concurrent_cached_async_redis_no_ttl.stderr b/tests/ui/concurrent_cached_async_redis_no_ttl.stderr index cb208de1..bf183c52 100644 --- a/tests/ui/concurrent_cached_async_redis_no_ttl.stderr +++ b/tests/ui/concurrent_cached_async_redis_no_ttl.stderr @@ -1,4 +1,4 @@ -error: AsyncRedisCache requires a `ttl` when `create` block is not specified +error: AsyncRedisCache requires a TTL (`ttl`/`ttl_secs`/`ttl_millis`) when `create` block is not specified --> tests/ui/concurrent_cached_async_redis_no_ttl.rs:4:10 | 4 | async fn my_fn(k: i32) -> Result { diff --git a/tests/ui/concurrent_cached_cache_err_result_fallback_exclusive.rs b/tests/ui/concurrent_cached_cache_err_result_fallback_exclusive.rs index 4533ba0b..69874acb 100644 --- a/tests/ui/concurrent_cached_cache_err_result_fallback_exclusive.rs +++ b/tests/ui/concurrent_cached_cache_err_result_fallback_exclusive.rs @@ -1,6 +1,6 @@ use cached::macros::concurrent_cached; -#[concurrent_cached(ttl = 1, cache_err = true, result_fallback = true)] +#[concurrent_cached(ttl_secs = 1, cache_err = true, result_fallback = true)] fn my_fn(k: i32) -> Result { Ok(k) } diff --git a/tests/ui/concurrent_cached_cache_none_with_redis.rs b/tests/ui/concurrent_cached_cache_none_with_redis.rs index a4f5cd0c..9bd97921 100644 --- a/tests/ui/concurrent_cached_cache_none_with_redis.rs +++ b/tests/ui/concurrent_cached_cache_none_with_redis.rs @@ -1,7 +1,7 @@ use cached::macros::concurrent_cached; // Option + cache_none=true on redis should say "Option return types", not "plain". -#[concurrent_cached(map_error = "|e| e", redis = true, ttl = 60, cache_none = true)] +#[concurrent_cached(map_error = "|e| e", redis = true, ttl_secs = 60, cache_none = true)] fn my_fn(k: i32) -> Option { Some(k) } diff --git a/tests/ui/concurrent_cached_const_generic_requires_convert.rs b/tests/ui/concurrent_cached_const_generic_requires_convert.rs new file mode 100644 index 00000000..c24d1344 --- /dev/null +++ b/tests/ui/concurrent_cached_const_generic_requires_convert.rs @@ -0,0 +1,12 @@ +use cached::macros::concurrent_cached; + +// A const-generic `#[concurrent_cached]` function without `key`/`convert` hits +// the generic rejection: the cache is a single monomorphic static and cannot +// name the function's const parameter, so the default-key path cannot compile. +#[concurrent_cached] +fn f(x: i32) -> i32 { + let _ = N; + x +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_const_generic_requires_convert.stderr b/tests/ui/concurrent_cached_const_generic_requires_convert.stderr new file mode 100644 index 00000000..1519739d --- /dev/null +++ b/tests/ui/concurrent_cached_const_generic_requires_convert.stderr @@ -0,0 +1,5 @@ +error: #[concurrent_cached] on a generic function requires `key` + `convert` to pin the cache key to a concrete type: the cache is a single monomorphic static shared across all instantiations and cannot name the function's type parameters. Provide `key`/`convert` (and a concrete `ty`/`create`), or wrap the generic function in a non-generic `#[concurrent_cached]` function per concrete type. + --> tests/ui/concurrent_cached_const_generic_requires_convert.rs:7:4 + | +7 | fn f(x: i32) -> i32 { + | ^ diff --git a/tests/ui/concurrent_cached_disk_create_conflict.rs b/tests/ui/concurrent_cached_disk_create_conflict.rs index 23cb6815..2e93bbab 100644 --- a/tests/ui/concurrent_cached_disk_create_conflict.rs +++ b/tests/ui/concurrent_cached_disk_create_conflict.rs @@ -1,6 +1,6 @@ use cached::macros::concurrent_cached; -#[concurrent_cached(map_error = "|e| e", disk = true, ttl = 1, create = "{ }")] +#[concurrent_cached(map_error = "|e| e", disk = true, ttl_secs = 1, create = "{ }")] fn my_fn(k: i32) -> Result { Ok(k) } diff --git a/tests/ui/concurrent_cached_disk_create_conflict.stderr b/tests/ui/concurrent_cached_disk_create_conflict.stderr index efa148b3..5434b343 100644 --- a/tests/ui/concurrent_cached_disk_create_conflict.stderr +++ b/tests/ui/concurrent_cached_disk_create_conflict.stderr @@ -1,4 +1,4 @@ -error: cannot specify `ttl` when passing a `create` block — `create` fully constructs the store, so these store-builder attributes would be silently ignored +error: cannot specify `ttl_secs` when passing a `create` block - `create` fully constructs the store, so these store-builder attributes would be silently ignored --> tests/ui/concurrent_cached_disk_create_conflict.rs:4:4 | 4 | fn my_fn(k: i32) -> Result { diff --git a/tests/ui/concurrent_cached_disk_create_ignored_attrs.stderr b/tests/ui/concurrent_cached_disk_create_ignored_attrs.stderr index 6eec03a7..d1e1baff 100644 --- a/tests/ui/concurrent_cached_disk_create_ignored_attrs.stderr +++ b/tests/ui/concurrent_cached_disk_create_ignored_attrs.stderr @@ -1,4 +1,4 @@ -error: cannot specify `disk_dir` when passing a `create` block — `create` fully constructs the store, so these store-builder attributes would be silently ignored +error: cannot specify `disk_dir` when passing a `create` block - `create` fully constructs the store, so these store-builder attributes would be silently ignored --> tests/ui/concurrent_cached_disk_create_ignored_attrs.rs:12:4 | 12 | fn my_fn(k: i32) -> Result { diff --git a/tests/ui/concurrent_cached_durable_with_redis.rs b/tests/ui/concurrent_cached_durable_with_redis.rs index b6e84470..b0a16aa5 100644 --- a/tests/ui/concurrent_cached_durable_with_redis.rs +++ b/tests/ui/concurrent_cached_durable_with_redis.rs @@ -4,7 +4,7 @@ use cached::macros::concurrent_cached; // ignored) on the `redis = true` path. #[concurrent_cached( redis = true, - ttl = 60, + ttl_secs = 60, durable = false, map_error = r#"|e| format!("{:?}", e)"# )] diff --git a/tests/ui/concurrent_cached_expires_and_ttl_millis_exclusive.rs b/tests/ui/concurrent_cached_expires_and_ttl_millis_exclusive.rs new file mode 100644 index 00000000..51d64cd2 --- /dev/null +++ b/tests/ui/concurrent_cached_expires_and_ttl_millis_exclusive.rs @@ -0,0 +1,8 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached(expires = true, ttl_millis = 500)] +fn f(x: i32) -> i32 { + x +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_expires_and_ttl_millis_exclusive.stderr b/tests/ui/concurrent_cached_expires_and_ttl_millis_exclusive.stderr new file mode 100644 index 00000000..f64a2a90 --- /dev/null +++ b/tests/ui/concurrent_cached_expires_and_ttl_millis_exclusive.stderr @@ -0,0 +1,5 @@ +error: `expires` and `ttl_millis` are mutually exclusive - `expires` delegates expiry to the value via the `Expires` trait; `ttl_millis` applies a uniform millisecond TTL to all entries + --> tests/ui/concurrent_cached_expires_and_ttl_millis_exclusive.rs:4:4 + | +4 | fn f(x: i32) -> i32 { + | ^ diff --git a/tests/ui/concurrent_cached_expires_cache_err_exclusive.stderr b/tests/ui/concurrent_cached_expires_cache_err_exclusive.stderr index 0b8ca2f6..83f30a3c 100644 --- a/tests/ui/concurrent_cached_expires_cache_err_exclusive.stderr +++ b/tests/ui/concurrent_cached_expires_cache_err_exclusive.stderr @@ -1,4 +1,4 @@ -error: `expires = true` and `cache_err = true` are mutually exclusive — `expires` requires the cached value to implement `Expires`, but `cache_err = true` stores `Result` as the value type, which does not implement `Expires`. Remove `cache_err = true`. +error: `expires = true` and `cache_err = true` are mutually exclusive - `expires` requires the cached value to implement `Expires`, but `cache_err = true` stores `Result` as the value type, which does not implement `Expires`. Remove `cache_err = true`. --> tests/ui/concurrent_cached_expires_cache_err_exclusive.rs:10:4 | 10 | fn my_fn(x: u32) -> Result { diff --git a/tests/ui/concurrent_cached_expires_cache_none_exclusive.stderr b/tests/ui/concurrent_cached_expires_cache_none_exclusive.stderr index f02e6afa..02abb6e8 100644 --- a/tests/ui/concurrent_cached_expires_cache_none_exclusive.stderr +++ b/tests/ui/concurrent_cached_expires_cache_none_exclusive.stderr @@ -1,4 +1,4 @@ -error: `expires = true` and `cache_none = true` are incompatible — `expires` requires the cache value type to implement `Expires`, but `cache_none = true` stores `Option` as the value, which does not implement `Expires`. Remove `cache_none = true` (None values are not cached by default with `expires = true`). +error: `expires = true` and `cache_none = true` are incompatible - `expires` requires the cache value type to implement `Expires`, but `cache_none = true` stores `Option` as the value, which does not implement `Expires`. Remove `cache_none = true` (None values are not cached by default with `expires = true`). --> tests/ui/concurrent_cached_expires_cache_none_exclusive.rs:4:4 | 4 | fn my_fn(x: u32) -> Option { diff --git a/tests/ui/concurrent_cached_expires_create_exclusive.rs b/tests/ui/concurrent_cached_expires_create_exclusive.rs index 50b8fe14..9fce1009 100644 --- a/tests/ui/concurrent_cached_expires_create_exclusive.rs +++ b/tests/ui/concurrent_cached_expires_create_exclusive.rs @@ -1,6 +1,6 @@ use cached::macros::concurrent_cached; -#[concurrent_cached(expires = true, create = "{ ShardedCache::new() }")] +#[concurrent_cached(expires = true, create = "{ ShardedUnboundCache::new() }")] fn my_fn(x: u32) -> Result { Ok(x) } diff --git a/tests/ui/concurrent_cached_expires_create_exclusive.stderr b/tests/ui/concurrent_cached_expires_create_exclusive.stderr index 7e20af0f..ea99387d 100644 --- a/tests/ui/concurrent_cached_expires_create_exclusive.stderr +++ b/tests/ui/concurrent_cached_expires_create_exclusive.stderr @@ -1,4 +1,4 @@ -error: `expires` and `create` are mutually exclusive — `expires` generates the store constructor automatically +error: `expires` and `create` are mutually exclusive - `expires` generates the store constructor automatically --> tests/ui/concurrent_cached_expires_create_exclusive.rs:4:4 | 4 | fn my_fn(x: u32) -> Result { diff --git a/tests/ui/concurrent_cached_expires_disk_exclusive.stderr b/tests/ui/concurrent_cached_expires_disk_exclusive.stderr index c0f7d1f7..6785f2f3 100644 --- a/tests/ui/concurrent_cached_expires_disk_exclusive.stderr +++ b/tests/ui/concurrent_cached_expires_disk_exclusive.stderr @@ -1,4 +1,4 @@ -error: `expires` and `disk` are mutually exclusive — `expires` selects sharded in-memory expiring stores +error: `expires` and `disk` are mutually exclusive - `expires` selects sharded in-memory expiring stores --> tests/ui/concurrent_cached_expires_disk_exclusive.rs:4:4 | 4 | fn my_fn(x: u32) -> Result { diff --git a/tests/ui/concurrent_cached_expires_malformed_ttl.rs b/tests/ui/concurrent_cached_expires_malformed_ttl.rs new file mode 100644 index 00000000..ce2e452e --- /dev/null +++ b/tests/ui/concurrent_cached_expires_malformed_ttl.rs @@ -0,0 +1,12 @@ +use cached::macros::concurrent_cached; + +// expires = true combined with a malformed ttl expression. +// The macro must fire the "mutually exclusive" error for expires+ttl BEFORE +// attempting to parse the ttl string. Old code order would emit a parse error +// for the malformed ttl; new code emits the exclusion error. +#[concurrent_cached(expires = true, ttl = "core::time::Duration::from_secs(")] +fn my_fn(x: u32) -> Result { + Ok(x) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_expires_malformed_ttl.stderr b/tests/ui/concurrent_cached_expires_malformed_ttl.stderr new file mode 100644 index 00000000..adc9c2eb --- /dev/null +++ b/tests/ui/concurrent_cached_expires_malformed_ttl.stderr @@ -0,0 +1,5 @@ +error: `expires` and `ttl` are mutually exclusive - `expires` delegates expiry to the value via the `Expires` trait + --> tests/ui/concurrent_cached_expires_malformed_ttl.rs:8:4 + | +8 | fn my_fn(x: u32) -> Result { + | ^^^^^ diff --git a/tests/ui/concurrent_cached_expires_redis_exclusive.stderr b/tests/ui/concurrent_cached_expires_redis_exclusive.stderr index 972b44da..19b0c7ca 100644 --- a/tests/ui/concurrent_cached_expires_redis_exclusive.stderr +++ b/tests/ui/concurrent_cached_expires_redis_exclusive.stderr @@ -1,4 +1,4 @@ -error: `expires` and `redis` are mutually exclusive — `expires` selects sharded in-memory expiring stores +error: `expires` and `redis` are mutually exclusive - `expires` selects sharded in-memory expiring stores --> tests/ui/concurrent_cached_expires_redis_exclusive.rs:4:4 | 4 | fn my_fn(x: u32) -> Result { diff --git a/tests/ui/concurrent_cached_expires_refresh_exclusive.stderr b/tests/ui/concurrent_cached_expires_refresh_exclusive.stderr index 0885e2e3..d8752003 100644 --- a/tests/ui/concurrent_cached_expires_refresh_exclusive.stderr +++ b/tests/ui/concurrent_cached_expires_refresh_exclusive.stderr @@ -1,4 +1,4 @@ -error: `expires` and `refresh` are mutually exclusive — `expires` delegates expiry to the value via `Expires::is_expired` +error: `expires` and `refresh` are mutually exclusive - `expires` delegates expiry to the value via `Expires::is_expired` --> tests/ui/concurrent_cached_expires_refresh_exclusive.rs:4:4 | 4 | fn my_fn(x: u32) -> Result { diff --git a/tests/ui/concurrent_cached_expires_ttl_exclusive.rs b/tests/ui/concurrent_cached_expires_ttl_exclusive.rs index 9fbcbd22..13fd3e3e 100644 --- a/tests/ui/concurrent_cached_expires_ttl_exclusive.rs +++ b/tests/ui/concurrent_cached_expires_ttl_exclusive.rs @@ -1,6 +1,6 @@ use cached::macros::concurrent_cached; -#[concurrent_cached(expires = true, ttl = 60)] +#[concurrent_cached(expires = true, ttl = "core::time::Duration::from_secs(60)")] fn my_fn(x: u32) -> Result { Ok(x) } diff --git a/tests/ui/concurrent_cached_expires_ttl_exclusive.stderr b/tests/ui/concurrent_cached_expires_ttl_exclusive.stderr index 95091bac..a1d23284 100644 --- a/tests/ui/concurrent_cached_expires_ttl_exclusive.stderr +++ b/tests/ui/concurrent_cached_expires_ttl_exclusive.stderr @@ -1,4 +1,4 @@ -error: `expires` and `ttl` are mutually exclusive — `expires` delegates expiry to the value via the `Expires` trait +error: `expires` and `ttl` are mutually exclusive - `expires` delegates expiry to the value via the `Expires` trait --> tests/ui/concurrent_cached_expires_ttl_exclusive.rs:4:4 | 4 | fn my_fn(x: u32) -> Result { diff --git a/tests/ui/concurrent_cached_expires_ty_exclusive.rs b/tests/ui/concurrent_cached_expires_ty_exclusive.rs index 9aa3f772..61b32cc6 100644 --- a/tests/ui/concurrent_cached_expires_ty_exclusive.rs +++ b/tests/ui/concurrent_cached_expires_ty_exclusive.rs @@ -1,6 +1,6 @@ use cached::macros::concurrent_cached; -#[concurrent_cached(expires = true, ty = "ShardedCache")] +#[concurrent_cached(expires = true, ty = "ShardedUnboundCache")] fn my_fn(x: u32) -> Result { Ok(x) } diff --git a/tests/ui/concurrent_cached_expires_ty_exclusive.stderr b/tests/ui/concurrent_cached_expires_ty_exclusive.stderr index ceeba633..dd1ce441 100644 --- a/tests/ui/concurrent_cached_expires_ty_exclusive.stderr +++ b/tests/ui/concurrent_cached_expires_ty_exclusive.stderr @@ -1,4 +1,4 @@ -error: `expires` and `ty` are mutually exclusive — `expires` generates the store type automatically +error: `expires` and `ty` are mutually exclusive - `expires` generates the store type automatically --> tests/ui/concurrent_cached_expires_ty_exclusive.rs:4:4 | 4 | fn my_fn(x: u32) -> Result { diff --git a/tests/ui/concurrent_cached_force_refresh_unparseable.rs b/tests/ui/concurrent_cached_force_refresh_unparseable.rs new file mode 100644 index 00000000..9e2cfc9b --- /dev/null +++ b/tests/ui/concurrent_cached_force_refresh_unparseable.rs @@ -0,0 +1,8 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached(force_refresh = "{ this is not ; an expr }")] +fn f(x: i32) -> i32 { + x +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_force_refresh_unparseable.stderr b/tests/ui/concurrent_cached_force_refresh_unparseable.stderr new file mode 100644 index 00000000..0b336be1 --- /dev/null +++ b/tests/ui/concurrent_cached_force_refresh_unparseable.stderr @@ -0,0 +1,5 @@ +error: Unknown literal value `{ this is not ; an expr }` + --> tests/ui/concurrent_cached_force_refresh_unparseable.rs:3:37 + | +3 | #[concurrent_cached(force_refresh = "{ this is not ; an expr }")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/concurrent_cached_generic_requires_convert.rs b/tests/ui/concurrent_cached_generic_requires_convert.rs new file mode 100644 index 00000000..7ac42f87 --- /dev/null +++ b/tests/ui/concurrent_cached_generic_requires_convert.rs @@ -0,0 +1,12 @@ +use cached::macros::concurrent_cached; + +// A generic `#[concurrent_cached]` function without `key`/`convert` hits the +// generic rejection: the cache is a single monomorphic static and cannot name +// the function's type parameter, so the default-key path cannot compile. +#[concurrent_cached] +fn f(x: T) -> usize { + let _ = x; + 0 +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_generic_requires_convert.stderr b/tests/ui/concurrent_cached_generic_requires_convert.stderr new file mode 100644 index 00000000..97089403 --- /dev/null +++ b/tests/ui/concurrent_cached_generic_requires_convert.stderr @@ -0,0 +1,5 @@ +error: #[concurrent_cached] on a generic function requires `key` + `convert` to pin the cache key to a concrete type: the cache is a single monomorphic static shared across all instantiations and cannot name the function's type parameters. Provide `key`/`convert` (and a concrete `ty`/`create`), or wrap the generic function in a non-generic `#[concurrent_cached]` function per concrete type. + --> tests/ui/concurrent_cached_generic_requires_convert.rs:7:4 + | +7 | fn f(x: T) -> usize { + | ^ diff --git a/tests/ui/concurrent_cached_in_impl_generic_requires_convert.rs b/tests/ui/concurrent_cached_in_impl_generic_requires_convert.rs new file mode 100644 index 00000000..d074ecab --- /dev/null +++ b/tests/ui/concurrent_cached_in_impl_generic_requires_convert.rs @@ -0,0 +1,16 @@ +use cached::macros::concurrent_cached; + +struct S; + +// A generic `in_impl` method without `key`/`convert` hits the same generic +// rejection as a generic free function: the generic check runs before any +// `in_impl` handling, so the cache key cannot name the type parameter. +impl S { + #[concurrent_cached(in_impl = true)] + fn f(&self, x: T) -> usize { + let _ = x; + 0 + } +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_in_impl_generic_requires_convert.stderr b/tests/ui/concurrent_cached_in_impl_generic_requires_convert.stderr new file mode 100644 index 00000000..396dea10 --- /dev/null +++ b/tests/ui/concurrent_cached_in_impl_generic_requires_convert.stderr @@ -0,0 +1,5 @@ +error: #[concurrent_cached] on a generic function requires `key` + `convert` to pin the cache key to a concrete type: the cache is a single monomorphic static shared across all instantiations and cannot name the function's type parameters. Provide `key`/`convert` (and a concrete `ty`/`create`), or wrap the generic function in a non-generic `#[concurrent_cached]` function per concrete type. + --> tests/ui/concurrent_cached_in_impl_generic_requires_convert.rs:10:8 + | +10 | fn f(&self, x: T) -> usize { + | ^ diff --git a/tests/ui/concurrent_cached_in_impl_requires_self.rs b/tests/ui/concurrent_cached_in_impl_requires_self.rs new file mode 100644 index 00000000..21d67339 --- /dev/null +++ b/tests/ui/concurrent_cached_in_impl_requires_self.rs @@ -0,0 +1,15 @@ +use cached::macros::concurrent_cached; + +struct S; + +// `in_impl = true` on an associated function with NO `self` receiver is rejected: +// the generated `{fn}_no_cache(args)` call inside the impl cannot resolve without +// a `Self::` qualifier, so the macro requires a `self` receiver under `in_impl`. +impl S { + #[concurrent_cached(in_impl = true)] + fn f(x: usize) -> usize { + x + } +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_in_impl_requires_self.stderr b/tests/ui/concurrent_cached_in_impl_requires_self.stderr new file mode 100644 index 00000000..e5bf928c --- /dev/null +++ b/tests/ui/concurrent_cached_in_impl_requires_self.stderr @@ -0,0 +1,5 @@ +error: in_impl = true requires a method with a `self` receiver; for a free function or an associated function without `self`, remove in_impl. + --> tests/ui/concurrent_cached_in_impl_requires_self.rs:10:8 + | +10 | fn f(x: usize) -> usize { + | ^ diff --git a/tests/ui/concurrent_cached_map_error_non_closure.rs b/tests/ui/concurrent_cached_map_error_non_closure.rs new file mode 100644 index 00000000..bde61742 --- /dev/null +++ b/tests/ui/concurrent_cached_map_error_non_closure.rs @@ -0,0 +1,9 @@ +use cached::macros::concurrent_cached; + +// map_error must be a closure expression; a plain integer literal must error. +#[concurrent_cached(disk = true, ttl_secs = 60, map_error = 5)] +async fn my_fn(k: u32) -> Result { + Ok(k) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_map_error_non_closure.stderr b/tests/ui/concurrent_cached_map_error_non_closure.stderr new file mode 100644 index 00000000..ad3de573 --- /dev/null +++ b/tests/ui/concurrent_cached_map_error_non_closure.stderr @@ -0,0 +1,5 @@ +error: `map_error` must be a closure, e.g. `map_error = |e| MyErr(e)` + --> tests/ui/concurrent_cached_map_error_non_closure.rs:4:61 + | +4 | #[concurrent_cached(disk = true, ttl_secs = 60, map_error = 5)] + | ^ diff --git a/tests/ui/concurrent_cached_map_error_on_infallible.stderr b/tests/ui/concurrent_cached_map_error_on_infallible.stderr index b349515f..949d8fff 100644 --- a/tests/ui/concurrent_cached_map_error_on_infallible.stderr +++ b/tests/ui/concurrent_cached_map_error_on_infallible.stderr @@ -1,4 +1,4 @@ -error: `map_error` is not applicable to the default in-memory sharded stores — their error type is `Infallible` and cache operations cannot fail. Remove `map_error`, or add `redis = true`, `disk = true`, or a custom `ty`/`create` to use a store with a fallible error type. +error: `map_error` is not applicable to the default in-memory sharded stores - their error type is `Infallible` and cache operations cannot fail. Remove `map_error`, or add `redis = true`, `disk = true`, or a custom `ty`/`create` to use a store with a fallible error type. --> tests/ui/concurrent_cached_map_error_on_infallible.rs:5:4 | 5 | fn simple(n: u64) -> u64 { diff --git a/tests/ui/concurrent_cached_max_size_with_redis.rs b/tests/ui/concurrent_cached_max_size_with_redis.rs index b824121a..ee2d9f9a 100644 --- a/tests/ui/concurrent_cached_max_size_with_redis.rs +++ b/tests/ui/concurrent_cached_max_size_with_redis.rs @@ -2,7 +2,7 @@ use cached::macros::concurrent_cached; #[concurrent_cached( redis = true, - ttl = 60, + ttl_secs = 60, max_size = 100, map_error = r#"|e| format!("{:?}", e)"# )] diff --git a/tests/ui/concurrent_cached_max_size_with_redis_ty.rs b/tests/ui/concurrent_cached_max_size_with_redis_ty.rs index 25dbf8ac..7a2f2748 100644 --- a/tests/ui/concurrent_cached_max_size_with_redis_ty.rs +++ b/tests/ui/concurrent_cached_max_size_with_redis_ty.rs @@ -2,7 +2,7 @@ use cached::macros::concurrent_cached; #[concurrent_cached( redis = true, - ttl = 60, + ttl_secs = 60, max_size = 100, ty = "cached::UnboundCache", map_error = r#"|e| format!("{:?}", e)"# diff --git a/tests/ui/concurrent_cached_name_invalid_ident.rs b/tests/ui/concurrent_cached_name_invalid_ident.rs new file mode 100644 index 00000000..152db140 --- /dev/null +++ b/tests/ui/concurrent_cached_name_invalid_ident.rs @@ -0,0 +1,8 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached(name = "bad-name")] +fn f(x: i32) -> i32 { + x +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_name_invalid_ident.stderr b/tests/ui/concurrent_cached_name_invalid_ident.stderr new file mode 100644 index 00000000..682b6b47 --- /dev/null +++ b/tests/ui/concurrent_cached_name_invalid_ident.stderr @@ -0,0 +1,5 @@ +error: `name` must be a valid Rust identifier + --> tests/ui/concurrent_cached_name_invalid_ident.rs:4:4 + | +4 | fn f(x: i32) -> i32 { + | ^ diff --git a/tests/ui/concurrent_cached_option_with_redis.rs b/tests/ui/concurrent_cached_option_with_redis.rs index fe456808..0cae6d3a 100644 --- a/tests/ui/concurrent_cached_option_with_redis.rs +++ b/tests/ui/concurrent_cached_option_with_redis.rs @@ -1,7 +1,7 @@ use cached::macros::concurrent_cached; // Option return type is only supported for the default in-memory sharded path, not redis. -#[concurrent_cached(map_error = "|e| e", redis = true, ttl = 60)] +#[concurrent_cached(map_error = "|e| e", redis = true, ttl_secs = 60)] fn my_fn(k: i32) -> Option { Some(k) } diff --git a/tests/ui/concurrent_cached_redis_create_conflict.rs b/tests/ui/concurrent_cached_redis_create_conflict.rs index 117c5119..3a4556f5 100644 --- a/tests/ui/concurrent_cached_redis_create_conflict.rs +++ b/tests/ui/concurrent_cached_redis_create_conflict.rs @@ -1,6 +1,6 @@ use cached::macros::concurrent_cached; -#[concurrent_cached(map_error = "|e| e", redis = true, ttl = 1, create = "{ }")] +#[concurrent_cached(map_error = "|e| e", redis = true, ttl_secs = 1, create = "{ }")] fn my_fn(k: i32) -> Result { Ok(k) } diff --git a/tests/ui/concurrent_cached_redis_create_conflict.stderr b/tests/ui/concurrent_cached_redis_create_conflict.stderr index bbc4177e..e6cdedf9 100644 --- a/tests/ui/concurrent_cached_redis_create_conflict.stderr +++ b/tests/ui/concurrent_cached_redis_create_conflict.stderr @@ -1,4 +1,4 @@ -error: cannot specify `ttl` when passing a `create` block — `create` fully constructs the store, so these store-builder attributes would be silently ignored +error: cannot specify `ttl_secs` when passing a `create` block - `create` fully constructs the store, so these store-builder attributes would be silently ignored --> tests/ui/concurrent_cached_redis_create_conflict.rs:4:4 | 4 | fn my_fn(k: i32) -> Result { diff --git a/tests/ui/concurrent_cached_redis_disk_exclusive.rs b/tests/ui/concurrent_cached_redis_disk_exclusive.rs index b74f069a..b3373666 100644 --- a/tests/ui/concurrent_cached_redis_disk_exclusive.rs +++ b/tests/ui/concurrent_cached_redis_disk_exclusive.rs @@ -1,6 +1,6 @@ use cached::macros::concurrent_cached; -#[concurrent_cached(redis = true, disk = true, ttl = 60, map_error = r#"|e| format!("{:?}", e)"#)] +#[concurrent_cached(redis = true, disk = true, ttl_secs = 60, map_error = r#"|e| format!("{:?}", e)"#)] fn my_fn(k: i32) -> Result { Ok(k) } diff --git a/tests/ui/concurrent_cached_redis_no_ttl.stderr b/tests/ui/concurrent_cached_redis_no_ttl.stderr index d10bb672..5e6fc28d 100644 --- a/tests/ui/concurrent_cached_redis_no_ttl.stderr +++ b/tests/ui/concurrent_cached_redis_no_ttl.stderr @@ -1,4 +1,4 @@ -error: RedisCache requires a `ttl` when `create` block is not specified +error: RedisCache requires a TTL (`ttl`/`ttl_secs`/`ttl_millis`) when `create` block is not specified --> tests/ui/concurrent_cached_redis_no_ttl.rs:4:4 | 4 | fn my_fn(k: i32) -> Result { diff --git a/tests/ui/concurrent_cached_refresh_create_conflict.rs b/tests/ui/concurrent_cached_refresh_create_conflict.rs new file mode 100644 index 00000000..aad246a7 --- /dev/null +++ b/tests/ui/concurrent_cached_refresh_create_conflict.rs @@ -0,0 +1,17 @@ +use cached::macros::concurrent_cached; + +// `create` fully constructs the store, so `refresh` (which the macro would +// otherwise wire via `refresh_on_hit`) would be silently ignored - the macro +// must reject it with a specific message. Parity with the `#[cached]` +// `cached_refresh_create_conflict` fixture. +#[concurrent_cached( + map_error = "|e| e", + ty = "cached::UnboundCache", + refresh = true, + create = "{ cached::UnboundCache::new() }" +)] +fn my_fn(k: i32) -> Result { + Ok(k) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_refresh_create_conflict.stderr b/tests/ui/concurrent_cached_refresh_create_conflict.stderr new file mode 100644 index 00000000..3971fa76 --- /dev/null +++ b/tests/ui/concurrent_cached_refresh_create_conflict.stderr @@ -0,0 +1,5 @@ +error: cannot specify `refresh` when passing a `create` block - `create` fully constructs the store, so these store-builder attributes would be silently ignored + --> tests/ui/concurrent_cached_refresh_create_conflict.rs:13:4 + | +13 | fn my_fn(k: i32) -> Result { + | ^^^^^ diff --git a/tests/ui/concurrent_cached_refresh_without_ttl.stderr b/tests/ui/concurrent_cached_refresh_without_ttl.stderr index ab88d31c..6618d10c 100644 --- a/tests/ui/concurrent_cached_refresh_without_ttl.stderr +++ b/tests/ui/concurrent_cached_refresh_without_ttl.stderr @@ -1,4 +1,4 @@ -error: `refresh` requires `ttl` to be set on the default in-memory sharded path +error: `refresh` requires a TTL (`ttl`/`ttl_secs`/`ttl_millis`) to be set on the default in-memory sharded path --> tests/ui/concurrent_cached_refresh_without_ttl.rs:4:4 | 4 | fn my_fn(k: i32) -> Result { diff --git a/tests/ui/concurrent_cached_result_fallback_disk_exclusive.rs b/tests/ui/concurrent_cached_result_fallback_disk_exclusive.rs index e08a0719..cb129db8 100644 --- a/tests/ui/concurrent_cached_result_fallback_disk_exclusive.rs +++ b/tests/ui/concurrent_cached_result_fallback_disk_exclusive.rs @@ -1,6 +1,6 @@ use cached::macros::concurrent_cached; -#[concurrent_cached(disk = true, ttl = 60, result_fallback = true)] +#[concurrent_cached(disk = true, ttl_secs = 60, result_fallback = true)] fn my_fn(k: i32) -> Result { Ok(k) } diff --git a/tests/ui/concurrent_cached_result_fallback_expires_exclusive.stderr b/tests/ui/concurrent_cached_result_fallback_expires_exclusive.stderr index 442b313f..4096d7a2 100644 --- a/tests/ui/concurrent_cached_result_fallback_expires_exclusive.stderr +++ b/tests/ui/concurrent_cached_result_fallback_expires_exclusive.stderr @@ -1,4 +1,4 @@ -error: `result_fallback = true` and `expires = true` are mutually exclusive — `expires` selects a per-value expiry store; `result_fallback` requires a fixed-TTL store whose entry expiry can be detected and refreshed by the cache layer, which per-value expiry does not support. Note: `ttl` and `expires` serve different purposes — `ttl` applies a fixed TTL to all entries, while `expires` delegates expiry to each value. If you need time-based expiry together with `result_fallback`, use `ttl` (not `expires`). +error: `result_fallback = true` and `expires = true` are mutually exclusive - `expires` selects a per-value expiry store; `result_fallback` requires a fixed-TTL store whose entry expiry can be detected and refreshed by the cache layer, which per-value expiry does not support. Note: `ttl` and `expires` serve different purposes - `ttl` applies a fixed TTL to all entries, while `expires` delegates expiry to each value. If you need time-based expiry together with `result_fallback`, use `ttl` (not `expires`). --> tests/ui/concurrent_cached_result_fallback_expires_exclusive.rs:10:4 | 10 | fn my_fn(x: u32) -> Result { diff --git a/tests/ui/concurrent_cached_result_fallback_redis_exclusive.rs b/tests/ui/concurrent_cached_result_fallback_redis_exclusive.rs index 3d1ada0f..69fb3f40 100644 --- a/tests/ui/concurrent_cached_result_fallback_redis_exclusive.rs +++ b/tests/ui/concurrent_cached_result_fallback_redis_exclusive.rs @@ -2,7 +2,7 @@ use cached::macros::concurrent_cached; #[concurrent_cached( redis = true, - ttl = 60, + ttl_secs = 60, result_fallback = true, map_error = r#"|e| format!("{:?}", e)"# )] diff --git a/tests/ui/concurrent_cached_result_fallback_requires_ttl.stderr b/tests/ui/concurrent_cached_result_fallback_requires_ttl.stderr index c5168a6a..9b94dc15 100644 --- a/tests/ui/concurrent_cached_result_fallback_requires_ttl.stderr +++ b/tests/ui/concurrent_cached_result_fallback_requires_ttl.stderr @@ -1,4 +1,4 @@ -error: `result_fallback` requires `ttl` to be set (e.g. `ttl = 60`). It serves the last cached `Ok` value when a refresh returns `Err`, but a refresh only happens after an entry expires. Without a TTL entries never expire, so the function body is never re-run for a cached key and the fallback can never fire — making the option a no-op. Set a TTL so cached entries expire and `result_fallback` has something to fall back to. +error: `result_fallback` requires a TTL (`ttl`/`ttl_secs`/`ttl_millis`) to be set (e.g. `ttl_secs = 60`). It serves the last cached `Ok` value when a refresh returns `Err`, but a refresh only happens after an entry expires. Without a TTL entries never expire, so the function body is never re-run for a cached key and the fallback can never fire - making the option a no-op. Set a TTL so cached entries expire and `result_fallback` has something to fall back to. --> tests/ui/concurrent_cached_result_fallback_requires_ttl.rs:7:4 | 7 | fn my_fn(x: u32) -> Result { diff --git a/tests/ui/concurrent_cached_result_fallback_with_cached_flag_exclusive.rs b/tests/ui/concurrent_cached_result_fallback_with_cached_flag_exclusive.rs index 185e05ad..a4105083 100644 --- a/tests/ui/concurrent_cached_result_fallback_with_cached_flag_exclusive.rs +++ b/tests/ui/concurrent_cached_result_fallback_with_cached_flag_exclusive.rs @@ -1,6 +1,6 @@ use cached::macros::concurrent_cached; -#[concurrent_cached(ttl = 1, result_fallback = true, with_cached_flag = true)] +#[concurrent_cached(ttl_secs = 1, result_fallback = true, with_cached_flag = true)] fn my_fn(k: i32) -> Result, ()> { Ok(cached::Return::new(k)) } diff --git a/tests/ui/concurrent_cached_result_fallback_with_cached_flag_exclusive.stderr b/tests/ui/concurrent_cached_result_fallback_with_cached_flag_exclusive.stderr index 2930ca86..5a38d5ce 100644 --- a/tests/ui/concurrent_cached_result_fallback_with_cached_flag_exclusive.stderr +++ b/tests/ui/concurrent_cached_result_fallback_with_cached_flag_exclusive.stderr @@ -1,4 +1,4 @@ -error: `result_fallback` and `with_cached_flag` are mutually exclusive: `result_fallback` stores the inner `Ok(T)` value directly, but `with_cached_flag` wraps the `Ok` value in `Return` — the generated code cannot simultaneously store `T` and expose `Return` through the cached function. Use `with_cached_flag = true` alone (without `result_fallback`) or `result_fallback = true` alone. +error: `result_fallback` and `with_cached_flag` are mutually exclusive: `result_fallback` stores the inner `Ok(T)` value directly, but `with_cached_flag` wraps the `Ok` value in `Return` - the generated code cannot simultaneously store `T` and expose `Return` through the cached function. Use `with_cached_flag = true` alone (without `result_fallback`) or `result_fallback = true` alone. --> tests/ui/concurrent_cached_result_fallback_with_cached_flag_exclusive.rs:4:4 | 4 | fn my_fn(k: i32) -> Result, ()> { diff --git a/tests/ui/concurrent_cached_self_method.stderr b/tests/ui/concurrent_cached_self_method.stderr index 3d3386d6..1df3aafd 100644 --- a/tests/ui/concurrent_cached_self_method.stderr +++ b/tests/ui/concurrent_cached_self_method.stderr @@ -1,4 +1,4 @@ -error: #[concurrent_cached] cannot be applied to methods that take `self` +error: #[concurrent_cached] cannot be applied to methods that take `self`. Set `in_impl = true` to cache the method inside its `impl` block (a `convert` block alone is not sufficient: the generated cache static cannot live at `impl` scope). --> tests/ui/concurrent_cached_self_method.rs:4:4 | 4 | fn my_fn(&self, k: i32) -> Result { diff --git a/tests/ui/concurrent_cached_shards_with_disk.rs b/tests/ui/concurrent_cached_shards_with_disk.rs index 9f2cfe55..4df471cf 100644 --- a/tests/ui/concurrent_cached_shards_with_disk.rs +++ b/tests/ui/concurrent_cached_shards_with_disk.rs @@ -3,7 +3,7 @@ use cached::macros::concurrent_cached; #[concurrent_cached( disk = true, disk_dir = "/tmp/cached-trybuild", - ttl = 60, + ttl_secs = 60, shards = 16, map_error = r#"|e| format!("{:?}", e)"# )] diff --git a/tests/ui/concurrent_cached_shards_with_redis.rs b/tests/ui/concurrent_cached_shards_with_redis.rs index 9ec55f45..3cb5082d 100644 --- a/tests/ui/concurrent_cached_shards_with_redis.rs +++ b/tests/ui/concurrent_cached_shards_with_redis.rs @@ -2,7 +2,7 @@ use cached::macros::concurrent_cached; #[concurrent_cached( redis = true, - ttl = 60, + ttl_secs = 60, shards = 16, map_error = r#"|e| format!("{:?}", e)"# )] diff --git a/tests/ui/concurrent_cached_sync_lock_unsupported.rs b/tests/ui/concurrent_cached_sync_lock_unsupported.rs new file mode 100644 index 00000000..45df443c --- /dev/null +++ b/tests/ui/concurrent_cached_sync_lock_unsupported.rs @@ -0,0 +1,8 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached(sync_lock = "mutex")] +fn f(x: i32) -> i32 { + x +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_sync_lock_unsupported.stderr b/tests/ui/concurrent_cached_sync_lock_unsupported.stderr new file mode 100644 index 00000000..dc2568c0 --- /dev/null +++ b/tests/ui/concurrent_cached_sync_lock_unsupported.stderr @@ -0,0 +1,5 @@ +error: `sync_lock` is not supported on `#[concurrent_cached]` + --> tests/ui/concurrent_cached_sync_lock_unsupported.rs:3:21 + | +3 | #[concurrent_cached(sync_lock = "mutex")] + | ^^^^^^^^^ diff --git a/tests/ui/concurrent_cached_sync_writes_attr_unsupported.stderr b/tests/ui/concurrent_cached_sync_writes_attr_unsupported.stderr index d8c198f4..62edba4d 100644 --- a/tests/ui/concurrent_cached_sync_writes_attr_unsupported.stderr +++ b/tests/ui/concurrent_cached_sync_writes_attr_unsupported.stderr @@ -1,4 +1,4 @@ -error: `sync_writes` is not supported by #[concurrent_cached]; concurrent stores synchronize cache access internally but do not deduplicate first-call execution +error: `sync_writes` is not supported on `#[concurrent_cached]`; concurrent stores synchronize cache access internally but do not deduplicate first-call execution --> tests/ui/concurrent_cached_sync_writes_attr_unsupported.rs:3:21 | 3 | #[concurrent_cached(sync_writes = true)] diff --git a/tests/ui/concurrent_cached_sync_writes_buckets_unsupported.rs b/tests/ui/concurrent_cached_sync_writes_buckets_unsupported.rs new file mode 100644 index 00000000..98646ebe --- /dev/null +++ b/tests/ui/concurrent_cached_sync_writes_buckets_unsupported.rs @@ -0,0 +1,8 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached(sync_writes_buckets = 32)] +fn f(x: i32) -> i32 { + x +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_sync_writes_buckets_unsupported.stderr b/tests/ui/concurrent_cached_sync_writes_buckets_unsupported.stderr new file mode 100644 index 00000000..7abc5932 --- /dev/null +++ b/tests/ui/concurrent_cached_sync_writes_buckets_unsupported.stderr @@ -0,0 +1,5 @@ +error: `sync_writes_buckets` is not supported on `#[concurrent_cached]` + --> tests/ui/concurrent_cached_sync_writes_buckets_unsupported.rs:3:21 + | +3 | #[concurrent_cached(sync_writes_buckets = 32)] + | ^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/concurrent_cached_time_attr_renamed.stderr b/tests/ui/concurrent_cached_time_attr_renamed.stderr index d1955fda..28952756 100644 --- a/tests/ui/concurrent_cached_time_attr_renamed.stderr +++ b/tests/ui/concurrent_cached_time_attr_renamed.stderr @@ -1,4 +1,4 @@ -error: `time` was renamed to `ttl` in cached 1.0; use `ttl = ...` +error: `time` (whole seconds) was renamed in cached 1.0; use `ttl_secs = ...` (or `ttl = "Duration::from_secs(...)"` / `ttl_millis = ...`) --> tests/ui/concurrent_cached_time_attr_renamed.rs:4:4 | 4 | fn my_fn(k: i32) -> Result { diff --git a/tests/ui/concurrent_cached_ttl_and_ttl_millis_exclusive.rs b/tests/ui/concurrent_cached_ttl_and_ttl_millis_exclusive.rs new file mode 100644 index 00000000..bcb16854 --- /dev/null +++ b/tests/ui/concurrent_cached_ttl_and_ttl_millis_exclusive.rs @@ -0,0 +1,8 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached(ttl = "core::time::Duration::from_secs(1)", ttl_millis = 500)] +fn f(x: i32) -> Result { + Ok(x) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_ttl_and_ttl_millis_exclusive.stderr b/tests/ui/concurrent_cached_ttl_and_ttl_millis_exclusive.stderr new file mode 100644 index 00000000..a38bb638 --- /dev/null +++ b/tests/ui/concurrent_cached_ttl_and_ttl_millis_exclusive.stderr @@ -0,0 +1,5 @@ +error: `ttl`, `ttl_secs`, and `ttl_millis` are mutually exclusive - `ttl` takes a `Duration` expression, `ttl_secs` whole seconds, `ttl_millis` milliseconds; use exactly one + --> tests/ui/concurrent_cached_ttl_and_ttl_millis_exclusive.rs:4:4 + | +4 | fn f(x: i32) -> Result { + | ^ diff --git a/tests/ui/concurrent_cached_ttl_integer_migration.rs b/tests/ui/concurrent_cached_ttl_integer_migration.rs new file mode 100644 index 00000000..b4d79a39 --- /dev/null +++ b/tests/ui/concurrent_cached_ttl_integer_migration.rs @@ -0,0 +1,8 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached(ttl = 60)] +fn f(x: i32) -> Result { + Ok(x) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_ttl_integer_migration.stderr b/tests/ui/concurrent_cached_ttl_integer_migration.stderr new file mode 100644 index 00000000..0c5612ee --- /dev/null +++ b/tests/ui/concurrent_cached_ttl_integer_migration.stderr @@ -0,0 +1,5 @@ +error: `ttl` now takes a Duration expression (e.g. `ttl = "Duration::from_secs(60)"`); for whole seconds use `ttl_secs = 60`, for milliseconds use `ttl_millis = 500`. + --> tests/ui/concurrent_cached_ttl_integer_migration.rs:3:27 + | +3 | #[concurrent_cached(ttl = 60)] + | ^^ diff --git a/tests/ui/concurrent_cached_ttl_millis_create_conflict.rs b/tests/ui/concurrent_cached_ttl_millis_create_conflict.rs new file mode 100644 index 00000000..f6fbe974 --- /dev/null +++ b/tests/ui/concurrent_cached_ttl_millis_create_conflict.rs @@ -0,0 +1,16 @@ +use cached::macros::concurrent_cached; + +// `create` fully constructs the store, so `ttl_millis` would be silently +// ignored - the macro must reject it just as it rejects `ttl` (#149). Without +// the conflict check this compiles and the TTL is dropped without warning. +#[concurrent_cached( + map_error = "|e| e", + disk = true, + ttl_millis = 500, + create = "{ todo!() }" +)] +fn my_fn(k: i32) -> Result { + Ok(k) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_ttl_millis_create_conflict.stderr b/tests/ui/concurrent_cached_ttl_millis_create_conflict.stderr new file mode 100644 index 00000000..89fc8ae3 --- /dev/null +++ b/tests/ui/concurrent_cached_ttl_millis_create_conflict.stderr @@ -0,0 +1,5 @@ +error: cannot specify `ttl_millis` when passing a `create` block - `create` fully constructs the store, so these store-builder attributes would be silently ignored + --> tests/ui/concurrent_cached_ttl_millis_create_conflict.rs:12:4 + | +12 | fn my_fn(k: i32) -> Result { + | ^^^^^ diff --git a/tests/ui/concurrent_cached_ttl_millis_zero.rs b/tests/ui/concurrent_cached_ttl_millis_zero.rs new file mode 100644 index 00000000..772356e8 --- /dev/null +++ b/tests/ui/concurrent_cached_ttl_millis_zero.rs @@ -0,0 +1,8 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached(ttl_millis = 0)] +fn f(x: i32) -> i32 { + x +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_ttl_millis_zero.stderr b/tests/ui/concurrent_cached_ttl_millis_zero.stderr new file mode 100644 index 00000000..4f6418fc --- /dev/null +++ b/tests/ui/concurrent_cached_ttl_millis_zero.stderr @@ -0,0 +1,5 @@ +error: `ttl_millis` must be >= 1 + --> tests/ui/concurrent_cached_ttl_millis_zero.rs:4:4 + | +4 | fn f(x: i32) -> i32 { + | ^ diff --git a/tests/ui/concurrent_cached_ttl_secs_and_ttl_millis_exclusive.rs b/tests/ui/concurrent_cached_ttl_secs_and_ttl_millis_exclusive.rs new file mode 100644 index 00000000..3e107aaa --- /dev/null +++ b/tests/ui/concurrent_cached_ttl_secs_and_ttl_millis_exclusive.rs @@ -0,0 +1,8 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached(ttl_secs = 1, ttl_millis = 500)] +fn f(x: i32) -> Result { + Ok(x) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_ttl_secs_and_ttl_millis_exclusive.stderr b/tests/ui/concurrent_cached_ttl_secs_and_ttl_millis_exclusive.stderr new file mode 100644 index 00000000..1bdae264 --- /dev/null +++ b/tests/ui/concurrent_cached_ttl_secs_and_ttl_millis_exclusive.stderr @@ -0,0 +1,5 @@ +error: `ttl`, `ttl_secs`, and `ttl_millis` are mutually exclusive - `ttl` takes a `Duration` expression, `ttl_secs` whole seconds, `ttl_millis` milliseconds; use exactly one + --> tests/ui/concurrent_cached_ttl_secs_and_ttl_millis_exclusive.rs:4:4 + | +4 | fn f(x: i32) -> Result { + | ^ diff --git a/tests/ui/concurrent_cached_ttl_ttl_secs_exclusive.rs b/tests/ui/concurrent_cached_ttl_ttl_secs_exclusive.rs new file mode 100644 index 00000000..30241800 --- /dev/null +++ b/tests/ui/concurrent_cached_ttl_ttl_secs_exclusive.rs @@ -0,0 +1,8 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached(ttl = "core::time::Duration::from_secs(1)", ttl_secs = 1)] +fn f(x: i32) -> Result { + Ok(x) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_ttl_ttl_secs_exclusive.stderr b/tests/ui/concurrent_cached_ttl_ttl_secs_exclusive.stderr new file mode 100644 index 00000000..cb1461c5 --- /dev/null +++ b/tests/ui/concurrent_cached_ttl_ttl_secs_exclusive.stderr @@ -0,0 +1,5 @@ +error: `ttl`, `ttl_secs`, and `ttl_millis` are mutually exclusive - `ttl` takes a `Duration` expression, `ttl_secs` whole seconds, `ttl_millis` milliseconds; use exactly one + --> tests/ui/concurrent_cached_ttl_ttl_secs_exclusive.rs:4:4 + | +4 | fn f(x: i32) -> Result { + | ^ diff --git a/tests/ui/concurrent_cached_ttl_unparseable_duration.rs b/tests/ui/concurrent_cached_ttl_unparseable_duration.rs new file mode 100644 index 00000000..333400e0 --- /dev/null +++ b/tests/ui/concurrent_cached_ttl_unparseable_duration.rs @@ -0,0 +1,8 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached(ttl = "core::time::Duration::from_secs(")] +fn f() -> u32 { + 0 +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_ttl_unparseable_duration.stderr b/tests/ui/concurrent_cached_ttl_unparseable_duration.stderr new file mode 100644 index 00000000..b892a234 --- /dev/null +++ b/tests/ui/concurrent_cached_ttl_unparseable_duration.stderr @@ -0,0 +1,5 @@ +error: unable to parse `ttl` as a Duration expression: cannot parse string into token stream; `ttl` takes a `Duration` expression as a string literal, e.g. `ttl = "core::time::Duration::from_secs(60)"` + --> tests/ui/concurrent_cached_ttl_unparseable_duration.rs:3:27 + | +3 | #[concurrent_cached(ttl = "core::time::Duration::from_secs(")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/concurrent_cached_ttl_zero.rs b/tests/ui/concurrent_cached_ttl_zero.rs index eabf2ceb..8b375d92 100644 --- a/tests/ui/concurrent_cached_ttl_zero.rs +++ b/tests/ui/concurrent_cached_ttl_zero.rs @@ -1,6 +1,6 @@ use cached::macros::concurrent_cached; -#[concurrent_cached(ttl = 0)] +#[concurrent_cached(ttl_secs = 0)] fn my_fn(k: i32) -> Result { Ok(k) } diff --git a/tests/ui/concurrent_cached_ttl_zero.stderr b/tests/ui/concurrent_cached_ttl_zero.stderr index a3af76d9..7f04453b 100644 --- a/tests/ui/concurrent_cached_ttl_zero.stderr +++ b/tests/ui/concurrent_cached_ttl_zero.stderr @@ -1,4 +1,4 @@ -error: `ttl` must be >= 1 +error: `ttl_secs` must be >= 1 --> tests/ui/concurrent_cached_ttl_zero.rs:4:4 | 4 | fn my_fn(k: i32) -> Result { diff --git a/tests/ui/concurrent_cached_unsync_reads_unsupported.rs b/tests/ui/concurrent_cached_unsync_reads_unsupported.rs new file mode 100644 index 00000000..f7456828 --- /dev/null +++ b/tests/ui/concurrent_cached_unsync_reads_unsupported.rs @@ -0,0 +1,8 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached(unsync_reads = true)] +fn f(x: i32) -> i32 { + x +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_unsync_reads_unsupported.stderr b/tests/ui/concurrent_cached_unsync_reads_unsupported.stderr new file mode 100644 index 00000000..3fa310f4 --- /dev/null +++ b/tests/ui/concurrent_cached_unsync_reads_unsupported.stderr @@ -0,0 +1,5 @@ +error: `unsync_reads` is not supported on `#[concurrent_cached]` + --> tests/ui/concurrent_cached_unsync_reads_unsupported.rs:3:21 + | +3 | #[concurrent_cached(unsync_reads = true)] + | ^^^^^^^^^^^^ diff --git a/tests/ui/concurrent_cached_with_cached_flag_option.stderr b/tests/ui/concurrent_cached_with_cached_flag_option.stderr index fde2e9a9..eef57cc1 100644 --- a/tests/ui/concurrent_cached_with_cached_flag_option.stderr +++ b/tests/ui/concurrent_cached_with_cached_flag_option.stderr @@ -1,4 +1,4 @@ -error: `with_cached_flag = true` and `cache_none = true` are structurally incompatible on `Option` returns: `with_cached_flag` unwraps `Return` and stores `T`, while `cache_none = true` stores `Option` as the cached value — the same store cannot satisfy both. Remove one: use `with_cached_flag = true` alone to receive a `Return` that signals cache hits, or use `cache_none = true` alone (without `with_cached_flag`) to cache `None` values. +error: `with_cached_flag = true` and `cache_none = true` are structurally incompatible on `Option` returns: `with_cached_flag` unwraps `Return` and stores `T`, while `cache_none = true` stores `Option` as the cached value - the same store cannot satisfy both. Remove one: use `with_cached_flag = true` alone to receive a `Return` that signals cache hits, or use `cache_none = true` alone (without `with_cached_flag`) to cache `None` values. --> tests/ui/concurrent_cached_with_cached_flag_option.rs:5:21 | 5 | fn my_fn(k: i32) -> Option> { diff --git a/tests/ui/once_cache_none_with_cached_flag_exclusive.stderr b/tests/ui/once_cache_none_with_cached_flag_exclusive.stderr index 9e0d1e6a..27a3b694 100644 --- a/tests/ui/once_cache_none_with_cached_flag_exclusive.stderr +++ b/tests/ui/once_cache_none_with_cached_flag_exclusive.stderr @@ -1,4 +1,4 @@ -error: `cache_none = true` and `with_cached_flag = true` are structurally incompatible on `Option` returns: `with_cached_flag` stores the inner `T` from `Return` while `cache_none = true` stores `Option` as the cached value — the same cache entry cannot hold both types. Use `with_cached_flag = true` alone (to get cache-state flags; `None` is not cached by default), or use `cache_none = true` alone (to force-cache `None` values). +error: `cache_none = true` and `with_cached_flag = true` are structurally incompatible on `Option` returns: `with_cached_flag` stores the inner `T` from `Return` while `cache_none = true` stores `Option` as the cached value - the same cache entry cannot hold both types. Use `with_cached_flag = true` alone (to get cache-state flags; `None` is not cached by default), or use `cache_none = true` alone (to force-cache `None` values). --> tests/ui/once_cache_none_with_cached_flag_exclusive.rs:4:4 | 4 | fn my_fn() -> Option> { diff --git a/tests/ui/once_convert_rejected.rs b/tests/ui/once_convert_rejected.rs new file mode 100644 index 00000000..620bc433 --- /dev/null +++ b/tests/ui/once_convert_rejected.rs @@ -0,0 +1,8 @@ +use cached::macros::once; + +#[once(convert = "{ a }")] +fn f(a: i32) -> i32 { + a +} + +fn main() {} diff --git a/tests/ui/once_convert_rejected.stderr b/tests/ui/once_convert_rejected.stderr new file mode 100644 index 00000000..dd68ae37 --- /dev/null +++ b/tests/ui/once_convert_rejected.stderr @@ -0,0 +1,5 @@ +error: `convert` is not supported on `#[once]`; `#[once]` stores a single value for all arguments and has no per-call cache key to convert + --> tests/ui/once_convert_rejected.rs:4:4 + | +4 | fn f(a: i32) -> i32 { + | ^ diff --git a/tests/ui/once_create_rejected.rs b/tests/ui/once_create_rejected.rs new file mode 100644 index 00000000..01da28e5 --- /dev/null +++ b/tests/ui/once_create_rejected.rs @@ -0,0 +1,8 @@ +use cached::macros::once; + +#[once(create = "{ cached::UnboundCache::new() }")] +fn f() -> i32 { + 42 +} + +fn main() {} diff --git a/tests/ui/once_create_rejected.stderr b/tests/ui/once_create_rejected.stderr new file mode 100644 index 00000000..4eb89c06 --- /dev/null +++ b/tests/ui/once_create_rejected.stderr @@ -0,0 +1,5 @@ +error: `create` is not supported on `#[once]`; `#[once]` manages its own single-value storage and does not take a custom store constructor + --> tests/ui/once_create_rejected.rs:4:4 + | +4 | fn f() -> i32 { + | ^ diff --git a/tests/ui/once_disk_concurrent_only.rs b/tests/ui/once_disk_concurrent_only.rs new file mode 100644 index 00000000..3d177a18 --- /dev/null +++ b/tests/ui/once_disk_concurrent_only.rs @@ -0,0 +1,8 @@ +use cached::macros::once; + +#[once(disk = true)] +fn load() -> u64 { + 42 +} + +fn main() {} diff --git a/tests/ui/once_disk_concurrent_only.stderr b/tests/ui/once_disk_concurrent_only.stderr new file mode 100644 index 00000000..eea86dff --- /dev/null +++ b/tests/ui/once_disk_concurrent_only.stderr @@ -0,0 +1,5 @@ +error: `disk` is not supported on `#[once]`; `disk` selects the redb disk-backed concurrent store. Use `#[concurrent_cached(disk = true)]` instead. + --> tests/ui/once_disk_concurrent_only.rs:3:8 + | +3 | #[once(disk = true)] + | ^^^^ diff --git a/tests/ui/once_expires_and_ttl_millis_exclusive.rs b/tests/ui/once_expires_and_ttl_millis_exclusive.rs new file mode 100644 index 00000000..112516ea --- /dev/null +++ b/tests/ui/once_expires_and_ttl_millis_exclusive.rs @@ -0,0 +1,8 @@ +use cached::macros::once; + +#[once(expires = true, ttl_millis = 500)] +fn f(x: i32) -> i32 { + x +} + +fn main() {} diff --git a/tests/ui/once_expires_and_ttl_millis_exclusive.stderr b/tests/ui/once_expires_and_ttl_millis_exclusive.stderr new file mode 100644 index 00000000..4869c842 --- /dev/null +++ b/tests/ui/once_expires_and_ttl_millis_exclusive.stderr @@ -0,0 +1,5 @@ +error: `expires` and `ttl_millis` are mutually exclusive - `expires` delegates expiry to the value via the `Expires` trait; `ttl_millis` applies a uniform millisecond TTL to all entries + --> tests/ui/once_expires_and_ttl_millis_exclusive.rs:4:4 + | +4 | fn f(x: i32) -> i32 { + | ^ diff --git a/tests/ui/once_expires_cache_err_exclusive.stderr b/tests/ui/once_expires_cache_err_exclusive.stderr index 3114dc31..d32ee76b 100644 --- a/tests/ui/once_expires_cache_err_exclusive.stderr +++ b/tests/ui/once_expires_cache_err_exclusive.stderr @@ -1,4 +1,4 @@ -error: `expires = true` and `cache_err = true` are incompatible — `expires` requires the cache value type to implement `Expires`, but `cache_err = true` stores `Result` as the value, which does not implement `Expires`. Remove `cache_err = true` (Err values are not cached by default with `expires = true`). +error: `expires = true` and `cache_err = true` are incompatible - `expires` requires the cache value type to implement `Expires`, but `cache_err = true` stores `Result` as the value, which does not implement `Expires`. Remove `cache_err = true` (Err values are not cached by default with `expires = true`). --> tests/ui/once_expires_cache_err_exclusive.rs:10:4 | 10 | fn my_fn() -> Result { diff --git a/tests/ui/once_expires_cache_none_exclusive.stderr b/tests/ui/once_expires_cache_none_exclusive.stderr index 80b6ba67..a41ae10a 100644 --- a/tests/ui/once_expires_cache_none_exclusive.stderr +++ b/tests/ui/once_expires_cache_none_exclusive.stderr @@ -1,4 +1,4 @@ -error: `expires = true` and `cache_none = true` are incompatible — `expires` requires the cache value type to implement `Expires`, but `cache_none = true` stores `Option` as the value, which does not implement `Expires`. Remove `cache_none = true` (None values are not cached by default with `expires = true`). +error: `expires = true` and `cache_none = true` are incompatible - `expires` requires the cache value type to implement `Expires`, but `cache_none = true` stores `Option` as the value, which does not implement `Expires`. Remove `cache_none = true` (None values are not cached by default with `expires = true`). --> tests/ui/once_expires_cache_none_exclusive.rs:10:4 | 10 | fn my_fn() -> Option { diff --git a/tests/ui/once_expires_malformed_ttl.rs b/tests/ui/once_expires_malformed_ttl.rs new file mode 100644 index 00000000..0d750a9b --- /dev/null +++ b/tests/ui/once_expires_malformed_ttl.rs @@ -0,0 +1,12 @@ +use cached::macros::once; + +// expires = true combined with a malformed ttl expression. +// The macro must fire the "mutually exclusive" error for expires+ttl BEFORE +// attempting to parse the ttl string. Old code order would emit a parse error +// for the malformed ttl; new code emits the exclusion error. +#[once(expires = true, ttl = "core::time::Duration::from_secs(")] +fn my_fn() -> String { + "x".to_string() +} + +fn main() {} diff --git a/tests/ui/once_expires_malformed_ttl.stderr b/tests/ui/once_expires_malformed_ttl.stderr new file mode 100644 index 00000000..2c6cbbfe --- /dev/null +++ b/tests/ui/once_expires_malformed_ttl.stderr @@ -0,0 +1,5 @@ +error: `expires` and `ttl` are mutually exclusive - `expires` delegates expiry to the value via the `Expires` trait; `ttl` applies a uniform time-based TTL + --> tests/ui/once_expires_malformed_ttl.rs:8:4 + | +8 | fn my_fn() -> String { + | ^^^^^ diff --git a/tests/ui/once_expires_ttl_exclusive.rs b/tests/ui/once_expires_ttl_exclusive.rs index 67f33e06..c39b25d5 100644 --- a/tests/ui/once_expires_ttl_exclusive.rs +++ b/tests/ui/once_expires_ttl_exclusive.rs @@ -1,6 +1,6 @@ use cached::macros::once; -#[once(ttl = 60, expires = true)] +#[once(ttl = "core::time::Duration::from_secs(60)", expires = true)] fn my_fn() -> String { "x".to_string() } diff --git a/tests/ui/once_expires_ttl_exclusive.stderr b/tests/ui/once_expires_ttl_exclusive.stderr index af915f0a..8d59bdb9 100644 --- a/tests/ui/once_expires_ttl_exclusive.stderr +++ b/tests/ui/once_expires_ttl_exclusive.stderr @@ -1,4 +1,4 @@ -error: `expires` and `ttl` are mutually exclusive — `expires` delegates expiry to the value via the `Expires` trait; `ttl` applies a uniform time-based TTL +error: `expires` and `ttl` are mutually exclusive - `expires` delegates expiry to the value via the `Expires` trait; `ttl` applies a uniform time-based TTL --> tests/ui/once_expires_ttl_exclusive.rs:4:4 | 4 | fn my_fn() -> String { diff --git a/tests/ui/once_expires_with_cached_flag_exclusive.stderr b/tests/ui/once_expires_with_cached_flag_exclusive.stderr index 16adb47e..d10e32ea 100644 --- a/tests/ui/once_expires_with_cached_flag_exclusive.stderr +++ b/tests/ui/once_expires_with_cached_flag_exclusive.stderr @@ -1,4 +1,4 @@ -error: `expires` and `with_cached_flag` are mutually exclusive — the `Return` wrapper does not implement `Expires` +error: `expires` and `with_cached_flag` are mutually exclusive - the `Return` wrapper does not implement `Expires` --> tests/ui/once_expires_with_cached_flag_exclusive.rs:6:4 | 6 | fn my_fn() -> Return { diff --git a/tests/ui/once_force_refresh_unparseable.rs b/tests/ui/once_force_refresh_unparseable.rs new file mode 100644 index 00000000..b4255da1 --- /dev/null +++ b/tests/ui/once_force_refresh_unparseable.rs @@ -0,0 +1,8 @@ +use cached::macros::once; + +#[once(force_refresh = "{ this is not ; an expr }")] +fn f(x: i32) -> i32 { + x +} + +fn main() {} diff --git a/tests/ui/once_force_refresh_unparseable.stderr b/tests/ui/once_force_refresh_unparseable.stderr new file mode 100644 index 00000000..78acde15 --- /dev/null +++ b/tests/ui/once_force_refresh_unparseable.stderr @@ -0,0 +1,5 @@ +error: Unknown literal value `{ this is not ; an expr }` + --> tests/ui/once_force_refresh_unparseable.rs:3:24 + | +3 | #[once(force_refresh = "{ this is not ; an expr }")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/once_in_impl_requires_self.rs b/tests/ui/once_in_impl_requires_self.rs new file mode 100644 index 00000000..b2da7ea8 --- /dev/null +++ b/tests/ui/once_in_impl_requires_self.rs @@ -0,0 +1,15 @@ +use cached::macros::once; + +struct S; + +// `in_impl = true` on an associated function with NO `self` receiver is rejected: +// the generated `{fn}_no_cache(args)` call inside the impl cannot resolve without +// a `Self::` qualifier, so the macro requires a `self` receiver under `in_impl`. +impl S { + #[once(in_impl = true)] + fn f(x: usize) -> usize { + x + } +} + +fn main() {} diff --git a/tests/ui/once_in_impl_requires_self.stderr b/tests/ui/once_in_impl_requires_self.stderr new file mode 100644 index 00000000..f474977d --- /dev/null +++ b/tests/ui/once_in_impl_requires_self.stderr @@ -0,0 +1,5 @@ +error: in_impl = true requires a method with a `self` receiver; for a free function or an associated function without `self`, remove in_impl. + --> tests/ui/once_in_impl_requires_self.rs:10:8 + | +10 | fn f(x: usize) -> usize { + | ^ diff --git a/tests/ui/once_key_rejected.rs b/tests/ui/once_key_rejected.rs new file mode 100644 index 00000000..a3afe439 --- /dev/null +++ b/tests/ui/once_key_rejected.rs @@ -0,0 +1,8 @@ +use cached::macros::once; + +#[once(key = "i32", convert = "{ a }")] +fn f(a: i32) -> i32 { + a +} + +fn main() {} diff --git a/tests/ui/once_key_rejected.stderr b/tests/ui/once_key_rejected.stderr new file mode 100644 index 00000000..793ea77c --- /dev/null +++ b/tests/ui/once_key_rejected.stderr @@ -0,0 +1,5 @@ +error: `key` is not supported on `#[once]`; `#[once]` stores a single value for all arguments and has no per-call cache key + --> tests/ui/once_key_rejected.rs:4:4 + | +4 | fn f(a: i32) -> i32 { + | ^ diff --git a/tests/ui/once_max_size_rejected.rs b/tests/ui/once_max_size_rejected.rs new file mode 100644 index 00000000..148bdb78 --- /dev/null +++ b/tests/ui/once_max_size_rejected.rs @@ -0,0 +1,8 @@ +use cached::macros::once; + +#[once(max_size = 10)] +fn f() -> i32 { + 42 +} + +fn main() {} diff --git a/tests/ui/once_max_size_rejected.stderr b/tests/ui/once_max_size_rejected.stderr new file mode 100644 index 00000000..9df81274 --- /dev/null +++ b/tests/ui/once_max_size_rejected.stderr @@ -0,0 +1,5 @@ +error: `max_size` is not supported on `#[once]`; `#[once]` stores a single value, so there is no entry count to bound + --> tests/ui/once_max_size_rejected.rs:4:4 + | +4 | fn f() -> i32 { + | ^ diff --git a/tests/ui/once_name_invalid_ident.rs b/tests/ui/once_name_invalid_ident.rs new file mode 100644 index 00000000..594a8ffe --- /dev/null +++ b/tests/ui/once_name_invalid_ident.rs @@ -0,0 +1,8 @@ +use cached::macros::once; + +#[once(name = "bad-name")] +fn f() -> i32 { + 42 +} + +fn main() {} diff --git a/tests/ui/once_name_invalid_ident.stderr b/tests/ui/once_name_invalid_ident.stderr new file mode 100644 index 00000000..f73ec41e --- /dev/null +++ b/tests/ui/once_name_invalid_ident.stderr @@ -0,0 +1,5 @@ +error: `name` must be a valid Rust identifier + --> tests/ui/once_name_invalid_ident.rs:4:4 + | +4 | fn f() -> i32 { + | ^ diff --git a/tests/ui/once_redis_concurrent_only.rs b/tests/ui/once_redis_concurrent_only.rs new file mode 100644 index 00000000..d35f3cb9 --- /dev/null +++ b/tests/ui/once_redis_concurrent_only.rs @@ -0,0 +1,8 @@ +use cached::macros::once; + +#[once(redis = true)] +fn load() -> u64 { + 42 +} + +fn main() {} diff --git a/tests/ui/once_redis_concurrent_only.stderr b/tests/ui/once_redis_concurrent_only.stderr new file mode 100644 index 00000000..8a4521c7 --- /dev/null +++ b/tests/ui/once_redis_concurrent_only.stderr @@ -0,0 +1,5 @@ +error: `redis` is not supported on `#[once]`; `redis` selects the Redis-backed concurrent store. Use `#[concurrent_cached(redis = true)]` instead. + --> tests/ui/once_redis_concurrent_only.rs:3:8 + | +3 | #[once(redis = true)] + | ^^^^^ diff --git a/tests/ui/once_refresh_rejected.rs b/tests/ui/once_refresh_rejected.rs new file mode 100644 index 00000000..58574ac2 --- /dev/null +++ b/tests/ui/once_refresh_rejected.rs @@ -0,0 +1,8 @@ +use cached::macros::once; + +#[once(refresh = true)] +fn f() -> i32 { + 42 +} + +fn main() {} diff --git a/tests/ui/once_refresh_rejected.stderr b/tests/ui/once_refresh_rejected.stderr new file mode 100644 index 00000000..fba31c7a --- /dev/null +++ b/tests/ui/once_refresh_rejected.stderr @@ -0,0 +1,5 @@ +error: `refresh` is not supported on `#[once]`; `refresh` renews a per-entry TTL on cache hit, but `#[once]` stores a single value and does not refresh on read - set `ttl`/`ttl_secs`/`ttl_millis` for time-based expiry + --> tests/ui/once_refresh_rejected.rs:4:4 + | +4 | fn f() -> i32 { + | ^ diff --git a/tests/ui/once_result_fallback_rejected.rs b/tests/ui/once_result_fallback_rejected.rs new file mode 100644 index 00000000..3547c96f --- /dev/null +++ b/tests/ui/once_result_fallback_rejected.rs @@ -0,0 +1,8 @@ +use cached::macros::once; + +#[once(result_fallback = true)] +fn f() -> Result { + Ok(42) +} + +fn main() {} diff --git a/tests/ui/once_result_fallback_rejected.stderr b/tests/ui/once_result_fallback_rejected.stderr new file mode 100644 index 00000000..20f511b7 --- /dev/null +++ b/tests/ui/once_result_fallback_rejected.stderr @@ -0,0 +1,5 @@ +error: `result_fallback` is not supported on `#[once]`; it returns the last cached `Ok` value from a keyed cache, but `#[once]` stores a single value and already returns the one cached `Ok` on subsequent calls + --> tests/ui/once_result_fallback_rejected.rs:4:4 + | +4 | fn f() -> Result { + | ^ diff --git a/tests/ui/once_self_method.stderr b/tests/ui/once_self_method.stderr index 9434233c..a03a01fb 100644 --- a/tests/ui/once_self_method.stderr +++ b/tests/ui/once_self_method.stderr @@ -1,4 +1,4 @@ -error: #[once] cannot be applied to methods that take `self` +error: #[once] cannot be applied to methods that take `self`. Use `in_impl = true` to cache a method inside an `impl` block. Note: `#[once]` stores a single value shared across all instances. --> tests/ui/once_self_method.rs:4:4 | 4 | fn my_fn(&self, k: i32) -> i32 { diff --git a/tests/ui/once_sync_lock_unsupported.rs b/tests/ui/once_sync_lock_unsupported.rs new file mode 100644 index 00000000..e04718d7 --- /dev/null +++ b/tests/ui/once_sync_lock_unsupported.rs @@ -0,0 +1,8 @@ +use cached::macros::once; + +#[once(sync_lock = "mutex")] +fn f() -> i32 { + 42 +} + +fn main() {} diff --git a/tests/ui/once_sync_lock_unsupported.stderr b/tests/ui/once_sync_lock_unsupported.stderr new file mode 100644 index 00000000..c8c10fcd --- /dev/null +++ b/tests/ui/once_sync_lock_unsupported.stderr @@ -0,0 +1,5 @@ +error: `sync_lock` is not supported on `#[once]` + --> tests/ui/once_sync_lock_unsupported.rs:4:4 + | +4 | fn f() -> i32 { + | ^ diff --git a/tests/ui/once_time_attr_renamed.stderr b/tests/ui/once_time_attr_renamed.stderr index 68b800c2..6f132326 100644 --- a/tests/ui/once_time_attr_renamed.stderr +++ b/tests/ui/once_time_attr_renamed.stderr @@ -1,4 +1,4 @@ -error: `time` was renamed to `ttl` in cached 1.0; use `ttl = ...` +error: `time` (whole seconds) was renamed in cached 1.0; use `ttl_secs = ...` (or `ttl = "Duration::from_secs(...)"` / `ttl_millis = ...`) --> tests/ui/once_time_attr_renamed.rs:4:4 | 4 | fn my_fn(k: i32) -> i32 { diff --git a/tests/ui/once_ttl_and_ttl_millis_exclusive.rs b/tests/ui/once_ttl_and_ttl_millis_exclusive.rs new file mode 100644 index 00000000..a0be7bc0 --- /dev/null +++ b/tests/ui/once_ttl_and_ttl_millis_exclusive.rs @@ -0,0 +1,8 @@ +use cached::macros::once; + +#[once(ttl = "core::time::Duration::from_secs(1)", ttl_millis = 500)] +fn f(x: i32) -> i32 { + x +} + +fn main() {} diff --git a/tests/ui/once_ttl_and_ttl_millis_exclusive.stderr b/tests/ui/once_ttl_and_ttl_millis_exclusive.stderr new file mode 100644 index 00000000..41df1130 --- /dev/null +++ b/tests/ui/once_ttl_and_ttl_millis_exclusive.stderr @@ -0,0 +1,5 @@ +error: `ttl`, `ttl_secs`, and `ttl_millis` are mutually exclusive - `ttl` takes a `Duration` expression, `ttl_secs` whole seconds, `ttl_millis` milliseconds; use exactly one + --> tests/ui/once_ttl_and_ttl_millis_exclusive.rs:4:4 + | +4 | fn f(x: i32) -> i32 { + | ^ diff --git a/tests/ui/once_ttl_integer_migration.rs b/tests/ui/once_ttl_integer_migration.rs new file mode 100644 index 00000000..4aaa8f1f --- /dev/null +++ b/tests/ui/once_ttl_integer_migration.rs @@ -0,0 +1,8 @@ +use cached::macros::once; + +#[once(ttl = 60)] +fn f(x: i32) -> i32 { + x +} + +fn main() {} diff --git a/tests/ui/once_ttl_integer_migration.stderr b/tests/ui/once_ttl_integer_migration.stderr new file mode 100644 index 00000000..c4e1c0db --- /dev/null +++ b/tests/ui/once_ttl_integer_migration.stderr @@ -0,0 +1,5 @@ +error: `ttl` now takes a Duration expression (e.g. `ttl = "Duration::from_secs(60)"`); for whole seconds use `ttl_secs = 60`, for milliseconds use `ttl_millis = 500`. + --> tests/ui/once_ttl_integer_migration.rs:3:14 + | +3 | #[once(ttl = 60)] + | ^^ diff --git a/tests/ui/once_ttl_millis_zero.rs b/tests/ui/once_ttl_millis_zero.rs new file mode 100644 index 00000000..8e07c59f --- /dev/null +++ b/tests/ui/once_ttl_millis_zero.rs @@ -0,0 +1,8 @@ +use cached::macros::once; + +#[once(ttl_millis = 0)] +fn f(x: i32) -> i32 { + x +} + +fn main() {} diff --git a/tests/ui/once_ttl_millis_zero.stderr b/tests/ui/once_ttl_millis_zero.stderr new file mode 100644 index 00000000..f46a4d64 --- /dev/null +++ b/tests/ui/once_ttl_millis_zero.stderr @@ -0,0 +1,5 @@ +error: `ttl_millis` must be >= 1 + --> tests/ui/once_ttl_millis_zero.rs:4:4 + | +4 | fn f(x: i32) -> i32 { + | ^ diff --git a/tests/ui/once_ttl_secs_and_ttl_millis_exclusive.rs b/tests/ui/once_ttl_secs_and_ttl_millis_exclusive.rs new file mode 100644 index 00000000..486b5f66 --- /dev/null +++ b/tests/ui/once_ttl_secs_and_ttl_millis_exclusive.rs @@ -0,0 +1,8 @@ +use cached::macros::once; + +#[once(ttl_secs = 1, ttl_millis = 500)] +fn f(x: i32) -> i32 { + x +} + +fn main() {} diff --git a/tests/ui/once_ttl_secs_and_ttl_millis_exclusive.stderr b/tests/ui/once_ttl_secs_and_ttl_millis_exclusive.stderr new file mode 100644 index 00000000..aec9f195 --- /dev/null +++ b/tests/ui/once_ttl_secs_and_ttl_millis_exclusive.stderr @@ -0,0 +1,5 @@ +error: `ttl`, `ttl_secs`, and `ttl_millis` are mutually exclusive - `ttl` takes a `Duration` expression, `ttl_secs` whole seconds, `ttl_millis` milliseconds; use exactly one + --> tests/ui/once_ttl_secs_and_ttl_millis_exclusive.rs:4:4 + | +4 | fn f(x: i32) -> i32 { + | ^ diff --git a/tests/ui/once_ttl_ttl_secs_exclusive.rs b/tests/ui/once_ttl_ttl_secs_exclusive.rs new file mode 100644 index 00000000..bcfc34d2 --- /dev/null +++ b/tests/ui/once_ttl_ttl_secs_exclusive.rs @@ -0,0 +1,8 @@ +use cached::macros::once; + +#[once(ttl = "core::time::Duration::from_secs(1)", ttl_secs = 1)] +fn f(x: i32) -> i32 { + x +} + +fn main() {} diff --git a/tests/ui/once_ttl_ttl_secs_exclusive.stderr b/tests/ui/once_ttl_ttl_secs_exclusive.stderr new file mode 100644 index 00000000..c201fd27 --- /dev/null +++ b/tests/ui/once_ttl_ttl_secs_exclusive.stderr @@ -0,0 +1,5 @@ +error: `ttl`, `ttl_secs`, and `ttl_millis` are mutually exclusive - `ttl` takes a `Duration` expression, `ttl_secs` whole seconds, `ttl_millis` milliseconds; use exactly one + --> tests/ui/once_ttl_ttl_secs_exclusive.rs:4:4 + | +4 | fn f(x: i32) -> i32 { + | ^ diff --git a/tests/ui/once_ttl_unparseable_duration.rs b/tests/ui/once_ttl_unparseable_duration.rs new file mode 100644 index 00000000..66338a32 --- /dev/null +++ b/tests/ui/once_ttl_unparseable_duration.rs @@ -0,0 +1,8 @@ +use cached::macros::once; + +#[once(ttl = "core::time::Duration::from_secs(")] +fn f() -> u32 { + 0 +} + +fn main() {} diff --git a/tests/ui/once_ttl_unparseable_duration.stderr b/tests/ui/once_ttl_unparseable_duration.stderr new file mode 100644 index 00000000..6cd970ce --- /dev/null +++ b/tests/ui/once_ttl_unparseable_duration.stderr @@ -0,0 +1,5 @@ +error: unable to parse `ttl` as a Duration expression: cannot parse string into token stream; `ttl` takes a `Duration` expression as a string literal, e.g. `ttl = "core::time::Duration::from_secs(60)"` + --> tests/ui/once_ttl_unparseable_duration.rs:3:14 + | +3 | #[once(ttl = "core::time::Duration::from_secs(")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/once_ttl_zero.rs b/tests/ui/once_ttl_zero.rs index dd2809f8..c19a2118 100644 --- a/tests/ui/once_ttl_zero.rs +++ b/tests/ui/once_ttl_zero.rs @@ -1,6 +1,6 @@ use cached::once; -#[once(ttl = 0)] +#[once(ttl_secs = 0)] fn my_fn() -> i32 { 42 } diff --git a/tests/ui/once_ttl_zero.stderr b/tests/ui/once_ttl_zero.stderr index 84ca729c..56cb253c 100644 --- a/tests/ui/once_ttl_zero.stderr +++ b/tests/ui/once_ttl_zero.stderr @@ -1,4 +1,4 @@ -error: `ttl` must be >= 1 +error: `ttl_secs` must be >= 1 --> tests/ui/once_ttl_zero.rs:4:4 | 4 | fn my_fn() -> i32 { diff --git a/tests/ui/once_ty_rejected.rs b/tests/ui/once_ty_rejected.rs new file mode 100644 index 00000000..9b3a10d2 --- /dev/null +++ b/tests/ui/once_ty_rejected.rs @@ -0,0 +1,8 @@ +use cached::macros::once; + +#[once(ty = "cached::UnboundCache<(), i32>")] +fn f() -> i32 { + 42 +} + +fn main() {} diff --git a/tests/ui/once_ty_rejected.stderr b/tests/ui/once_ty_rejected.stderr new file mode 100644 index 00000000..6b821bf0 --- /dev/null +++ b/tests/ui/once_ty_rejected.stderr @@ -0,0 +1,5 @@ +error: `ty` is not supported on `#[once]`; `#[once]` manages its own single-value storage and does not take a custom store type + --> tests/ui/once_ty_rejected.rs:4:4 + | +4 | fn f() -> i32 { + | ^ diff --git a/tests/ui/once_unsync_reads_unsupported.rs b/tests/ui/once_unsync_reads_unsupported.rs new file mode 100644 index 00000000..308a6002 --- /dev/null +++ b/tests/ui/once_unsync_reads_unsupported.rs @@ -0,0 +1,8 @@ +use cached::macros::once; + +#[once(unsync_reads = true)] +fn f() -> i32 { + 42 +} + +fn main() {} diff --git a/tests/ui/once_unsync_reads_unsupported.stderr b/tests/ui/once_unsync_reads_unsupported.stderr new file mode 100644 index 00000000..68db1cd9 --- /dev/null +++ b/tests/ui/once_unsync_reads_unsupported.stderr @@ -0,0 +1,5 @@ +error: `unsync_reads` is not supported on `#[once]` + --> tests/ui/once_unsync_reads_unsupported.rs:4:4 + | +4 | fn f() -> i32 { + | ^ diff --git a/tests/ui/result_fallback_unbound_cache.stderr b/tests/ui/result_fallback_unbound_cache.stderr index 5d53976a..a396a3fc 100644 --- a/tests/ui/result_fallback_unbound_cache.stderr +++ b/tests/ui/result_fallback_unbound_cache.stderr @@ -1,4 +1,4 @@ -error: `result_fallback` requires a store that implements `CloneCached`. The default `UnboundCache` and `LruCache` (size without ttl) do not implement it. Use `ttl` (for `TtlCache`), `max_size` + `ttl` (for `LruTtlCache`), `expires` (for `ExpiringCache`/`ExpiringLruCache`), or a custom `ty`. +error: `result_fallback` requires a store that implements `CloneCached`. The default `UnboundCache` and `LruCache` (size without ttl) do not implement it. Use `ttl`/`ttl_secs`/`ttl_millis` (for `TtlCache`), `max_size` + a TTL (for `LruTtlCache`), `expires` (for `ExpiringCache`/`ExpiringLruCache`), or a custom `ty`. --> tests/ui/result_fallback_unbound_cache.rs:4:4 | 4 | fn my_fn(k: i32) -> Result { diff --git a/tests/ui/result_fallback_without_result.rs b/tests/ui/result_fallback_without_result.rs index a34b9920..ce0f2106 100644 --- a/tests/ui/result_fallback_without_result.rs +++ b/tests/ui/result_fallback_without_result.rs @@ -1,6 +1,6 @@ use cached::macros::cached; -#[cached(ttl = 1, result_fallback = true)] +#[cached(ttl_secs = 1, result_fallback = true)] fn my_fn(k: i32) -> i32 { k } diff --git a/tests/ui/sharded_base_custom_hasher_constructor.rs b/tests/ui/sharded_base_custom_hasher_constructor.rs new file mode 100644 index 00000000..0dcb4fd8 --- /dev/null +++ b/tests/ui/sharded_base_custom_hasher_constructor.rs @@ -0,0 +1,18 @@ +// `new` and `builder` exist only on the default-hasher specialization of each sharded +// `*Base` type, so a `Base::<_, _, CustomHasher>::{new,builder}()` turbofish (which would +// silently drop the custom hasher) does not compile. A custom hasher is introduced via +// `ShardedUnboundCache::builder().hasher(h)` instead, which switches the builder's hasher type. +use cached::{ShardHasher, ShardedUnboundCacheBase}; + +#[derive(Clone, Default)] +struct ConstHasher; +impl ShardHasher for ConstHasher { + fn shard_hash(&self, _key: &u32) -> u64 { + 0 + } +} + +fn main() { + let _ = ShardedUnboundCacheBase::::builder(); + let _ = ShardedUnboundCacheBase::::new(); +} diff --git a/tests/ui/sharded_base_custom_hasher_constructor.stderr b/tests/ui/sharded_base_custom_hasher_constructor.stderr new file mode 100644 index 00000000..9cefa7eb --- /dev/null +++ b/tests/ui/sharded_base_custom_hasher_constructor.stderr @@ -0,0 +1,15 @@ +error[E0599]: no associated function or constant named `builder` found for struct `ShardedUnboundCacheBase` in the current scope + --> tests/ui/sharded_base_custom_hasher_constructor.rs:16:63 + | +16 | let _ = ShardedUnboundCacheBase::::builder(); + | ^^^^^^^ associated function or constant not found in `ShardedUnboundCacheBase` + | + = note: the associated function or constant was found for `ShardedUnboundCacheBase` + +error[E0599]: no associated function or constant named `new` found for struct `ShardedUnboundCacheBase` in the current scope + --> tests/ui/sharded_base_custom_hasher_constructor.rs:17:63 + | +17 | let _ = ShardedUnboundCacheBase::::new(); + | ^^^ associated function or constant not found in `ShardedUnboundCacheBase` + | + = note: the associated function or constant was found for `ShardedUnboundCacheBase` diff --git a/tests/ui/sharded_non_clone_shard_hasher.rs b/tests/ui/sharded_non_clone_shard_hasher.rs new file mode 100644 index 00000000..b12a4f68 --- /dev/null +++ b/tests/ui/sharded_non_clone_shard_hasher.rs @@ -0,0 +1,16 @@ +// `ShardHasher` has `Clone` as a supertrait (item 11). A custom hasher type +// that does NOT implement `Clone` must be rejected: the `impl ShardHasher` is +// only valid for `Clone` types. This locks the supertrait contract so the +// sharded stores can rely on cloning the hasher across shards/threads. +use cached::ShardHasher; + +// Intentionally NOT `#[derive(Clone)]`. +struct NonCloneHasher; + +impl ShardHasher for NonCloneHasher { + fn shard_hash(&self, key: &u64) -> u64 { + *key + } +} + +fn main() {} diff --git a/tests/ui/sharded_non_clone_shard_hasher.stderr b/tests/ui/sharded_non_clone_shard_hasher.stderr new file mode 100644 index 00000000..4b6300e4 --- /dev/null +++ b/tests/ui/sharded_non_clone_shard_hasher.stderr @@ -0,0 +1,16 @@ +error[E0277]: the trait bound `NonCloneHasher: Clone` is not satisfied + --> tests/ui/sharded_non_clone_shard_hasher.rs:10:27 + | +10 | impl ShardHasher for NonCloneHasher { + | ^^^^^^^^^^^^^^ the trait `Clone` is not implemented for `NonCloneHasher` + | +note: required by a bound in `ShardHasher` + --> src/stores/sharded/mod.rs + | + | pub trait ShardHasher: Clone + Send + Sync + 'static { + | ^^^^^ required by this bound in `ShardHasher` +help: consider annotating `NonCloneHasher` with `#[derive(Clone)]` + | + 8 + #[derive(Clone)] + 9 | struct NonCloneHasher; + | diff --git a/tests/ui/sharded_unbound_no_set_ttl.rs b/tests/ui/sharded_unbound_no_set_ttl.rs new file mode 100644 index 00000000..caece602 --- /dev/null +++ b/tests/ui/sharded_unbound_no_set_ttl.rs @@ -0,0 +1,17 @@ +// Negative surface for the required-trait design (the point of the trait split): +// non-TTL concurrent stores intentionally do NOT implement `ConcurrentCacheTtl`, so +// the global-TTL knobs (`set_ttl`/`ttl`/`unset_ttl`) do not exist on them at all. +// `ShardedUnboundCache` has no global TTL, so `set_ttl` must NOT resolve even with the +// prelude glob (which brings `ConcurrentCacheTtl` into scope). If a future change +// implemented `ConcurrentCacheTtl` for a non-TTL store, this would start compiling and +// the golden would break, flagging the regression. +use cached::prelude::*; +use cached::ShardedUnboundCache; +use std::time::Duration; + +fn main() { + let cache: ShardedUnboundCache = + ShardedUnboundCache::builder().build().expect("build"); + // `set_ttl` is on `ConcurrentCacheTtl`, which `ShardedUnboundCache` does not implement. + let _ = cache.set_ttl(Duration::from_secs(60)); +} diff --git a/tests/ui/sharded_unbound_no_set_ttl.stderr b/tests/ui/sharded_unbound_no_set_ttl.stderr new file mode 100644 index 00000000..b1264349 --- /dev/null +++ b/tests/ui/sharded_unbound_no_set_ttl.stderr @@ -0,0 +1,5 @@ +error[E0599]: no method named `set_ttl` found for struct `ShardedUnboundCacheBase` in the current scope + --> tests/ui/sharded_unbound_no_set_ttl.rs:16:19 + | +16 | let _ = cache.set_ttl(Duration::from_secs(60)); + | ^^^^^^^ method not found in `ShardedUnboundCacheBase` diff --git a/tests/ui/time_attr_renamed.stderr b/tests/ui/time_attr_renamed.stderr index 9ee74061..437a686e 100644 --- a/tests/ui/time_attr_renamed.stderr +++ b/tests/ui/time_attr_renamed.stderr @@ -1,4 +1,4 @@ -error: `time` was renamed to `ttl` in cached 1.0; use `ttl = ...` +error: `time` (whole seconds) was renamed in cached 1.0; use `ttl_secs = ...` (or `ttl = "Duration::from_secs(...)"` / `ttl_millis = ...`) --> tests/ui/time_attr_renamed.rs:4:4 | 4 | fn my_fn(k: i32) -> i32 { diff --git a/tests/ui/time_refresh_attr_renamed.rs b/tests/ui/time_refresh_attr_renamed.rs index c2e29ee6..6f17a3e0 100644 --- a/tests/ui/time_refresh_attr_renamed.rs +++ b/tests/ui/time_refresh_attr_renamed.rs @@ -1,6 +1,6 @@ use cached::macros::cached; -#[cached(ttl = 1, time_refresh = true)] +#[cached(ttl_secs = 1, time_refresh = true)] fn my_fn(k: i32) -> i32 { k } diff --git a/tests/ui/unsync_reads_timed_cache.rs b/tests/ui/unsync_reads_timed_cache.rs index cb5f53c4..1736473b 100644 --- a/tests/ui/unsync_reads_timed_cache.rs +++ b/tests/ui/unsync_reads_timed_cache.rs @@ -1,6 +1,6 @@ use cached::macros::cached; -#[cached(ttl = 10, unsync_reads = true)] +#[cached(ttl_secs = 10, unsync_reads = true)] fn my_fn(k: i32) -> i32 { k } diff --git a/tests/v3_cache_peek_with_expiry_status.rs b/tests/v3_cache_peek_with_expiry_status.rs new file mode 100644 index 00000000..e76433bf --- /dev/null +++ b/tests/v3_cache_peek_with_expiry_status.rs @@ -0,0 +1,440 @@ +/*! +Store-level certification for `CloneCached::cache_peek_with_expiry_status`. + +This is the side-effect-free, expired-surfacing peek added to fix the bug where +`#[cached(result_fallback = true, force_refresh = "...")]` over a TTL store dropped +the stale `Ok` fallback when a bypassed recompute returned `Err` over an EXPIRED +entry (the bypass branch used `cache_peek`, which returns `None` for expired entries). + +The macro end-to-end coverage lives in `v3_force_refresh.rs`. These tests pin the +trait method directly on every single-owner override, because the method has +contractual guarantees the macro tests cannot fully observe: + + 1. Surfaces a present-but-expired entry as `(Some(v), true)` (the regression). + 2. Returns the live value as `(Some(v), false)` and absent keys as `(None, false)`. + 3. Produces NO read side effects: no hit/miss counter change, no LRU recency + promotion, no TTL renewal. + 4. A plain (non-TTL) `CloneCached` implementor must provide a required + implementation of `cache_peek_with_expiry_status`; for stores with no + expiry the method simply returns `(Some(v), false)` for present keys and + `(None, false)` for absent keys. + +`TtlSortedCache` is covered here too: it implements `CloneCached` and overrides the +method, but is NOT reachable through any `#[cached]` attribute combination, so a +direct store test is the only way to certify its override. +*/ + +#![cfg(all(feature = "proc_macro", feature = "time_stores"))] + +use std::thread::sleep; +use std::time::Duration; + +use cached::stores::{ + Expires, ExpiringCache, ExpiringLruCache, LruTtlCache, TtlCache, TtlSortedCache, +}; +use cached::{Cached, CloneCached}; + +// A short-but-nonzero TTL the entry will outlive within a test, then a sleep past +// it to force expiry. Kept small to keep the suite fast while staying deterministic. +const SHORT_TTL: Duration = Duration::from_millis(80); +const PAST_TTL: Duration = Duration::from_millis(160); + +// ── Value type for the per-value `Expires` stores ───────────────────────────── +// `is_expired` is driven by a flag baked into the value, so the expiring-store +// tests need no sleep and are fully deterministic. +#[derive(Clone, Debug, PartialEq)] +struct Token { + payload: i32, + expired: bool, +} +impl Token { + fn live(payload: i32) -> Self { + Token { + payload, + expired: false, + } + } + fn stale(payload: i32) -> Self { + Token { + payload, + expired: true, + } + } +} +impl Expires for Token { + fn is_expired(&self) -> bool { + self.expired + } +} + +// ───────────────────────────────── TtlCache ────────────────────────────────── + +#[test] +fn ttl_peek_absent_is_none_false() { + let c: TtlCache = TtlCache::builder().ttl(SHORT_TTL).build().unwrap(); + assert_eq!(c.cache_peek_with_expiry_status(&404), (None, false)); +} + +#[test] +fn ttl_peek_live_is_some_false() { + let mut c: TtlCache = TtlCache::builder().ttl(SHORT_TTL).build().unwrap(); + c.cache_set(1, 11); + assert_eq!(c.cache_peek_with_expiry_status(&1), (Some(11), false)); +} + +#[test] +fn ttl_peek_expired_surfaces_some_true() { + // The regression: an expired entry must still be returned (with `true`), + // not dropped as `None`. + let mut c: TtlCache = TtlCache::builder().ttl(SHORT_TTL).build().unwrap(); + c.cache_set(1, 11); + sleep(PAST_TTL); + assert_eq!(c.cache_peek_with_expiry_status(&1), (Some(11), true)); +} + +#[test] +fn ttl_peek_has_no_side_effects() { + let mut c: TtlCache = TtlCache::builder().ttl(SHORT_TTL).build().unwrap(); + c.cache_set(1, 11); + let hits0 = c.cache_hits(); + let misses0 = c.cache_misses(); + + // Many peeks on present (live), absent, and expired keys. + let _ = c.cache_peek_with_expiry_status(&1); + let _ = c.cache_peek_with_expiry_status(&999); + sleep(PAST_TTL); + let _ = c.cache_peek_with_expiry_status(&1); + + assert_eq!(c.cache_hits(), hits0, "peek must not touch hit counter"); + assert_eq!( + c.cache_misses(), + misses0, + "peek must not touch miss counter" + ); +} + +#[test] +fn ttl_peek_does_not_renew_ttl_on_refresh_store() { + // `refresh_on_hit(true)` makes a *real* `cache_get` reset the entry instant. + // The non-renewing peek must NOT, so the entry stays expired after a peek. + let mut c: TtlCache = TtlCache::builder() + .ttl(SHORT_TTL) + .refresh_on_hit(true) + .build() + .unwrap(); + c.cache_set(1, 11); + sleep(PAST_TTL); + + // Peek sees it expired and must not renew. + assert_eq!(c.cache_peek_with_expiry_status(&1), (Some(11), true)); + // A second peek must still report expired: if peek had treated the entry as + // live and triggered refresh_on_hit, the entry would now be unexpired and + // the assertion below would fail. + assert!( + c.cache_peek_with_expiry_status(&1).1, + "peek must not renew TTL" + ); +} + +// ──────────────────────────────── LruTtlCache ──────────────────────────────── + +#[test] +fn lru_ttl_peek_absent_live_expired() { + let mut c: LruTtlCache = LruTtlCache::builder() + .max_size(8) + .ttl(SHORT_TTL) + .build() + .unwrap(); + assert_eq!(c.cache_peek_with_expiry_status(&404), (None, false)); + c.cache_set(1, 11); + assert_eq!(c.cache_peek_with_expiry_status(&1), (Some(11), false)); + sleep(PAST_TTL); + assert_eq!(c.cache_peek_with_expiry_status(&1), (Some(11), true)); +} + +#[test] +fn lru_ttl_peek_does_not_promote_recency() { + // `key_order()` exposes LRU recency. A non-renewing peek of the LRU key must + // NOT move it to the front; a real `cache_get` would. + let mut c: LruTtlCache = LruTtlCache::builder() + .max_size(8) + .ttl(SHORT_TTL) + .build() + .unwrap(); + c.cache_set(1, 11); + c.cache_set(2, 22); + c.cache_set(3, 33); + let order_before = c.key_order(); + + // Peek the least-recently-used key (1). Must not reorder. + let _ = c.cache_peek_with_expiry_status(&1); + assert_eq!( + c.key_order(), + order_before, + "peek must not promote LRU recency" + ); + + // Sanity: a real get DOES reorder, proving key_order is recency-sensitive. + let _ = c.cache_get(&1); + assert_ne!( + c.key_order(), + order_before, + "control: a real cache_get is expected to change recency order" + ); +} + +#[test] +fn lru_ttl_peek_has_no_counter_side_effects() { + let mut c: LruTtlCache = LruTtlCache::builder() + .max_size(8) + .ttl(SHORT_TTL) + .build() + .unwrap(); + c.cache_set(1, 11); + let hits0 = c.cache_hits(); + let misses0 = c.cache_misses(); + let _ = c.cache_peek_with_expiry_status(&1); + let _ = c.cache_peek_with_expiry_status(&999); + assert_eq!(c.cache_hits(), hits0); + assert_eq!(c.cache_misses(), misses0); +} + +// ─────────────────────────────── TtlSortedCache ────────────────────────────── +// Not reachable via `#[cached]`; this is its only override certification. + +#[test] +fn ttl_sorted_peek_absent_live_expired() { + let mut c: TtlSortedCache = TtlSortedCache::builder().ttl(SHORT_TTL).build().unwrap(); + assert_eq!(c.cache_peek_with_expiry_status(&404), (None, false)); + c.cache_set(1, 11); + assert_eq!(c.cache_peek_with_expiry_status(&1), (Some(11), false)); + sleep(PAST_TTL); + assert_eq!(c.cache_peek_with_expiry_status(&1), (Some(11), true)); +} + +#[test] +fn ttl_sorted_peek_has_no_counter_side_effects() { + let mut c: TtlSortedCache = TtlSortedCache::builder().ttl(SHORT_TTL).build().unwrap(); + c.cache_set(1, 11); + let hits0 = c.cache_hits(); + let misses0 = c.cache_misses(); + let _ = c.cache_peek_with_expiry_status(&1); + let _ = c.cache_peek_with_expiry_status(&999); + sleep(PAST_TTL); + let _ = c.cache_peek_with_expiry_status(&1); + assert_eq!(c.cache_hits(), hits0); + assert_eq!(c.cache_misses(), misses0); +} + +// ─────────────────────────────── ExpiringCache ─────────────────────────────── +// Per-value expiry: deterministic, no sleeps. + +#[test] +fn expiring_peek_absent_live_expired() { + let mut c: ExpiringCache = ExpiringCache::builder().build().unwrap(); + assert_eq!(c.cache_peek_with_expiry_status(&404), (None, false)); + c.cache_set(1, Token::live(11)); + assert_eq!( + c.cache_peek_with_expiry_status(&1), + (Some(Token::live(11)), false) + ); + // A value that reports itself expired must surface as `(Some, true)`. + c.cache_set(2, Token::stale(22)); + assert_eq!( + c.cache_peek_with_expiry_status(&2), + (Some(Token::stale(22)), true) + ); +} + +#[test] +fn expiring_peek_does_not_remove_expired_entry() { + // `cache_get` removes an expired entry on access; the peek must leave it. + let mut c: ExpiringCache = ExpiringCache::builder().build().unwrap(); + c.cache_set(2, Token::stale(22)); + let size_before = c.cache_size(); + let _ = c.cache_peek_with_expiry_status(&2); + assert_eq!( + c.cache_size(), + size_before, + "peek must not remove the expired entry" + ); + // Still peekable. + assert!(c.cache_peek_with_expiry_status(&2).1); +} + +#[test] +fn expiring_peek_has_no_counter_side_effects() { + let mut c: ExpiringCache = ExpiringCache::builder().build().unwrap(); + c.cache_set(1, Token::live(11)); + c.cache_set(2, Token::stale(22)); + let hits0 = c.cache_hits(); + let misses0 = c.cache_misses(); + let _ = c.cache_peek_with_expiry_status(&1); + let _ = c.cache_peek_with_expiry_status(&2); + let _ = c.cache_peek_with_expiry_status(&999); + assert_eq!(c.cache_hits(), hits0); + assert_eq!(c.cache_misses(), misses0); +} + +// ────────────────────────────── ExpiringLruCache ───────────────────────────── + +#[test] +fn expiring_lru_peek_absent_live_expired() { + let mut c: ExpiringLruCache = + ExpiringLruCache::builder().max_size(8).build().unwrap(); + assert_eq!(c.cache_peek_with_expiry_status(&404), (None, false)); + c.cache_set(1, Token::live(11)); + assert_eq!( + c.cache_peek_with_expiry_status(&1), + (Some(Token::live(11)), false) + ); + c.cache_set(2, Token::stale(22)); + assert_eq!( + c.cache_peek_with_expiry_status(&2), + (Some(Token::stale(22)), true) + ); +} + +#[test] +fn expiring_lru_peek_does_not_promote_recency() { + // Recency is observable through eviction: with max_size = 2, the LRU key is + // evicted on overflow. A peek of the LRU key must NOT save it from eviction. + let mut c: ExpiringLruCache = + ExpiringLruCache::builder().max_size(2).build().unwrap(); + c.cache_set(1, Token::live(1)); // LRU + c.cache_set(2, Token::live(2)); // MRU + + // Peek the LRU key (1). If this promoted recency, key 2 would become LRU and + // be evicted instead of key 1 on the next insert. + let _ = c.cache_peek_with_expiry_status(&1); + + c.cache_set(3, Token::live(3)); // overflow -> evict the still-LRU key 1 + assert_eq!( + c.cache_get(&1), + None, + "key 1 must still be LRU and get evicted (peek must not promote it)" + ); + assert_eq!(c.cache_get(&2), Some(&Token::live(2))); + assert_eq!(c.cache_get(&3), Some(&Token::live(3))); +} + +#[test] +fn expiring_lru_peek_has_no_counter_side_effects() { + let mut c: ExpiringLruCache = + ExpiringLruCache::builder().max_size(8).build().unwrap(); + c.cache_set(1, Token::live(11)); + c.cache_set(2, Token::stale(22)); + let hits0 = c.cache_hits(); + let misses0 = c.cache_misses(); + let _ = c.cache_peek_with_expiry_status(&1); + let _ = c.cache_peek_with_expiry_status(&2); + let _ = c.cache_peek_with_expiry_status(&999); + assert_eq!(c.cache_hits(), hits0); + assert_eq!(c.cache_misses(), misses0); +} + +// ────────────────── required method on a plain non-TTL store (#3) ───────────── +// +// `cache_peek_with_expiry_status` is now a required method. A plain (non-TTL) +// external `CloneCached` implementor with no expiry should implement it to return +// `(Some(v), false)` for present keys and `(None, false)` for absent keys -- the +// same side-effect-free shape as the built-in stores, just with entries that are +// never expired. + +#[derive(Default)] +struct PlainPeekStore { + map: std::collections::HashMap, +} + +impl Cached for PlainPeekStore { + type Error = std::convert::Infallible; + + fn cache_get(&mut self, k: &Q) -> Option<&i32> + where + i32: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized, + { + self.map.get(k) + } + fn cache_get_mut(&mut self, k: &Q) -> Option<&mut i32> + where + i32: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized, + { + self.map.get_mut(k) + } + fn cache_set(&mut self, k: i32, v: i32) -> Option { + self.map.insert(k, v) + } + fn cache_get_or_set_with_mut i32>(&mut self, k: i32, f: F) -> &mut i32 { + self.map.entry(k).or_insert_with(f) + } + fn cache_try_get_or_set_with_mut Result, E>( + &mut self, + k: i32, + f: F, + ) -> Result<&mut i32, E> { + use std::collections::hash_map::Entry; + let v = match self.map.entry(k) { + Entry::Occupied(o) => o.into_mut(), + Entry::Vacant(v) => v.insert(f()?), + }; + Ok(v) + } + fn cache_remove(&mut self, k: &Q) -> Option + where + i32: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized, + { + self.map.remove(k) + } + fn cache_remove_entry(&mut self, k: &Q) -> Option<(i32, i32)> + where + i32: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized, + { + self.map.remove_entry(k) + } + fn cache_clear(&mut self) { + self.map.clear(); + } + fn cache_reset(&mut self) { + self.map.clear(); + } + fn cache_size(&self) -> usize { + self.map.len() + } +} + +impl CloneCached for PlainPeekStore { + fn cache_get_with_expiry_status(&mut self, k: &Q) -> (Option, bool) + where + i32: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized, + { + (self.map.get(k).copied(), false) + } + + // Required: side-effect-free read; plain store has no expiry so never returns true. + fn cache_peek_with_expiry_status(&self, k: &Q) -> (Option, bool) + where + i32: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized, + i32: Clone, + { + (self.map.get(k).copied(), false) + } +} + +#[test] +fn required_cache_peek_with_expiry_status_on_plain_store() { + let mut store = PlainPeekStore::default(); + store.cache_set(1, 11); + + // Present key: returns (Some(v), false) -- plain store, entries never expire. + assert_eq!(store.cache_peek_with_expiry_status(&1), (Some(11), false)); + // Absent key: returns (None, false). + assert_eq!(store.cache_peek_with_expiry_status(&999), (None, false)); + // The renewing read agrees on the same value. + assert_eq!(store.cache_get_with_expiry_status(&1), (Some(11), false)); +} diff --git a/tests/v3_concurrent_evictions_agg.rs b/tests/v3_concurrent_evictions_agg.rs new file mode 100644 index 00000000..b4b875c9 --- /dev/null +++ b/tests/v3_concurrent_evictions_agg.rs @@ -0,0 +1,160 @@ +/*! +Tests for aggregated metrics on sharded concurrent stores via the `ConcurrentCacheBase` +trait: `cache_hits`, `cache_misses`, `cache_capacity`, and `cache_evictions`. + +These tests do NOT require a Redis server. + +Covered: +- A sharded LRU store with small per-shard capacity is driven to eviction; the + aggregated `cache_evictions` via the TRAIT METHOD (not an inherent method) returns + `Some(n)` with n > 0. +- `cache_capacity` equals the sum of per-shard capacities (total logical capacity). +- `cache_hits` and `cache_misses` are consistent with the operations performed. +*/ + +use cached::{ConcurrentCacheBase, ConcurrentCached, ShardedLruCache}; + +/// Drive enough inserts to guarantee at least one eviction across the shards. +/// With 2 shards each holding 4 entries (total capacity 8), inserting 20 distinct +/// keys will overflow every shard and produce evictions. +#[test] +fn sharded_lru_cache_evictions_aggregated_via_trait() { + // Use `per_shard_max_size` to bypass the 16-per-shard minimum floor, which + // would otherwise force each shard to hold at least 16 entries and require + // many more inserts to trigger an eviction. + let store = ShardedLruCache::::builder() + .shards(2) + .per_shard_max_size(4) + .build() + .expect("build ShardedLruCache(2 shards, 4 per-shard)"); + + // Insert enough distinct keys to overflow both shards. + for i in 0u64..20 { + ConcurrentCached::cache_set(&store, i.to_string(), i).unwrap(); + } + + // Aggregated evictions via the TRAIT method must be Some(n) with n > 0. + let evictions = ConcurrentCacheBase::cache_evictions(&store); + assert!( + evictions.is_some(), + "cache_evictions must return Some(_) for a bounded sharded LRU" + ); + assert!( + evictions.unwrap() > 0, + "at least one eviction must have occurred; got evictions={evictions:?}" + ); +} + +/// `cache_capacity` via the trait must equal the effective total capacity +/// (sum of per-shard capacities, reflecting the per-shard floor if applicable). +#[test] +fn sharded_lru_cache_capacity_aggregated_via_trait() { + let shards = 2usize; + let per_shard = 4usize; + + let store = ShardedLruCache::::builder() + .shards(shards) + .per_shard_max_size(per_shard) + .build() + .expect("build ShardedLruCache"); + + let capacity = ConcurrentCacheBase::cache_capacity(&store); + assert_eq!( + capacity, + Some(shards * per_shard), + "cache_capacity must equal shards * per_shard_max_size = {}", + shards * per_shard + ); +} + +/// `cache_hits` and `cache_misses` via the trait move correctly with gets. +#[test] +fn sharded_lru_cache_hits_and_misses_via_trait() { + let store = ShardedLruCache::::builder() + .shards(2) + .per_shard_max_size(8) + .build() + .expect("build ShardedLruCache"); + + // Baseline: empty cache, no hits or misses yet. + let hits_before = ConcurrentCacheBase::cache_hits(&store).unwrap_or(0); + let misses_before = ConcurrentCacheBase::cache_misses(&store).unwrap_or(0); + + // Insert a key. + ConcurrentCached::cache_set(&store, "present".to_string(), 42u64).unwrap(); + + // Miss on an absent key. + let got_miss = ConcurrentCached::cache_get(&store, &"absent".to_string()).unwrap(); + assert_eq!(got_miss, None, "absent key must return None"); + + // Hit on the present key. + let got_hit = ConcurrentCached::cache_get(&store, &"present".to_string()).unwrap(); + assert_eq!(got_hit, Some(42), "present key must return Some(42)"); + + let hits_after = ConcurrentCacheBase::cache_hits(&store).unwrap_or(0); + let misses_after = ConcurrentCacheBase::cache_misses(&store).unwrap_or(0); + + assert_eq!( + hits_after, + hits_before + 1, + "one hit must have been recorded" + ); + assert_eq!( + misses_after, + misses_before + 1, + "one miss must have been recorded" + ); +} + +/// All three metrics coexist correctly after a mixed workload (inserts + gets). +#[test] +fn sharded_lru_cache_aggregated_metrics_consistent() { + let store = ShardedLruCache::::builder() + .shards(4) + .per_shard_max_size(2) // very small per-shard cap to force evictions + .build() + .expect("build ShardedLruCache(4 shards, 2 per-shard)"); + + // Insert 40 distinct keys to saturate all shards and drive evictions. + for i in 0u32..40 { + ConcurrentCached::cache_set(&store, i, i * 10).unwrap(); + } + + // Read a few keys — some will hit, some will miss (evicted). + let mut hits = 0u64; + let mut misses = 0u64; + for i in 0u32..40 { + match ConcurrentCached::cache_get(&store, &i).unwrap() { + Some(_) => hits += 1, + None => misses += 1, + } + } + + let agg_hits = ConcurrentCacheBase::cache_hits(&store).unwrap_or(0); + let agg_misses = ConcurrentCacheBase::cache_misses(&store).unwrap_or(0); + let agg_evictions = ConcurrentCacheBase::cache_evictions(&store).unwrap_or(0); + let agg_capacity = ConcurrentCacheBase::cache_capacity(&store); + + // Aggregated hits/misses must match what we counted. + assert_eq!( + agg_hits, hits, + "aggregated hits must equal measured hit count" + ); + assert_eq!( + agg_misses, misses, + "aggregated misses must equal measured miss count" + ); + + // Capacity must be reported and consistent (4 shards * 2 per-shard = 8). + assert_eq!( + agg_capacity, + Some(4 * 2), + "capacity must be 4 shards * 2 per-shard = 8" + ); + + // At least some evictions must have occurred (40 inserts into 8 slots). + assert!( + agg_evictions > 0, + "at least one eviction must have occurred with 40 inserts into 8 slots; got {agg_evictions}" + ); +} diff --git a/tests/v3_custom_hasher_threaded.rs b/tests/v3_custom_hasher_threaded.rs new file mode 100644 index 00000000..280de40e --- /dev/null +++ b/tests/v3_custom_hasher_threaded.rs @@ -0,0 +1,189 @@ +/*! +Tests that non-sharded stores (`UnboundCache`, `LruCache`) correctly accept a +custom `BuildHasher` via their `.hasher(...)` builder method. + +These tests do NOT require a Redis server. + +Covered: +- `UnboundCache` built via `.hasher(...)` inserts and reads + back several entries, asserting correct hit/miss behavior. +- `LruCache` built via `.hasher(...)` does the same, and + additionally exercises LRU eviction to confirm the hasher doesn't break ordering. +- A deterministic FNV-1a hasher is used as `MyHasher` so the custom-hasher path + cannot silently fall back to `DefaultHashBuilder`. +*/ + +use std::hash::{BuildHasher, Hasher}; + +use cached::{Cached, LruCache, UnboundCache}; + +// ── Minimal FNV-1a BuildHasher ──────────────────────────────────────────────── + +/// A simple FNV-1a 64-bit hasher. +struct FnvHasher(u64); + +impl Default for FnvHasher { + fn default() -> Self { + Self(0xcbf2_9ce4_8422_2325) + } +} + +impl Hasher for FnvHasher { + fn write(&mut self, bytes: &[u8]) { + for &b in bytes { + self.0 ^= b as u64; + self.0 = self.0.wrapping_mul(0x0000_0100_0000_01b3); + } + } + + fn finish(&self) -> u64 { + self.0 + } +} + +/// A `BuildHasher` that constructs a `FnvHasher`. +#[derive(Clone, Default)] +struct FnvBuildHasher; + +impl BuildHasher for FnvBuildHasher { + type Hasher = FnvHasher; + fn build_hasher(&self) -> Self::Hasher { + FnvHasher::default() + } +} + +// ── UnboundCache with custom hasher ────────────────────────────────────────── + +#[test] +fn unbound_cache_custom_hasher_hit_and_miss() { + let mut cache = UnboundCache::::builder() + .hasher(FnvBuildHasher) + .build() + .expect("build UnboundCache with FnvBuildHasher"); + + // Miss on empty cache. + assert_eq!( + cache.cache_get(&"absent".to_string()), + None, + "cache miss on absent key" + ); + + // Insert several entries. + cache.cache_set("alpha".to_string(), 1); + cache.cache_set("beta".to_string(), 2); + cache.cache_set("gamma".to_string(), 3); + + // All three must be retrievable. + assert_eq!(cache.cache_get(&"alpha".to_string()), Some(&1)); + assert_eq!(cache.cache_get(&"beta".to_string()), Some(&2)); + assert_eq!(cache.cache_get(&"gamma".to_string()), Some(&3)); + + // A key that was never inserted must still miss. + assert_eq!(cache.cache_get(&"delta".to_string()), None); + + // Overwrite an existing entry. + cache.cache_set("alpha".to_string(), 99); + assert_eq!( + cache.cache_get(&"alpha".to_string()), + Some(&99), + "overwritten value must be returned" + ); + + // Remove one entry; it must no longer be present. + let _ = cache.cache_remove(&"beta".to_string()); + assert_eq!( + cache.cache_get(&"beta".to_string()), + None, + "removed entry must return None" + ); +} + +#[test] +fn unbound_cache_custom_hasher_metrics() { + let mut cache = UnboundCache::::builder() + .hasher(FnvBuildHasher) + .build() + .expect("build UnboundCache with FnvBuildHasher"); + + // Baseline: no hits or misses yet. + let hits_before = cache.cache_hits().unwrap_or(0); + let misses_before = cache.cache_misses().unwrap_or(0); + + cache.cache_set("x".to_string(), 42); + + // Miss (absent key). + cache.cache_get(&"absent".to_string()); + // Hit. + cache.cache_get(&"x".to_string()); + + let hits_after = cache.cache_hits().unwrap_or(0); + let misses_after = cache.cache_misses().unwrap_or(0); + + assert!( + hits_after > hits_before, + "hit counter must have incremented" + ); + assert!( + misses_after > misses_before, + "miss counter must have incremented" + ); +} + +// ── LruCache with custom hasher ─────────────────────────────────────────────── + +#[test] +fn lru_cache_custom_hasher_hit_and_miss() { + let mut cache = LruCache::::builder() + .max_size(8) + .hasher(FnvBuildHasher) + .build() + .expect("build LruCache with FnvBuildHasher"); + + // Miss on empty cache. + assert_eq!(cache.cache_get(&"absent".to_string()), None); + + cache.cache_set("a".to_string(), 10); + cache.cache_set("b".to_string(), 20); + cache.cache_set("c".to_string(), 30); + + assert_eq!(cache.cache_get(&"a".to_string()), Some(&10)); + assert_eq!(cache.cache_get(&"b".to_string()), Some(&20)); + assert_eq!(cache.cache_get(&"c".to_string()), Some(&30)); + assert_eq!(cache.cache_get(&"z".to_string()), None); +} + +#[test] +fn lru_cache_custom_hasher_eviction() { + // Capacity 2 means the third insert must evict the LRU entry. + let mut cache = LruCache::::builder() + .max_size(2) + .hasher(FnvBuildHasher) + .build() + .expect("build LruCache(2) with FnvBuildHasher"); + + cache.cache_set("first".to_string(), 1); // LRU + cache.cache_set("second".to_string(), 2); // MRU + + // Access "first" to make "second" the LRU. + cache.cache_get(&"first".to_string()); + + // Third insert evicts the LRU, which is now "second". + cache.cache_set("third".to_string(), 3); + + // "second" must have been evicted. + assert_eq!( + cache.cache_get(&"second".to_string()), + None, + "LRU entry (second) must have been evicted" + ); + // The other two must still be present. + assert_eq!(cache.cache_get(&"first".to_string()), Some(&1)); + assert_eq!(cache.cache_get(&"third".to_string()), Some(&3)); + + // Eviction counter must reflect one eviction. + assert_eq!( + cache.cache_evictions(), + Some(1), + "eviction counter must be 1 after one LRU eviction" + ); +} diff --git a/tests/v3_force_refresh.rs b/tests/v3_force_refresh.rs new file mode 100644 index 00000000..3136b602 --- /dev/null +++ b/tests/v3_force_refresh.rs @@ -0,0 +1,902 @@ +/*! +Regression tests for force_refresh single-evaluation on the unsync_reads path. + +Covers: +- force_refresh predicate is evaluated AT MOST ONCE per call when `unsync_reads` + is set (the `SyncWriteMode::Default` + `unsync_reads` path). Before the fix the + predicate was expanded inside both the optimistic read-lock block and the + write-lock re-check, causing double evaluation on every call. +- const-generic functions with `key` + `convert` compile and cache correctly. +*/ + +#![cfg(feature = "proc_macro")] +// The lifetime-only-generic regression tests below deliberately spell out `<'a>` +// to prove the macro's generic guard accepts a lifetime param (eliding it would +// drop the very thing under test). clippy's `needless_lifetimes` fires on the +// macro-generated origin fn, where an attribute cannot be forwarded, so suppress +// it file-wide. +#![allow(clippy::needless_lifetimes)] + +use std::sync::atomic::{AtomicUsize, Ordering}; + +use cached::macros::cached; + +// ── force_refresh single-evaluation on unsync_reads path ────────────────────── +// +// The predicate block is a side-effecting counter increment. If the predicate is +// evaluated more than once per call, the counter will over-count relative to the +// number of function invocations. + +static UNSYNC_FR_PREDICATE_EVALS: AtomicUsize = AtomicUsize::new(0); +static UNSYNC_FR_BODY_CALLS: AtomicUsize = AtomicUsize::new(0); + +// `bypass` is excluded from the cache key via `key`/`convert` so the same slot +// is hit/refreshed regardless of the flag. +// `unsync_reads` triggers the optimistic read-lock path under `SyncWriteMode::Default`. +// The `force_refresh` predicate increments the counter so we can count evaluations. +#[cached( + key = "i32", + convert = "{ x }", + unsync_reads, + force_refresh = "{ UNSYNC_FR_PREDICATE_EVALS.fetch_add(1, Ordering::SeqCst); bypass }" +)] +fn unsync_fr_fn(x: i32, bypass: bool) -> i32 { + let _ = bypass; // consumed by the generated force_refresh guard, not the body + UNSYNC_FR_BODY_CALLS.fetch_add(1, Ordering::SeqCst); + x +} + +#[test] +fn force_refresh_predicate_evaluated_once_per_call_on_unsync_reads_path() { + UNSYNC_FR_PREDICATE_EVALS.store(0, Ordering::SeqCst); + UNSYNC_FR_BODY_CALLS.store(0, Ordering::SeqCst); + + // Call 1: cache miss (bypass = false). Predicate evaluates once -> false. + // Body runs, result is cached. + let _ = unsync_fr_fn(42, false); + assert_eq!( + UNSYNC_FR_PREDICATE_EVALS.load(Ordering::SeqCst), + 1, + "call 1 (miss, no bypass): predicate must run exactly once" + ); + assert_eq!(UNSYNC_FR_BODY_CALLS.load(Ordering::SeqCst), 1); + + // Call 2: cache hit (bypass = false). Predicate evaluates once -> false. + // Cached value returned, body does NOT run. + let _ = unsync_fr_fn(42, false); + assert_eq!( + UNSYNC_FR_PREDICATE_EVALS.load(Ordering::SeqCst), + 2, + "call 2 (hit, no bypass): predicate must run exactly once (not twice)" + ); + assert_eq!( + UNSYNC_FR_BODY_CALLS.load(Ordering::SeqCst), + 1, + "hit path: body must not re-run" + ); + + // Call 3: force bypass (bypass = true). Predicate evaluates once -> true. + // Body re-runs and overwrites the cache entry. + let _ = unsync_fr_fn(42, true); + assert_eq!( + UNSYNC_FR_PREDICATE_EVALS.load(Ordering::SeqCst), + 3, + "call 3 (bypass): predicate must run exactly once" + ); + assert_eq!( + UNSYNC_FR_BODY_CALLS.load(Ordering::SeqCst), + 2, + "bypass: body must re-run" + ); + + // Verify: total predicate evals == total calls (3), never more. + // Pre-fix: each call would double-evaluate the predicate (6 total), not 3. + assert_eq!( + UNSYNC_FR_PREDICATE_EVALS.load(Ordering::SeqCst), + 3, + "total predicate evals must equal total calls (1 eval per call)" + ); +} + +// ── const-generic positive test: key + convert compiles and caches ───────────── +// +// Confirms that a const-generic function WITH key + convert successfully compiles +// and caches: the first call runs the body and caches the result; subsequent calls +// with the same arguments are served from the cache. +// +// Note: the const parameter N must appear in the argument types so that the +// compiler can infer N when calling the generated `_no_cache` helper. Here we use +// a `&[i32; N]` parameter so N is determined by the slice length at the call site. + +static CONST_GEN_CALLS: AtomicUsize = AtomicUsize::new(0); + +// `key`/`convert` pins the cache key to a concrete `String` (the slice's debug +// repr), satisfying the guard. The const parameter N is part of the argument type +// `&[i32; N]`, so the compiler can infer N at each call site. +#[cached(key = "String", convert = r#"{ format!("{:?}", arr) }"#)] +fn const_generic_cached(arr: &[i32; N]) -> usize { + CONST_GEN_CALLS.fetch_add(1, Ordering::SeqCst); + arr.len() +} + +#[test] +fn const_generic_with_key_convert_compiles_and_caches() { + CONST_GEN_CALLS.store(0, Ordering::SeqCst); + + let arr = [1i32, 2, 3]; + + // First call: miss, body runs. + let v1 = const_generic_cached(&arr); + assert_eq!(v1, 3); + assert_eq!(CONST_GEN_CALLS.load(Ordering::SeqCst), 1); + + // Second call with same key: hit. + let v2 = const_generic_cached(&arr); + assert_eq!(v2, 3); + assert_eq!( + CONST_GEN_CALLS.load(Ordering::SeqCst), + 1, + "second call with same key must be a cache hit" + ); +} + +// ── lifetime-only generics must NOT be rejected (Change 1 regression guard) ── +// +// The generic-function guard checks `type_params()` and `const_params()` only. +// `syn` excludes lifetime parameters from both iterators, so a function that is +// generic only in lifetimes (no type params, no const params) must compile and +// cache WITHOUT `key`/`convert`. This test pins that behavior. +// +// Regression trigger: if the guard were changed to `generics.params.is_empty()` +// instead of the targeted `type_params()/const_params()` check, every cached fn +// that takes a `&'a T` (e.g. `&str`) would be rejected with a compile error. +// +// These functions use `key`/`convert` to own the borrowed data for the key, +// which is normal for reference args, but the ABSENCE of a rejection is what +// we are pinning: the macro must not complain about the lifetime parameter. + +use cached::macros::concurrent_cached; + +static LIFETIME_CACHED_CALLS: AtomicUsize = AtomicUsize::new(0); + +// A function generic in lifetime only. The default key derives from the owned +// `String` produced by `convert`, pinning the cache slot. Without lifetime +// generics being explicitly allowed by the guard, this would fail to expand. +#[cached(key = "String", convert = r#"{ s.to_owned() }"#)] +fn lifetime_only_cached<'a>(s: &'a str) -> usize { + LIFETIME_CACHED_CALLS.fetch_add(1, Ordering::SeqCst); + s.len() +} + +#[test] +fn lifetime_only_generic_cached_compiles_and_caches() { + LIFETIME_CACHED_CALLS.store(0, Ordering::SeqCst); + + // First call: miss, body runs. + assert_eq!(lifetime_only_cached("hello"), 5); + assert_eq!(LIFETIME_CACHED_CALLS.load(Ordering::SeqCst), 1); + + // Second call with the same string: cache hit, body does NOT run. + assert_eq!(lifetime_only_cached("hello"), 5); + assert_eq!( + LIFETIME_CACHED_CALLS.load(Ordering::SeqCst), + 1, + "second call with same arg must be a cache hit (lifetime generic must not be rejected)" + ); + + // Different string: miss, body runs again. + assert_eq!(lifetime_only_cached("world!"), 6); + assert_eq!(LIFETIME_CACHED_CALLS.load(Ordering::SeqCst), 2); +} + +static LIFETIME_CONC_CALLS: AtomicUsize = AtomicUsize::new(0); + +// Same regression guard but for `#[concurrent_cached]`. The concurrent path has +// its own copy of the guard (concurrent_cached.rs ~line 257). +#[concurrent_cached(key = "String", convert = r#"{ s.to_owned() }"#)] +fn lifetime_only_concurrent<'a>(s: &'a str) -> usize { + LIFETIME_CONC_CALLS.fetch_add(1, Ordering::SeqCst); + s.len() +} + +#[test] +fn lifetime_only_generic_concurrent_cached_compiles_and_caches() { + LIFETIME_CONC_CALLS.store(0, Ordering::SeqCst); + + // First call: miss, body runs. + assert_eq!(lifetime_only_concurrent("abc"), 3); + assert_eq!(LIFETIME_CONC_CALLS.load(Ordering::SeqCst), 1); + + // Second call with the same string: cache hit, body does NOT run. + assert_eq!(lifetime_only_concurrent("abc"), 3); + assert_eq!( + LIFETIME_CONC_CALLS.load(Ordering::SeqCst), + 1, + "second call with same arg must be a cache hit (lifetime generic must not be rejected)" + ); + + // Different string: miss, body runs again. + assert_eq!(lifetime_only_concurrent("xy"), 2); + assert_eq!(LIFETIME_CONC_CALLS.load(Ordering::SeqCst), 2); +} + +// ── unsync_reads WITHOUT force_refresh: SyncWriteMode::Default baseline ─────── +// +// Pins that the `SyncWriteMode::Default` + `unsync_reads` path (the path that +// received the force_refresh single-eval hoist) correctly caches WITHOUT any +// force_refresh: a miss populates the cache and all subsequent calls are hits. +// +// This is the zero-predicate case for the hoisted binding: +// `let __cached_force_refreshing = if true { false } else { true };` -> always false +// Both the read-lock block and the write-lock re-check use `false`, so the cache +// is always consulted and the body runs exactly once per unique key. +// +// Without this test, a future refactor that accidentally sets +// `__cached_force_refreshing = true` unconditionally would silently skip the +// cache on every call, and no v3 test would catch it. + +static UNSYNC_NO_FR_CALLS: AtomicUsize = AtomicUsize::new(0); + +#[cached(unsync_reads, sync_writes = "default")] +fn unsync_no_force_refresh(x: i32) -> i32 { + UNSYNC_NO_FR_CALLS.fetch_add(1, Ordering::SeqCst); + x * 2 +} + +#[test] +fn unsync_reads_without_force_refresh_caches_correctly() { + UNSYNC_NO_FR_CALLS.store(0, Ordering::SeqCst); + + // First call: miss, body runs. + assert_eq!(unsync_no_force_refresh(3), 6); + assert_eq!(UNSYNC_NO_FR_CALLS.load(Ordering::SeqCst), 1); + + // Second call, same key: hit, body must NOT run. + assert_eq!(unsync_no_force_refresh(3), 6); + assert_eq!( + UNSYNC_NO_FR_CALLS.load(Ordering::SeqCst), + 1, + "unsync_reads without force_refresh: second call must be a cache hit" + ); + + // Different key: miss, body runs. + assert_eq!(unsync_no_force_refresh(7), 14); + assert_eq!(UNSYNC_NO_FR_CALLS.load(Ordering::SeqCst), 2); + + // Repeat different key: hit. + assert_eq!(unsync_no_force_refresh(7), 14); + assert_eq!( + UNSYNC_NO_FR_CALLS.load(Ordering::SeqCst), + 2, + "unsync_reads without force_refresh: repeated call must stay cached" + ); +} + +// ── unsync_reads WITH always-true force_refresh: every call recomputes ──────── +// +// Pins that the `SyncWriteMode::Default` + `unsync_reads` + always-bypassing +// force_refresh path recomputes on EVERY call. The hoisted binding evaluates to +// `true` on every call, both the optimistic read-lock block and the write-lock +// re-check skip the cache, and the body runs every time. +// +// Without this test, a bug that short-circuits the hoisted flag to `false` +// (never bypass) would silently serve stale values and go undetected. + +static UNSYNC_ALWAYS_FR_CALLS: AtomicUsize = AtomicUsize::new(0); + +#[cached( + key = "i32", + convert = "{ x }", + unsync_reads, + sync_writes = "default", + force_refresh = "{ true }" +)] +fn unsync_always_force_refresh(x: i32) -> i32 { + UNSYNC_ALWAYS_FR_CALLS.fetch_add(1, Ordering::SeqCst); + x +} + +#[test] +fn unsync_reads_with_always_true_force_refresh_recomputes_every_call() { + UNSYNC_ALWAYS_FR_CALLS.store(0, Ordering::SeqCst); + + // Every call bypasses the cache regardless of whether the entry is warm. + let _ = unsync_always_force_refresh(5); + let _ = unsync_always_force_refresh(5); + let _ = unsync_always_force_refresh(5); + assert_eq!( + UNSYNC_ALWAYS_FR_CALLS.load(Ordering::SeqCst), + 3, + "always-bypass predicate: body must run on every call (3 calls -> 3 body executions)" + ); +} + +// ── by_key + unsync_reads + force_refresh: single inline guard, no double-eval ─ +// +// The `SyncWriteMode::ByKey` + `unsync_reads` path expands `#force_refresh_guard` +// inline into `by_key_cache_get_return_block` once, with no write-lock re-check +// (unlike `SyncWriteMode::Default` + `unsync_reads` which had the double-eval bug). +// After the key-specific lock is acquired, the function body is called +// unconditionally if the guard caused a bypass, then `set_cache_and_return` runs. +// There is intentionally no second guard expansion in the write-lock section. +// +// This test documents and pins the behavior of the by_key + unsync_reads + +// force_refresh combination. It also demonstrates that a side-effecting predicate +// on this path is evaluated exactly once per call (single expansion, no write-lock +// re-check means no double-eval risk on this path). + +static BY_KEY_UNSYNC_FR_PREDICATE_EVALS: AtomicUsize = AtomicUsize::new(0); +static BY_KEY_UNSYNC_FR_BODY_CALLS: AtomicUsize = AtomicUsize::new(0); + +#[cached( + key = "i32", + convert = "{ x }", + sync_writes = "by_key", + unsync_reads, + force_refresh = "{ BY_KEY_UNSYNC_FR_PREDICATE_EVALS.fetch_add(1, Ordering::SeqCst); bypass }" +)] +fn by_key_unsync_force_refresh(x: i32, bypass: bool) -> i32 { + let _ = bypass; // consumed by the generated force_refresh guard + BY_KEY_UNSYNC_FR_BODY_CALLS.fetch_add(1, Ordering::SeqCst); + x +} + +#[test] +fn by_key_unsync_reads_force_refresh_single_eval_per_call() { + BY_KEY_UNSYNC_FR_PREDICATE_EVALS.store(0, Ordering::SeqCst); + BY_KEY_UNSYNC_FR_BODY_CALLS.store(0, Ordering::SeqCst); + + // Call 1: miss (bypass = false). Predicate evaluates once -> false. Body runs. + let _ = by_key_unsync_force_refresh(10, false); + assert_eq!( + BY_KEY_UNSYNC_FR_PREDICATE_EVALS.load(Ordering::SeqCst), + 1, + "by_key + unsync: miss, no bypass: predicate must run exactly once" + ); + assert_eq!(BY_KEY_UNSYNC_FR_BODY_CALLS.load(Ordering::SeqCst), 1); + + // Call 2: hit (bypass = false). Predicate evaluates once -> false. + // Cache hit returned, body does NOT run. + let _ = by_key_unsync_force_refresh(10, false); + assert_eq!( + BY_KEY_UNSYNC_FR_PREDICATE_EVALS.load(Ordering::SeqCst), + 2, + "by_key + unsync: hit, no bypass: predicate must run exactly once" + ); + assert_eq!( + BY_KEY_UNSYNC_FR_BODY_CALLS.load(Ordering::SeqCst), + 1, + "hit path: body must not re-run" + ); + + // Call 3: bypass (bypass = true). Predicate evaluates once -> true. + // Body re-runs. + let _ = by_key_unsync_force_refresh(10, true); + assert_eq!( + BY_KEY_UNSYNC_FR_PREDICATE_EVALS.load(Ordering::SeqCst), + 3, + "by_key + unsync: bypass: predicate must run exactly once" + ); + assert_eq!( + BY_KEY_UNSYNC_FR_BODY_CALLS.load(Ordering::SeqCst), + 2, + "bypass: body must re-run" + ); + + // Total: 3 calls -> 3 predicate evals. The by_key path has no write-lock + // re-check, so the predicate is never expanded twice even before the fix. + assert_eq!( + BY_KEY_UNSYNC_FR_PREDICATE_EVALS.load(Ordering::SeqCst), + 3, + "total predicate evals must equal total calls (1 eval per call on by_key path)" + ); +} + +// ── force_refresh + result_fallback: stale fallback preserved for LIVE entry ───── +// +// Baseline: with a LIVE (not yet expired) entry, force-refresh + Err still returns +// the stale Ok fallback. This was already correct before the fix; we pin it to +// ensure the fix does not break the live-entry case. +// +// The function uses SyncWriteMode::Disabled (the default, no sync_writes attribute) +// and ttl_secs = 60 so the entry stays live throughout the test. + +#[cfg(feature = "time_stores")] +mod result_fallback_live_tests { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + use cached::macros::cached; + + static RF_LIVE_BODY_CALLS: AtomicUsize = AtomicUsize::new(0); + static RF_LIVE_RETURN_ERR: AtomicBool = AtomicBool::new(false); + + #[cached( + key = "i32", + convert = "{ x }", + ttl_secs = 60, + result_fallback = true, + force_refresh = "{ bypass }" + )] + fn rf_live_fn(x: i32, bypass: bool) -> Result { + let _ = bypass; // consumed by the generated force_refresh guard + RF_LIVE_BODY_CALLS.fetch_add(1, Ordering::SeqCst); + if RF_LIVE_RETURN_ERR.load(Ordering::SeqCst) { + Err(format!("error for {x}")) + } else { + Ok(x * 10) + } + } + + #[test] + fn result_fallback_force_refresh_live_entry_returns_stale_ok() { + RF_LIVE_BODY_CALLS.store(0, Ordering::SeqCst); + RF_LIVE_RETURN_ERR.store(false, Ordering::SeqCst); + + // Seed an Ok value into the cache (bypass = false). + let first = rf_live_fn(100, false); + assert_eq!(first, Ok(1000), "seed call must return Ok(1000)"); + assert_eq!(RF_LIVE_BODY_CALLS.load(Ordering::SeqCst), 1); + + // Now the body will return Err. + RF_LIVE_RETURN_ERR.store(true, Ordering::SeqCst); + + // Force-refresh (bypass = true) with Err recompute over a LIVE entry. + // result_fallback must return the stale Ok(1000), not the Err. + let second = rf_live_fn(100, true); + assert_eq!( + second, + Ok(1000), + "force-refresh Err over a LIVE entry must return stale Ok fallback" + ); + assert_eq!(RF_LIVE_BODY_CALLS.load(Ordering::SeqCst), 2); + } +} + +// ── force_refresh + result_fallback: stale fallback preserved for EXPIRED entry ── +// +// Regression guard for the bug where `cache_peek` (used on the bypass branch) returns +// `None` for an expired TTL entry, causing the stale `Ok` fallback to be lost when a +// bypassed recompute returns `Err` over an entry that has expired. +// +// Before the fix: the bypass branch called `CachedPeek::cache_peek`, which returns `None` +// for expired entries. An Err recompute over an expired key therefore had no fallback. +// After the fix: the bypass branch calls `CloneCached::cache_peek_with_expiry_status`, +// which returns `(Some(stale_value), true)` for an expired entry, preserving the fallback. +// +// This test FAILS on the pre-fix code path (the Err propagates instead of the stale Ok) +// and PASSES after the fix. +// +// The function uses SyncWriteMode::Disabled (the default, no sync_writes attribute) +// and ttl_secs = 1 so the entry expires quickly. + +#[cfg(feature = "time_stores")] +mod result_fallback_expired_tests { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + use cached::macros::cached; + + static RF_EXPIRED_BODY_CALLS: AtomicUsize = AtomicUsize::new(0); + static RF_EXPIRED_RETURN_ERR: AtomicBool = AtomicBool::new(false); + + // Separate function from the live-entry test above so the cache static is independent + // (each #[cached] fn has its own static) and the two tests do not share state. + #[cached( + key = "i32", + convert = "{ x }", + ttl_secs = 1, + result_fallback = true, + force_refresh = "{ bypass }" + )] + fn rf_expired_fn(x: i32, bypass: bool) -> Result { + let _ = bypass; // consumed by the generated force_refresh guard + RF_EXPIRED_BODY_CALLS.fetch_add(1, Ordering::SeqCst); + if RF_EXPIRED_RETURN_ERR.load(Ordering::SeqCst) { + Err(format!("error for {x}")) + } else { + Ok(x * 10) + } + } + + #[test] + fn result_fallback_force_refresh_expired_entry_returns_stale_ok() { + RF_EXPIRED_BODY_CALLS.store(0, Ordering::SeqCst); + RF_EXPIRED_RETURN_ERR.store(false, Ordering::SeqCst); + + // Seed an Ok value into the cache (bypass = false). + let first = rf_expired_fn(200, false); + assert_eq!(first, Ok(2000), "seed call must return Ok(2000)"); + assert_eq!(RF_EXPIRED_BODY_CALLS.load(Ordering::SeqCst), 1); + + // Wait for the TTL to expire (ttl_secs = 1 -> sleep at least 1100ms). + std::thread::sleep(std::time::Duration::from_millis(1200)); + + // Now the body will return Err. + RF_EXPIRED_RETURN_ERR.store(true, Ordering::SeqCst); + + // Force-refresh (bypass = true) with Err recompute over an EXPIRED entry. + // Pre-fix: cache_peek returns None for expired -> fallback lost -> Err propagated. + // Post-fix: cache_peek_with_expiry_status returns (Some(2000), true) -> Ok(2000) returned. + let second = rf_expired_fn(200, true); + assert_eq!( + second, + Ok(2000), + "force-refresh Err over an EXPIRED entry must return stale Ok fallback (regression)" + ); + assert_eq!(RF_EXPIRED_BODY_CALLS.load(Ordering::SeqCst), 2); + } +} + +// ── store diversity: expired-entry Err-fallback across every macro-reachable store ─ +// +// The author's expired-fallback test exercises only the default `TtlCache` +// (`ttl_secs` alone). The same bug — the bypass branch losing the stale `Ok` over +// an EXPIRED entry — lives in the bypass path regardless of which single-owner TTL +// store the macro selects. These tests drive the OTHER store overrides reachable +// through `#[cached]` attribute combinations: +// +// ttl_secs + max_size -> LruTtlCache +// expires -> ExpiringCache (per-value expiry) +// expires + max_size -> ExpiringLruCache +// +// `TtlSortedCache` is not selectable through any `#[cached]` attribute; its override +// is certified directly in `v3_cache_peek_with_expiry_status.rs`. + +// LruTtlCache: ttl_secs + max_size. +// +// Each subtest uses its OWN `#[cached]` function so its cache slot and body-call +// counter are fully isolated. The test binary runs tests in parallel by default; +// two tests sharing one cached fn + one global counter would race on the count. +#[cfg(feature = "time_stores")] +mod result_fallback_lru_ttl_tests { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + use cached::macros::cached; + + static RF_LRUTTL_EXP_BODY_CALLS: AtomicUsize = AtomicUsize::new(0); + static RF_LRUTTL_EXP_RETURN_ERR: AtomicBool = AtomicBool::new(false); + + #[cached( + key = "i32", + convert = "{ x }", + ttl_secs = 1, + max_size = 16, + result_fallback = true, + force_refresh = "{ bypass }" + )] + fn rf_lru_ttl_expired_fn(x: i32, bypass: bool) -> Result { + let _ = bypass; + RF_LRUTTL_EXP_BODY_CALLS.fetch_add(1, Ordering::SeqCst); + if RF_LRUTTL_EXP_RETURN_ERR.load(Ordering::SeqCst) { + Err(format!("error for {x}")) + } else { + Ok(x * 10) + } + } + + #[test] + fn lru_ttl_force_refresh_expired_entry_returns_stale_ok() { + RF_LRUTTL_EXP_BODY_CALLS.store(0, Ordering::SeqCst); + RF_LRUTTL_EXP_RETURN_ERR.store(false, Ordering::SeqCst); + + assert_eq!(rf_lru_ttl_expired_fn(300, false), Ok(3000), "seed call"); + assert_eq!(RF_LRUTTL_EXP_BODY_CALLS.load(Ordering::SeqCst), 1); + + std::thread::sleep(std::time::Duration::from_millis(1200)); + RF_LRUTTL_EXP_RETURN_ERR.store(true, Ordering::SeqCst); + + // Bypassed Err recompute over an EXPIRED LruTtlCache entry must recover the + // stale Ok via the store's `cache_peek_with_expiry_status` override. + assert_eq!( + rf_lru_ttl_expired_fn(300, true), + Ok(3000), + "LruTtlCache: force-refresh Err over expired entry must return stale Ok" + ); + assert_eq!(RF_LRUTTL_EXP_BODY_CALLS.load(Ordering::SeqCst), 2); + } + + static RF_LRUTTL_LIVE_BODY_CALLS: AtomicUsize = AtomicUsize::new(0); + static RF_LRUTTL_LIVE_RETURN_ERR: AtomicBool = AtomicBool::new(false); + + #[cached( + key = "i32", + convert = "{ x }", + ttl_secs = 60, + max_size = 16, + result_fallback = true, + force_refresh = "{ bypass }" + )] + fn rf_lru_ttl_live_fn(x: i32, bypass: bool) -> Result { + let _ = bypass; + RF_LRUTTL_LIVE_BODY_CALLS.fetch_add(1, Ordering::SeqCst); + if RF_LRUTTL_LIVE_RETURN_ERR.load(Ordering::SeqCst) { + Err(format!("error for {x}")) + } else { + Ok(x * 10) + } + } + + #[test] + fn lru_ttl_force_refresh_live_entry_returns_stale_ok() { + // Boundary companion: with a LIVE entry the fallback must still hold. + RF_LRUTTL_LIVE_BODY_CALLS.store(0, Ordering::SeqCst); + RF_LRUTTL_LIVE_RETURN_ERR.store(false, Ordering::SeqCst); + + assert_eq!(rf_lru_ttl_live_fn(301, false), Ok(3010), "seed call"); + assert_eq!(RF_LRUTTL_LIVE_BODY_CALLS.load(Ordering::SeqCst), 1); + RF_LRUTTL_LIVE_RETURN_ERR.store(true, Ordering::SeqCst); + assert_eq!( + rf_lru_ttl_live_fn(301, true), + Ok(3010), + "LruTtlCache: force-refresh Err over live entry must return stale Ok" + ); + assert_eq!(RF_LRUTTL_LIVE_BODY_CALLS.load(Ordering::SeqCst), 2); + } +} + +// ExpiringCache: `expires` (per-value expiry, deterministic, no sleeps). +#[cfg(feature = "time_stores")] +mod result_fallback_expiring_tests { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + use cached::Expires; + use cached::macros::cached; + + #[derive(Clone)] + struct Val { + n: i32, + expired: bool, + } + impl Expires for Val { + fn is_expired(&self) -> bool { + self.expired + } + } + + static RF_EXP_E_BODY_CALLS: AtomicUsize = AtomicUsize::new(0); + static RF_EXP_E_RETURN_ERR: AtomicBool = AtomicBool::new(false); + + #[cached( + key = "i32", + convert = "{ x }", + expires = true, + result_fallback = true, + force_refresh = "{ bypass }" + )] + fn rf_expiring_expired_fn(x: i32, bypass: bool) -> Result { + let _ = bypass; + RF_EXP_E_BODY_CALLS.fetch_add(1, Ordering::SeqCst); + if RF_EXP_E_RETURN_ERR.load(Ordering::SeqCst) { + Err(format!("error for {x}")) + } else { + // Seed value is ALREADY expired-by-value, so the bypass peek must + // surface it as (Some, true) rather than dropping it. + Ok(Val { + n: x * 10, + expired: true, + }) + } + } + + #[test] + fn expiring_force_refresh_expired_entry_returns_stale_ok() { + RF_EXP_E_BODY_CALLS.store(0, Ordering::SeqCst); + RF_EXP_E_RETURN_ERR.store(false, Ordering::SeqCst); + + let first = rf_expiring_expired_fn(400, false).expect("seed Ok"); + assert_eq!(first.n, 4000); + assert_eq!(RF_EXP_E_BODY_CALLS.load(Ordering::SeqCst), 1); + + RF_EXP_E_RETURN_ERR.store(true, Ordering::SeqCst); + + // Bypassed Err over the per-value-expired ExpiringCache entry must recover + // the stale Ok (pre-fix: cache_peek returned None for expired -> Err leaked). + let second = + rf_expiring_expired_fn(400, true).expect("expired-entry fallback must yield Ok"); + assert_eq!( + second.n, 4000, + "ExpiringCache: force-refresh Err over expired entry must return stale Ok" + ); + assert_eq!(RF_EXP_E_BODY_CALLS.load(Ordering::SeqCst), 2); + } + + static RF_EXP_L_BODY_CALLS: AtomicUsize = AtomicUsize::new(0); + static RF_EXP_L_RETURN_ERR: AtomicBool = AtomicBool::new(false); + + #[cached( + key = "i32", + convert = "{ x }", + expires = true, + result_fallback = true, + force_refresh = "{ bypass }" + )] + fn rf_expiring_live_fn(x: i32, bypass: bool) -> Result { + let _ = bypass; + RF_EXP_L_BODY_CALLS.fetch_add(1, Ordering::SeqCst); + if RF_EXP_L_RETURN_ERR.load(Ordering::SeqCst) { + Err(format!("error for {x}")) + } else { + Ok(Val { + n: x * 10, + expired: false, + }) // live entry + } + } + + #[test] + fn expiring_force_refresh_live_entry_returns_stale_ok() { + RF_EXP_L_BODY_CALLS.store(0, Ordering::SeqCst); + RF_EXP_L_RETURN_ERR.store(false, Ordering::SeqCst); + + let first = rf_expiring_live_fn(401, false).expect("seed Ok"); + assert_eq!(first.n, 4010); + assert_eq!(RF_EXP_L_BODY_CALLS.load(Ordering::SeqCst), 1); + + RF_EXP_L_RETURN_ERR.store(true, Ordering::SeqCst); + let second = rf_expiring_live_fn(401, true).expect("live-entry fallback must yield Ok"); + assert_eq!( + second.n, 4010, + "ExpiringCache: force-refresh Err over live entry must return stale Ok" + ); + assert_eq!(RF_EXP_L_BODY_CALLS.load(Ordering::SeqCst), 2); + } +} + +// ExpiringLruCache: `expires` + `max_size`. +#[cfg(feature = "time_stores")] +mod result_fallback_expiring_lru_tests { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + use cached::Expires; + use cached::macros::cached; + + static RF_EXPLRU_BODY_CALLS: AtomicUsize = AtomicUsize::new(0); + static RF_EXPLRU_RETURN_ERR: AtomicBool = AtomicBool::new(false); + static RF_EXPLRU_MAKE_STALE: AtomicBool = AtomicBool::new(false); + + #[derive(Clone)] + struct Val { + n: i32, + expired: bool, + } + impl Expires for Val { + fn is_expired(&self) -> bool { + self.expired + } + } + + #[cached( + key = "i32", + convert = "{ x }", + expires = true, + max_size = 16, + result_fallback = true, + force_refresh = "{ bypass }" + )] + fn rf_expiring_lru_fn(x: i32, bypass: bool) -> Result { + let _ = bypass; + RF_EXPLRU_BODY_CALLS.fetch_add(1, Ordering::SeqCst); + if RF_EXPLRU_RETURN_ERR.load(Ordering::SeqCst) { + Err(format!("error for {x}")) + } else { + Ok(Val { + n: x * 10, + expired: RF_EXPLRU_MAKE_STALE.load(Ordering::SeqCst), + }) + } + } + + #[test] + fn expiring_lru_force_refresh_expired_entry_returns_stale_ok() { + RF_EXPLRU_BODY_CALLS.store(0, Ordering::SeqCst); + RF_EXPLRU_RETURN_ERR.store(false, Ordering::SeqCst); + RF_EXPLRU_MAKE_STALE.store(true, Ordering::SeqCst); + + let first = rf_expiring_lru_fn(500, false).expect("seed Ok"); + assert_eq!(first.n, 5000); + assert_eq!(RF_EXPLRU_BODY_CALLS.load(Ordering::SeqCst), 1); + + RF_EXPLRU_RETURN_ERR.store(true, Ordering::SeqCst); + + let second = rf_expiring_lru_fn(500, true).expect("expired-entry fallback must yield Ok"); + assert_eq!( + second.n, 5000, + "ExpiringLruCache: force-refresh Err over expired entry must return stale Ok" + ); + assert_eq!(RF_EXPLRU_BODY_CALLS.load(Ordering::SeqCst), 2); + } +} + +// ── force_refresh predicate FALSE: normal early-return path is unaffected ────── +// +// Boundary: when the force_refresh predicate is false, the bypass arm is NOT taken, +// the renewing read serves the early-return on a fresh hit, and the body must not +// re-run. This pins that the rewired capture did not disturb the non-bypass branch. + +#[cfg(feature = "time_stores")] +mod result_fallback_predicate_false_tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use cached::macros::cached; + + static RF_FALSE_BODY_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[cached( + key = "i32", + convert = "{ x }", + ttl_secs = 60, + result_fallback = true, + force_refresh = "{ false }" + )] + fn rf_false_fn(x: i32) -> Result { + RF_FALSE_BODY_CALLS.fetch_add(1, Ordering::SeqCst); + Ok(x * 10) + } + + #[test] + fn predicate_false_serves_cache_without_rerunning_body() { + RF_FALSE_BODY_CALLS.store(0, Ordering::SeqCst); + + assert_eq!(rf_false_fn(600), Ok(6000), "miss seeds the cache"); + assert_eq!(RF_FALSE_BODY_CALLS.load(Ordering::SeqCst), 1); + + // Fresh hit, predicate false -> early return, body must NOT re-run. + assert_eq!(rf_false_fn(600), Ok(6000)); + assert_eq!( + RF_FALSE_BODY_CALLS.load(Ordering::SeqCst), + 1, + "predicate-false fresh hit must serve cache, not re-run body" + ); + } +} + +// ── async path: result_fallback + force_refresh expired-entry fallback ───────── +// +// The async `#[cached]` expansion routes the bypass capture through the same +// `cache_peek_with_expiry_status` call. This pins that an async bypassed Err over +// an EXPIRED TtlCache entry still recovers the stale Ok fallback. + +#[cfg(all(feature = "async", feature = "time_stores"))] +mod result_fallback_async_tests { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + use cached::macros::cached; + + static RF_ASYNC_BODY_CALLS: AtomicUsize = AtomicUsize::new(0); + static RF_ASYNC_RETURN_ERR: AtomicBool = AtomicBool::new(false); + + #[cached( + key = "i32", + convert = "{ x }", + ttl_secs = 1, + result_fallback = true, + force_refresh = "{ bypass }" + )] + async fn rf_async_fn(x: i32, bypass: bool) -> Result { + let _ = bypass; + RF_ASYNC_BODY_CALLS.fetch_add(1, Ordering::SeqCst); + if RF_ASYNC_RETURN_ERR.load(Ordering::SeqCst) { + Err(format!("error for {x}")) + } else { + Ok(x * 10) + } + } + + #[tokio::test] + async fn async_force_refresh_expired_entry_returns_stale_ok() { + RF_ASYNC_BODY_CALLS.store(0, Ordering::SeqCst); + RF_ASYNC_RETURN_ERR.store(false, Ordering::SeqCst); + + assert_eq!(rf_async_fn(700, false).await, Ok(7000), "seed call"); + assert_eq!(RF_ASYNC_BODY_CALLS.load(Ordering::SeqCst), 1); + + tokio::time::sleep(std::time::Duration::from_millis(1200)).await; + RF_ASYNC_RETURN_ERR.store(true, Ordering::SeqCst); + + assert_eq!( + rf_async_fn(700, true).await, + Ok(7000), + "async: force-refresh Err over expired entry must return stale Ok" + ); + assert_eq!(RF_ASYNC_BODY_CALLS.load(Ordering::SeqCst), 2); + } +} diff --git a/tests/v3_macros.rs b/tests/v3_macros.rs new file mode 100644 index 00000000..72d4ed8b --- /dev/null +++ b/tests/v3_macros.rs @@ -0,0 +1,2427 @@ +/*! +Integration tests for the 3.0 macro changes: + +- (#230/#114): macro-introduced bindings no longer collide with user args + named `key`/`cache`/`result` (the confirmed repro) under all three macros. +- (#202/#203): reference inputs (`&str`/`Option<&str>`/`&String`) form an + owned default key without a `convert` block. +- (#149): the new `ttl_millis` attribute (recompute after a sub-second TTL), + gated on `time_stores`. +- (#146): the new `force_refresh` attribute (bypass the cache on demand). +- (#16/#140): `in_impl = true` caches a method that takes `self`. +*/ + +#![cfg(feature = "proc_macro")] +// Several tests intentionally take `&String` / other ref args to exercise the +// macro's default-key handling for reference inputs (#202/#203). +#![allow(clippy::ptr_arg)] + +use cached::macros::{cached, concurrent_cached, once}; + +// ── (#230/#114): user args named like macro internals ────────────────────── +// Before the binding-hygiene fix, a function argument named `key`, `cache`, or +// `result` shadowed the macro-introduced locals and failed to compile. + +static COLLIDE_CACHED_CALLS: AtomicUsize = AtomicUsize::new(0); +static COLLIDE_ONCE_CALLS: AtomicUsize = AtomicUsize::new(0); +static COLLIDE_CONCURRENT_CALLS: AtomicUsize = AtomicUsize::new(0); + +#[cached] +fn collide_cached(key: i32, cache: i32, result: i32) -> i32 { + COLLIDE_CACHED_CALLS.fetch_add(1, Ordering::SeqCst); + key + cache + result +} + +#[once] +fn collide_once(key: i32, cache: i32, result: i32) -> i32 { + COLLIDE_ONCE_CALLS.fetch_add(1, Ordering::SeqCst); + key + cache + result +} + +#[concurrent_cached] +fn collide_concurrent(key: i32, cache: i32, result: i32) -> i32 { + COLLIDE_CONCURRENT_CALLS.fetch_add(1, Ordering::SeqCst); + key + cache + result +} + +#[test] +fn arg_name_collisions_compile_and_cache() { + // Reset counters so the test does not depend on execution order. + COLLIDE_CACHED_CALLS.store(0, Ordering::SeqCst); + COLLIDE_ONCE_CALLS.store(0, Ordering::SeqCst); + COLLIDE_CONCURRENT_CALLS.store(0, Ordering::SeqCst); + + // #[cached]: two calls with the same args hit the cache; body runs once. + assert_eq!(collide_cached(1, 2, 3), 6); + assert_eq!(collide_cached(1, 2, 3), 6); // cached hit, same key + assert_eq!( + COLLIDE_CACHED_CALLS.load(Ordering::SeqCst), + 1, + "#[cached]: second same-arg call must be a cache hit (body runs once)" + ); + assert_eq!(collide_cached(10, 20, 30), 60); + + // `#[once]` caches the first produced value for all later calls. + assert_eq!(collide_once(1, 2, 3), 6); + assert_eq!(collide_once(4, 5, 6), 6); // once: single value, second call is a cache hit + assert_eq!( + COLLIDE_ONCE_CALLS.load(Ordering::SeqCst), + 1, + "#[once]: second call with different args must be a cache hit (body runs once)" + ); + + // #[concurrent_cached]: two calls with the same args hit the cache; body runs once. + assert_eq!(collide_concurrent(1, 2, 3), 6); + assert_eq!(collide_concurrent(1, 2, 3), 6); + assert_eq!( + COLLIDE_CONCURRENT_CALLS.load(Ordering::SeqCst), + 1, + "#[concurrent_cached]: second same-arg call must be a cache hit (body runs once)" + ); + assert_eq!(collide_concurrent(7, 8, 9), 24); +} + +// ── (#202/#203): reference inputs form an owned default key ──────────────── +// `&str`, `Option<&str>`, and `&String` should produce an owned key (`String` / +// `Option`) without an explicit `key`/`convert`. + +use std::sync::atomic::{AtomicUsize, Ordering}; + +static STR_CALLS: AtomicUsize = AtomicUsize::new(0); +static OPT_CALLS: AtomicUsize = AtomicUsize::new(0); +static STRING_CALLS: AtomicUsize = AtomicUsize::new(0); + +#[cached] +fn ref_str_len(s: &str) -> usize { + STR_CALLS.fetch_add(1, Ordering::SeqCst); + s.len() +} + +#[cached] +fn opt_ref_str_len(o: Option<&str>) -> usize { + OPT_CALLS.fetch_add(1, Ordering::SeqCst); + o.map_or(0, |s| s.len()) +} + +// `&String` is intentional here: this exercises the macro's `&T` default-key +// handling (#202/#203), not idiomatic API design (see the file-level +// `allow(clippy::ptr_arg)`). +#[cached] +fn ref_string_len(s: &String) -> usize { + STRING_CALLS.fetch_add(1, Ordering::SeqCst); + s.len() +} + +// Note: `STR_CALLS`/`OPT_CALLS`/`STRING_CALLS` and their cache statics are owned +// exclusively by this test. The counters are reset below; the underlying caches +// cannot be reset from here (function-local or module-static), so the assertions +// remain valid only on first call per entry (which holds since this test is the +// sole caller of these functions). +#[test] +fn reference_inputs_default_key() { + // Reset counters so the assertions are independent of execution order. + STR_CALLS.store(0, Ordering::SeqCst); + OPT_CALLS.store(0, Ordering::SeqCst); + STRING_CALLS.store(0, Ordering::SeqCst); + + assert_eq!(ref_str_len("hello"), 5); + assert_eq!(ref_str_len("hello"), 5); + assert_eq!( + STR_CALLS.load(Ordering::SeqCst), + 1, + "second call should hit cache" + ); + assert_eq!(ref_str_len("hi"), 2); + assert_eq!(STR_CALLS.load(Ordering::SeqCst), 2); + + assert_eq!(opt_ref_str_len(Some("hello")), 5); + assert_eq!(opt_ref_str_len(Some("hello")), 5); + assert_eq!(OPT_CALLS.load(Ordering::SeqCst), 1); + assert_eq!(opt_ref_str_len(None), 0); + assert_eq!(opt_ref_str_len(None), 0); + assert_eq!(OPT_CALLS.load(Ordering::SeqCst), 2); + + let owned = String::from("world!"); + assert_eq!(ref_string_len(&owned), 6); + assert_eq!(ref_string_len(&owned), 6); + assert_eq!(STRING_CALLS.load(Ordering::SeqCst), 1); +} + +// ── (#146): force_refresh bypasses the cache on demand ───────────────────── + +static FORCE_CALLS: AtomicUsize = AtomicUsize::new(0); +// The returned value tracks an externally controllable source so the test can +// distinguish "served the stale cached value" from "recomputed + overwrote". +static FORCE_SOURCE: AtomicUsize = AtomicUsize::new(1); + +// `bypass` is excluded from the cache key via `key`/`convert` so the same entry +// is hit/refreshed regardless of the flag. +#[cached(key = "i32", convert = "{ x }", force_refresh = "{ bypass }")] +fn force_refresh_fn(x: i32, bypass: bool) -> usize { + let _ = bypass; // used by the generated force_refresh guard, not the body + FORCE_CALLS.fetch_add(1, Ordering::SeqCst); + x as usize + FORCE_SOURCE.load(Ordering::SeqCst) +} + +#[test] +fn force_refresh_bypasses_cache() { + // `FORCE_CALLS`/`force_refresh_fn` are exclusive to this test, but reset the + // counter so the absolute assertions do not depend on its initial value. + FORCE_CALLS.store(0, Ordering::SeqCst); + FORCE_SOURCE.store(1, Ordering::SeqCst); + let first = force_refresh_fn(1, false); // miss → 1 + 1 = 2 + assert_eq!(first, 2); + assert_eq!(FORCE_CALLS.load(Ordering::SeqCst), 1); + // bypass=false: cached hit, no recompute even though the source changed. + FORCE_SOURCE.store(100, Ordering::SeqCst); + let hit = force_refresh_fn(1, false); + assert_eq!(hit, 2, "served the stale cached value"); + assert_eq!(FORCE_CALLS.load(Ordering::SeqCst), 1); + // bypass=true: recompute + overwrite even though the key is cached. + let refreshed = force_refresh_fn(1, true); + assert_eq!(refreshed, 101, "recomputed against the new source"); + assert_eq!(FORCE_CALLS.load(Ordering::SeqCst), 2); + // After the overwrite, a non-bypass call serves the refreshed value. + let after = force_refresh_fn(1, false); + assert_eq!(after, 101, "force_refresh overwrote the cache entry"); + assert_eq!(FORCE_CALLS.load(Ordering::SeqCst), 2); +} + +static FORCE_CONC_CALLS: AtomicUsize = AtomicUsize::new(0); +static FORCE_CONC_SOURCE: AtomicUsize = AtomicUsize::new(1); + +#[concurrent_cached(key = "i32", convert = "{ x }", force_refresh = "{ bypass }")] +fn force_refresh_concurrent(x: i32, bypass: bool) -> usize { + let _ = bypass; // used by the generated force_refresh guard, not the body + FORCE_CONC_CALLS.fetch_add(1, Ordering::SeqCst); + x as usize + FORCE_CONC_SOURCE.load(Ordering::SeqCst) +} + +#[test] +fn force_refresh_concurrent_bypasses_cache() { + // Exclusive statics; reset the counter so the absolute assertions do not + // depend on its initial value. + FORCE_CONC_CALLS.store(0, Ordering::SeqCst); + FORCE_CONC_SOURCE.store(1, Ordering::SeqCst); + let first = force_refresh_concurrent(2, false); // 2 + 1 = 3 + assert_eq!(first, 3); + assert_eq!(FORCE_CONC_CALLS.load(Ordering::SeqCst), 1); + FORCE_CONC_SOURCE.store(100, Ordering::SeqCst); + let hit = force_refresh_concurrent(2, false); + assert_eq!(hit, 3, "served the stale cached value"); + assert_eq!(FORCE_CONC_CALLS.load(Ordering::SeqCst), 1); + let refreshed = force_refresh_concurrent(2, true); + assert_eq!(refreshed, 102, "recomputed against the new source"); + assert_eq!(FORCE_CONC_CALLS.load(Ordering::SeqCst), 2); + let after = force_refresh_concurrent(2, false); + assert_eq!(after, 102, "force_refresh overwrote the cache entry"); + assert_eq!(FORCE_CONC_CALLS.load(Ordering::SeqCst), 2); +} + +// force_refresh as an arbitrary expression over an existing argument (no dedicated +// flag, default key): recompute whenever the predicate over the args holds. This is +// the canonical form: the block is evaluated, it does not introduce a bool param. +static FORCE_EXPR_CALLS: AtomicUsize = AtomicUsize::new(0); + +#[cached(force_refresh = "{ x == 0 }")] +fn force_refresh_expr(x: i32) -> usize { + FORCE_EXPR_CALLS.fetch_add(1, Ordering::SeqCst); + x as usize +} + +#[test] +fn force_refresh_expression_over_args() { + FORCE_EXPR_CALLS.store(0, Ordering::SeqCst); + // x == 0: predicate true, so every call bypasses the cache and recomputes. + let _ = force_refresh_expr(0); + let _ = force_refresh_expr(0); + assert_eq!( + FORCE_EXPR_CALLS.load(Ordering::SeqCst), + 2, + "x==0 bypasses every call" + ); + // x != 0: predicate false, normal caching (one compute, then hits). + let _ = force_refresh_expr(5); + let _ = force_refresh_expr(5); + assert_eq!( + FORCE_EXPR_CALLS.load(Ordering::SeqCst), + 3, + "x!=0 served from cache" + ); +} + +// Documents WHY a dedicated flag must be excluded from the key (see the +// `force_refresh` attribute docs). With the DEFAULT key the flag is part of the +// key, so a `refresh = true` call recomputes into the `(x, true)` entry while +// ordinary `refresh = false` calls read the `(x, false)` entry and never see the +// refreshed value. The `force_refresh_*` tests above use `key`/`convert` to +// exclude the flag, which is the correct pattern. +static FOOTGUN_CALLS: AtomicUsize = AtomicUsize::new(0); +static FOOTGUN_SOURCE: AtomicUsize = AtomicUsize::new(1); + +#[cached(force_refresh = "{ refresh }")] +fn force_refresh_default_key(x: i32, refresh: bool) -> usize { + let _ = refresh; + FOOTGUN_CALLS.fetch_add(1, Ordering::SeqCst); + x as usize + FOOTGUN_SOURCE.load(Ordering::SeqCst) +} + +#[test] +fn force_refresh_default_key_does_not_update_normal_slot() { + FOOTGUN_CALLS.store(0, Ordering::SeqCst); + FOOTGUN_SOURCE.store(1, Ordering::SeqCst); + assert_eq!(force_refresh_default_key(1, false), 2); // miss: stores (1,false)=2, body runs (count=1) + FOOTGUN_SOURCE.store(100, Ordering::SeqCst); + // refresh=true recomputes the fresh value (101) but stores it under (1,true), body runs (count=2). + assert_eq!(force_refresh_default_key(1, true), 101); + // A normal refresh=false call still reads the stale (1,false) entry; body does NOT run (count stays 2). + assert_eq!( + force_refresh_default_key(1, false), + 2, + "default key: forced refresh writes a separate (x,true) slot, not seen here" + ); + assert_eq!( + FOOTGUN_CALLS.load(Ordering::SeqCst), + 2, + "body ran exactly twice: once for the initial miss and once for the force-refresh" + ); +} + +// ── force_refresh on #[once] ─────────────────────────────────────────────── +// `#[once]` stores one value for all callers. `force_refresh` bypasses that single +// value and recomputes/overwrites it. Unlike `#[cached]` there is no key, so there +// is no "(x, true)" slot footgun: the refreshed value is what every later call sees. + +static ONCE_FR_CALLS: AtomicUsize = AtomicUsize::new(0); +// Separate source so the cached return value is independent of the call counter; +// this lets us reset ONCE_FR_CALLS without coupling the counter to the cached value. +static ONCE_FR_SOURCE: AtomicUsize = AtomicUsize::new(10); + +// NOTE: this fn must remain call-exclusive to `once_force_refresh_recomputes_shared_value`. +#[once(force_refresh = "{ bypass }")] +fn once_force_refresh(bypass: bool) -> usize { + let _ = bypass; // used by the generated force_refresh guard, not the body + ONCE_FR_CALLS.fetch_add(1, Ordering::SeqCst); + ONCE_FR_SOURCE.load(Ordering::SeqCst) +} + +// Note: `ONCE_FR_CALLS` is reset below; the underlying `#[once]` cache static +// cannot be reset from here (function-local), so the test is the sole caller of +// `once_force_refresh`. +#[test] +fn once_force_refresh_recomputes_shared_value() { + ONCE_FR_CALLS.store(0, Ordering::SeqCst); + ONCE_FR_SOURCE.store(10, Ordering::SeqCst); + + // First call computes and caches the single shared value. + let first = once_force_refresh(false); + assert_eq!(first, 10); + assert_eq!(ONCE_FR_CALLS.load(Ordering::SeqCst), 1); + + // Non-bypass hit: cached value returned, body not re-run. + ONCE_FR_SOURCE.store(99, Ordering::SeqCst); // would change value if body ran + let hit = once_force_refresh(false); + assert_eq!(hit, first, "cached hit, body not re-run"); + assert_eq!(ONCE_FR_CALLS.load(Ordering::SeqCst), 1); + + // Bypass: recompute and overwrite the single shared value. + let refreshed = once_force_refresh(true); + assert_eq!(ONCE_FR_CALLS.load(Ordering::SeqCst), 2); + assert_eq!( + refreshed, 99, + "force_refresh recomputed against the new source" + ); + assert_ne!(refreshed, first, "force_refresh produced a new value"); + + // Subsequent non-bypass call serves the refreshed (overwritten) value — no + // separate keyed slot, unlike the `#[cached]` default-key footgun above. + let after = once_force_refresh(false); + assert_eq!(after, refreshed, "later calls see the overwritten value"); + assert_eq!(ONCE_FR_CALLS.load(Ordering::SeqCst), 2); +} + +// ── force_refresh + result_fallback compose ─────────────────────────────── +// `result_fallback` keeps the prior `Ok` and serves it when a refresh returns +// `Err`; `force_refresh` decides when to bypass the cached value and re-run the +// body. Together: an `Err` recompute falls back to the last `Ok`, an `Ok` +// recompute overwrites. Requires a `CloneCached` store, so this uses `ttl` +// (gated on `time_stores`). + +#[cfg(feature = "time_stores")] +mod force_refresh_result_fallback { + use super::*; + + static FB_CALLS: AtomicUsize = AtomicUsize::new(0); + // 0 => return Err; non-zero => return Ok(value). + static FB_SOURCE: AtomicUsize = AtomicUsize::new(0); + + // `bypass` is excluded from the key so the same entry is hit/bypassed. + // A long `ttl` keeps entries fresh; bypass (not expiry) drives recompute. + #[cached( + key = "i32", + convert = "{ x }", + ttl_secs = 600, + result_fallback = true, + force_refresh = "{ bypass }" + )] + fn fb_fn(x: i32, bypass: bool) -> Result { + let _ = bypass; // used by the generated force_refresh guard, not the body + FB_CALLS.fetch_add(1, Ordering::SeqCst); + match FB_SOURCE.load(Ordering::SeqCst) { + 0 => Err(()), + v => Ok(x as usize + v), + } + } + + #[test] + fn err_falls_back_force_refresh_recomputes_on_ok() { + FB_CALLS.store(0, Ordering::SeqCst); + // First call: Ok(10), cached. + FB_SOURCE.store(10, Ordering::SeqCst); + assert_eq!(fb_fn(1, false), Ok(11)); + assert_eq!(FB_CALLS.load(Ordering::SeqCst), 1); + + // Non-bypass hit: served from cache, body not re-run. + FB_SOURCE.store(0, Ordering::SeqCst); // would Err if run + assert_eq!(fb_fn(1, false), Ok(11), "cached hit, body not re-run"); + assert_eq!(FB_CALLS.load(Ordering::SeqCst), 1); + + // Bypass with the source returning Err: body runs, falls back to last Ok. + assert_eq!(fb_fn(1, true), Ok(11), "Err refresh falls back to last Ok"); + assert_eq!(FB_CALLS.load(Ordering::SeqCst), 2); + + // Bypass with the source returning Ok: body runs and overwrites. + FB_SOURCE.store(50, Ordering::SeqCst); + assert_eq!(fb_fn(1, true), Ok(51), "Ok refresh recomputes + overwrites"); + assert_eq!(FB_CALLS.load(Ordering::SeqCst), 3); + + // Non-bypass call now serves the refreshed value. + FB_SOURCE.store(0, Ordering::SeqCst); + assert_eq!(fb_fn(1, false), Ok(51), "serves the overwritten value"); + assert_eq!(FB_CALLS.load(Ordering::SeqCst), 3); + } + + // (#146 / FIX 3b): a force_refresh bypass on the `result_fallback` path must not + // have read side effects on the bypassed entry. `result_fallback` captures the + // prior `Ok` via the renewing `cache_get_with_expiry_status` only for the genuine + // early-return; on a bypass it uses the non-renewing `CachedPeek::cache_peek`. + // The deterministic signal: a live hit through the renewing read increments the + // store's hits counter, while `cache_peek` does not. So after a force_refresh + // bypass the hits counter must stay 0. (Pre-fix it incremented on every bypass.) + static FRSE_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[cached( + name = "FRSE_CACHE", + key = "i32", + convert = "{ x }", + ttl_secs = 600, + result_fallback = true, + force_refresh = "{ bypass }" + )] + fn frse_fn(x: i32, bypass: bool) -> Result { + let _ = bypass; // consumed by the generated force_refresh guard, not the body + FRSE_CALLS.fetch_add(1, Ordering::SeqCst); + Ok(x as usize + 1) + } + + #[test] + fn force_refresh_bypass_has_no_read_side_effects() { + use cached::Cached; + + FRSE_CALLS.store(0, Ordering::SeqCst); + // Seed the entry (a miss, then it is set). No hit yet. + assert_eq!(frse_fn(1, false), Ok(2)); + assert_eq!(FRSE_CALLS.load(Ordering::SeqCst), 1); + assert_eq!( + FRSE_CACHE.read().cache_hits(), + Some(0), + "seeding the entry is a miss + set, not a hit" + ); + + // Bypass the cached entry several times. Each bypass recomputes the body. + // The bypassed entry must NOT be read through the renewing path, so the + // hits counter must remain 0. + assert_eq!(frse_fn(1, true), Ok(2)); + assert_eq!(frse_fn(1, true), Ok(2)); + assert_eq!(frse_fn(1, true), Ok(2)); + assert_eq!( + FRSE_CALLS.load(Ordering::SeqCst), + 4, + "each bypass recomputes" + ); + assert_eq!( + FRSE_CACHE.read().cache_hits(), + Some(0), + "force_refresh bypass must not hit-count the bypassed entry (#146)" + ); + + // A genuine (non-bypass) hit still counts: this confirms the renewing read + // path is intact for the early-return case. + assert_eq!(frse_fn(1, false), Ok(2)); + assert_eq!( + FRSE_CALLS.load(Ordering::SeqCst), + 4, + "non-bypass served from cache" + ); + assert_eq!( + FRSE_CACHE.read().cache_hits(), + Some(1), + "a real early-return hit increments the counter" + ); + } + + static CFB_CALLS: AtomicUsize = AtomicUsize::new(0); + static CFB_SOURCE: AtomicUsize = AtomicUsize::new(0); + + // `#[concurrent_cached]` folds the `result_fallback` lookup into a different + // code path than `#[cached]` (via `ConcurrentCloneCached` inside the set + // block), so the `result_fallback` + `force_refresh` composition needs its + // own coverage on this macro. + #[concurrent_cached( + key = "i32", + convert = "{ x }", + ttl_secs = 600, + result_fallback = true, + force_refresh = "{ bypass }" + )] + fn cfb_fn(x: i32, bypass: bool) -> Result { + let _ = bypass; // used by the generated force_refresh guard, not the body + CFB_CALLS.fetch_add(1, Ordering::SeqCst); + match CFB_SOURCE.load(Ordering::SeqCst) { + 0 => Err(()), + v => Ok(x as usize + v), + } + } + + #[test] + fn concurrent_err_falls_back_force_refresh_recomputes_on_ok() { + CFB_CALLS.store(0, Ordering::SeqCst); + // First call: Ok(10), cached. + CFB_SOURCE.store(10, Ordering::SeqCst); + assert_eq!(cfb_fn(1, false), Ok(11)); + assert_eq!(CFB_CALLS.load(Ordering::SeqCst), 1); + + // Non-bypass hit: served from cache, body not re-run. + CFB_SOURCE.store(0, Ordering::SeqCst); // would Err if run + assert_eq!(cfb_fn(1, false), Ok(11), "cached hit, body not re-run"); + assert_eq!(CFB_CALLS.load(Ordering::SeqCst), 1); + + // Bypass with the source returning Err: body runs, falls back to last Ok. + assert_eq!(cfb_fn(1, true), Ok(11), "Err refresh falls back to last Ok"); + assert_eq!(CFB_CALLS.load(Ordering::SeqCst), 2); + + // Bypass with the source returning Ok: body runs and overwrites. + CFB_SOURCE.store(50, Ordering::SeqCst); + assert_eq!( + cfb_fn(1, true), + Ok(51), + "Ok refresh recomputes + overwrites" + ); + assert_eq!(CFB_CALLS.load(Ordering::SeqCst), 3); + + // Non-bypass call now serves the refreshed value. + CFB_SOURCE.store(0, Ordering::SeqCst); + assert_eq!(cfb_fn(1, false), Ok(51), "serves the overwritten value"); + assert_eq!(CFB_CALLS.load(Ordering::SeqCst), 3); + } + + // (#146): the `#[concurrent_cached]` analogue of + // `force_refresh_bypass_has_no_read_side_effects`. A force_refresh bypass on the + // `result_fallback` path must not read the bypassed entry through the renewing + // `cache_get_with_expiry_status` (which would increment the sharded store's hits + // counter); it must use the non-renewing `cache_peek_with_expiry_status` instead. + // The deterministic signal is the underlying `ShardedTtlCache`'s hits metric: + // it must stay 0 across bypass calls and increment only on a genuine non-bypass hit. + // (Pre-fix: pointing the bypass read back at the renewing `cache_get_with_expiry_status` + // makes the hits counter climb on every bypass, so this test fails.) + static CFRSE_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[concurrent_cached( + name = "CFRSE_CACHE", + key = "i32", + convert = "{ x }", + ttl_secs = 600, + result_fallback = true, + force_refresh = "{ bypass }" + )] + fn cfrse_fn(x: i32, bypass: bool) -> Result { + let _ = bypass; // consumed by the generated force_refresh guard, not the body + CFRSE_CALLS.fetch_add(1, Ordering::SeqCst); + Ok(x as usize + 1) + } + + #[test] + fn concurrent_force_refresh_bypass_has_no_read_side_effects() { + CFRSE_CALLS.store(0, Ordering::SeqCst); + // Seed the entry (a miss, then it is set). No hit yet. + assert_eq!(cfrse_fn(1, false), Ok(2)); + assert_eq!(CFRSE_CALLS.load(Ordering::SeqCst), 1); + assert_eq!( + CFRSE_CACHE.metrics().hits, + Some(0), + "seeding the entry is a miss + set, not a hit" + ); + + // Bypass the cached entry several times. Each bypass recomputes the body and + // must read the stale fallback via the non-renewing peek, leaving hits at 0. + assert_eq!(cfrse_fn(1, true), Ok(2)); + assert_eq!(cfrse_fn(1, true), Ok(2)); + assert_eq!(cfrse_fn(1, true), Ok(2)); + assert_eq!( + CFRSE_CALLS.load(Ordering::SeqCst), + 4, + "each bypass recomputes" + ); + assert_eq!( + CFRSE_CACHE.metrics().hits, + Some(0), + "force_refresh bypass must not hit-count the bypassed entry (#146)" + ); + + // A genuine (non-bypass) hit still counts: this confirms the renewing read + // path is intact for the early-return case. + assert_eq!(cfrse_fn(1, false), Ok(2)); + assert_eq!( + CFRSE_CALLS.load(Ordering::SeqCst), + 4, + "non-bypass served from cache" + ); + assert_eq!( + CFRSE_CACHE.metrics().hits, + Some(1), + "a real early-return hit increments the counter" + ); + } + + // ── result_fallback + force_refresh on an in_impl method ────────────────── + // Combines the function-local-static in_impl path with `result_fallback`: an + // `Err` refresh falls back to the last `Ok`, an `Ok` refresh overwrites. This + // mirrors `err_falls_back_force_refresh_recomputes_on_ok` but on a `self`-method. + + struct ImplFallback; + + static IMPL_FB_CALLS: AtomicUsize = AtomicUsize::new(0); + static IMPL_FB_SOURCE: AtomicUsize = AtomicUsize::new(0); + + impl ImplFallback { + #[cached( + in_impl = true, + key = "i32", + convert = "{ x }", + ttl_secs = 600, + result_fallback = true, + force_refresh = "{ bypass }" + )] + fn fb_method(&self, x: i32, bypass: bool) -> Result { + let _ = bypass; // consumed by the generated force_refresh guard + IMPL_FB_CALLS.fetch_add(1, Ordering::SeqCst); + match IMPL_FB_SOURCE.load(Ordering::SeqCst) { + 0 => Err(()), + v => Ok(x as usize + v), + } + } + } + + #[test] + fn in_impl_err_falls_back_force_refresh_recomputes_on_ok() { + IMPL_FB_CALLS.store(0, Ordering::SeqCst); + let s = ImplFallback; + // First call: Ok(10), cached. + IMPL_FB_SOURCE.store(10, Ordering::SeqCst); + assert_eq!(s.fb_method(1, false), Ok(11)); + assert_eq!(IMPL_FB_CALLS.load(Ordering::SeqCst), 1); + + // Non-bypass hit: served from cache, body not re-run. + IMPL_FB_SOURCE.store(0, Ordering::SeqCst); // would Err if run + assert_eq!(s.fb_method(1, false), Ok(11), "cached hit, body not re-run"); + assert_eq!(IMPL_FB_CALLS.load(Ordering::SeqCst), 1); + + // Bypass with the source returning Err: body runs, falls back to last Ok. + assert_eq!( + s.fb_method(1, true), + Ok(11), + "Err refresh falls back to last Ok" + ); + assert_eq!(IMPL_FB_CALLS.load(Ordering::SeqCst), 2); + + // Bypass with the source returning Ok: body runs and overwrites. + IMPL_FB_SOURCE.store(50, Ordering::SeqCst); + assert_eq!( + s.fb_method(1, true), + Ok(51), + "Ok refresh recomputes + overwrites" + ); + assert_eq!(IMPL_FB_CALLS.load(Ordering::SeqCst), 3); + + // Non-bypass call now serves the refreshed value. + IMPL_FB_SOURCE.store(0, Ordering::SeqCst); + assert_eq!( + s.fb_method(1, false), + Ok(51), + "serves the overwritten value" + ); + assert_eq!(IMPL_FB_CALLS.load(Ordering::SeqCst), 3); + } + + // ── result_fallback + force_refresh with the ttl_millis duration form ───── + // Same composition as the `ttl_secs = 600` tests above, but the TTL is expressed via + // `ttl_millis`. This proves the millis duration threads into the TTL store on the + // fallback path: if the duration were dropped the store would have no TTL and + // `result_fallback` (which needs a CloneCached TTL store) would be a no-op. + + static FB_MILLIS_CALLS: AtomicUsize = AtomicUsize::new(0); + static FB_MILLIS_SOURCE: AtomicUsize = AtomicUsize::new(0); + + #[cached( + key = "i32", + convert = "{ x }", + ttl_millis = 600_000, + result_fallback = true, + force_refresh = "{ bypass }" + )] + fn fb_millis_fn(x: i32, bypass: bool) -> Result { + let _ = bypass; // used by the generated force_refresh guard, not the body + FB_MILLIS_CALLS.fetch_add(1, Ordering::SeqCst); + match FB_MILLIS_SOURCE.load(Ordering::SeqCst) { + 0 => Err(()), + v => Ok(x as usize + v), + } + } + + #[test] + fn err_falls_back_force_refresh_recomputes_on_ok_ttl_millis() { + FB_MILLIS_CALLS.store(0, Ordering::SeqCst); + // First call: Ok(10), cached. + FB_MILLIS_SOURCE.store(10, Ordering::SeqCst); + assert_eq!(fb_millis_fn(1, false), Ok(11)); + assert_eq!(FB_MILLIS_CALLS.load(Ordering::SeqCst), 1); + + // Non-bypass hit: served from cache, body not re-run. + FB_MILLIS_SOURCE.store(0, Ordering::SeqCst); // would Err if run + assert_eq!( + fb_millis_fn(1, false), + Ok(11), + "cached hit, body not re-run" + ); + assert_eq!(FB_MILLIS_CALLS.load(Ordering::SeqCst), 1); + + // Bypass with the source returning Err: body runs, falls back to last Ok. + assert_eq!( + fb_millis_fn(1, true), + Ok(11), + "Err refresh falls back to last Ok" + ); + assert_eq!(FB_MILLIS_CALLS.load(Ordering::SeqCst), 2); + + // Bypass with the source returning Ok: body runs and overwrites. + FB_MILLIS_SOURCE.store(50, Ordering::SeqCst); + assert_eq!( + fb_millis_fn(1, true), + Ok(51), + "Ok refresh recomputes + overwrites" + ); + assert_eq!(FB_MILLIS_CALLS.load(Ordering::SeqCst), 3); + + // Non-bypass call now serves the refreshed value. + FB_MILLIS_SOURCE.store(0, Ordering::SeqCst); + assert_eq!( + fb_millis_fn(1, false), + Ok(51), + "serves the overwritten value" + ); + assert_eq!(FB_MILLIS_CALLS.load(Ordering::SeqCst), 3); + } + + static CFB_MILLIS_CALLS: AtomicUsize = AtomicUsize::new(0); + static CFB_MILLIS_SOURCE: AtomicUsize = AtomicUsize::new(0); + + // The `#[concurrent_cached]` analogue: confirms the millis duration also threads + // into the sharded TTL store on the concurrent fallback path. + #[concurrent_cached( + key = "i32", + convert = "{ x }", + ttl_millis = 600_000, + result_fallback = true, + force_refresh = "{ bypass }" + )] + fn cfb_millis_fn(x: i32, bypass: bool) -> Result { + let _ = bypass; // used by the generated force_refresh guard, not the body + CFB_MILLIS_CALLS.fetch_add(1, Ordering::SeqCst); + match CFB_MILLIS_SOURCE.load(Ordering::SeqCst) { + 0 => Err(()), + v => Ok(x as usize + v), + } + } + + #[test] + fn concurrent_err_falls_back_force_refresh_recomputes_on_ok_ttl_millis() { + CFB_MILLIS_CALLS.store(0, Ordering::SeqCst); + // First call: Ok(10), cached. + CFB_MILLIS_SOURCE.store(10, Ordering::SeqCst); + assert_eq!(cfb_millis_fn(1, false), Ok(11)); + assert_eq!(CFB_MILLIS_CALLS.load(Ordering::SeqCst), 1); + + // Non-bypass hit: served from cache, body not re-run. + CFB_MILLIS_SOURCE.store(0, Ordering::SeqCst); // would Err if run + assert_eq!( + cfb_millis_fn(1, false), + Ok(11), + "cached hit, body not re-run" + ); + assert_eq!(CFB_MILLIS_CALLS.load(Ordering::SeqCst), 1); + + // Bypass with the source returning Err: body runs, falls back to last Ok. + assert_eq!( + cfb_millis_fn(1, true), + Ok(11), + "Err refresh falls back to last Ok" + ); + assert_eq!(CFB_MILLIS_CALLS.load(Ordering::SeqCst), 2); + + // Bypass with the source returning Ok: body runs and overwrites. + CFB_MILLIS_SOURCE.store(50, Ordering::SeqCst); + assert_eq!( + cfb_millis_fn(1, true), + Ok(51), + "Ok refresh recomputes + overwrites" + ); + assert_eq!(CFB_MILLIS_CALLS.load(Ordering::SeqCst), 3); + + // Non-bypass call now serves the refreshed value. + CFB_MILLIS_SOURCE.store(0, Ordering::SeqCst); + assert_eq!( + cfb_millis_fn(1, false), + Ok(51), + "serves the overwritten value" + ); + assert_eq!(CFB_MILLIS_CALLS.load(Ordering::SeqCst), 3); + } +} + +// ── (#16/#140): in_impl caches a self-method ─────────────────────────────── + +static COMPUTE_CALLS: AtomicUsize = AtomicUsize::new(0); + +struct Calculator { + base: i32, +} + +impl Calculator { + #[cached(in_impl = true)] + fn compute(&self, k: i32) -> i32 { + COMPUTE_CALLS.fetch_add(1, Ordering::SeqCst); + k * 2 + } +} + +// Note: `COMPUTE_CALLS` and the in_impl cache static are owned exclusively by +// this test; the assertions depend on a fresh (empty) cache, which cannot be +// reset from here, so the test is left as-is. +#[test] +fn in_impl_self_method_caches() { + let c = Calculator { base: 100 }; + assert_eq!(c.compute(5), 10); + assert_eq!(c.compute(5), 10); + assert_eq!( + COMPUTE_CALLS.load(Ordering::SeqCst), + 1, + "second call should hit cache" + ); + assert_eq!(c.compute(6), 12); + assert_eq!(COMPUTE_CALLS.load(Ordering::SeqCst), 2); + // The cache is shared across instances (receiver is not part of the key). + let other = Calculator { base: 0 }; + assert_eq!(other.compute(5), 10); + assert_eq!( + COMPUTE_CALLS.load(Ordering::SeqCst), + 2, + "shared cache: still a hit" + ); + let _ = c.base + other.base; // silence dead-code on `base` +} + +// in_impl also works for `#[concurrent_cached]` and `#[once]` (sibling-method +// codegen): smoke-test that they compile and cache. + +static CONC_METHOD_CALLS: AtomicUsize = AtomicUsize::new(0); +static ONCE_METHOD_CALLS: AtomicUsize = AtomicUsize::new(0); + +struct Svc; + +impl Svc { + #[concurrent_cached(in_impl = true)] + fn conc_method(&self, k: i32) -> i32 { + CONC_METHOD_CALLS.fetch_add(1, Ordering::SeqCst); + k + 1 + } + + #[once(in_impl = true)] + fn once_method(&self, k: i32) -> i32 { + ONCE_METHOD_CALLS.fetch_add(1, Ordering::SeqCst); + k + } +} + +// Note: `CONC_METHOD_CALLS`/`ONCE_METHOD_CALLS` are reset below; the underlying +// in_impl cache statics are function-local and cannot be reset from here, so this +// test must remain the sole caller of `conc_method`/`once_method`. +#[test] +fn in_impl_concurrent_and_once_methods() { + // Reset counters so the assertions do not depend on execution order. + CONC_METHOD_CALLS.store(0, Ordering::SeqCst); + ONCE_METHOD_CALLS.store(0, Ordering::SeqCst); + + let s = Svc; + assert_eq!(s.conc_method(5), 6); + assert_eq!(s.conc_method(5), 6); + assert_eq!(CONC_METHOD_CALLS.load(Ordering::SeqCst), 1); + + assert_eq!(s.once_method(3), 3); + assert_eq!(s.once_method(9), 3); // once: single value shared + assert_eq!(ONCE_METHOD_CALLS.load(Ordering::SeqCst), 1); +} + +// ── (#146 + #16/#140): force_refresh composes with in_impl ───────────────── +// The force_refresh guard is emitted inside the in_impl method body, so it must +// reference the method's own arguments rather than a free-function ident. Drive +// a keyed force_refresh on a `self`-method: a bypass call recomputes and +// overwrites the shared entry, and the next normal call reads the new value. + +struct Refresher; + +static IN_IMPL_FR_CALLS: AtomicUsize = AtomicUsize::new(0); +static IN_IMPL_FR_SOURCE: AtomicUsize = AtomicUsize::new(1); + +impl Refresher { + #[cached( + in_impl = true, + key = "i32", + convert = "{ k }", + force_refresh = "{ bypass }" + )] + fn load(&self, k: i32, bypass: bool) -> usize { + IN_IMPL_FR_CALLS.fetch_add(1, Ordering::SeqCst); + let _ = bypass; // consumed by the generated guard, not the body + (k as usize) + IN_IMPL_FR_SOURCE.load(Ordering::SeqCst) + } +} + +// Note: `IN_IMPL_FR_CALLS` is reset below; the in_impl cache is function-local +// and cannot be reset from here, so this test must remain the sole caller of `load`. +#[test] +fn force_refresh_composes_with_in_impl() { + // Reset the call counter (and source) so assertions are order-independent. + IN_IMPL_FR_CALLS.store(0, Ordering::SeqCst); + IN_IMPL_FR_SOURCE.store(1, Ordering::SeqCst); + + let r = Refresher; + // miss → 1 + 1 = 2, cached under key 1 + assert_eq!(r.load(1, false), 2); + assert_eq!(IN_IMPL_FR_CALLS.load(Ordering::SeqCst), 1); + // hit: body not re-run + assert_eq!(r.load(1, false), 2); + assert_eq!(IN_IMPL_FR_CALLS.load(Ordering::SeqCst), 1); + // bump the source, then force a refresh: body re-runs and overwrites key 1 + IN_IMPL_FR_SOURCE.store(100, Ordering::SeqCst); + assert_eq!(r.load(1, true), 101); + assert_eq!(IN_IMPL_FR_CALLS.load(Ordering::SeqCst), 2); + // a subsequent normal call reads the refreshed entry (shared key, no footgun) + assert_eq!(r.load(1, false), 101); + assert_eq!(IN_IMPL_FR_CALLS.load(Ordering::SeqCst), 2); +} + +// ── FIX 2a: #[cached(in_impl = true)] on a pub method ───────────────────── +// Pins that a public in_impl method compiles and actually caches (body runs +// exactly once for two same-arg calls). + +struct PubImplStruct; + +static PUB_IMPL_CALLS: AtomicUsize = AtomicUsize::new(0); + +impl PubImplStruct { + #[cached(in_impl = true)] + pub fn pub_cached_method(&self, x: i32) -> i32 { + PUB_IMPL_CALLS.fetch_add(1, Ordering::SeqCst); + x * 3 + } +} + +#[test] +fn in_impl_pub_method_caches() { + PUB_IMPL_CALLS.store(0, Ordering::SeqCst); + let s = PubImplStruct; + assert_eq!(s.pub_cached_method(4), 12); + assert_eq!(s.pub_cached_method(4), 12); // cache hit + assert_eq!( + PUB_IMPL_CALLS.load(Ordering::SeqCst), + 1, + "second call with the same arg must be a cache hit" + ); + assert_eq!(s.pub_cached_method(5), 15); // different key, miss + assert_eq!(PUB_IMPL_CALLS.load(Ordering::SeqCst), 2); +} + +// The `in_impl` macro generates a `{fn}_no_cache` sibling that bypasses the cache +// and always runs the body. Calling it after the cache is warm must increment the +// counter again, proving the body ran rather than returning the cached value. +// +// Uses its own struct/counter so it shares neither the function-local cache nor the +// call counter with `in_impl_pub_method_caches` (the two would otherwise race when +// the test harness runs them in parallel). +struct NoCacheSiblingStruct; + +static NO_CACHE_SIBLING_CALLS: AtomicUsize = AtomicUsize::new(0); + +impl NoCacheSiblingStruct { + #[cached(in_impl = true)] + pub fn cached_method(&self, x: i32) -> i32 { + NO_CACHE_SIBLING_CALLS.fetch_add(1, Ordering::SeqCst); + x * 3 + } +} + +#[test] +fn in_impl_no_cache_sibling_bypasses_cache() { + let s = NoCacheSiblingStruct; + // Warm the cache for x=7 via the normal (cached) path. + assert_eq!(s.cached_method(7), 21); + assert_eq!(NO_CACHE_SIBLING_CALLS.load(Ordering::SeqCst), 1); + // A second cached call is a hit; body does not run. + assert_eq!(s.cached_method(7), 21); + assert_eq!(NO_CACHE_SIBLING_CALLS.load(Ordering::SeqCst), 1); + // The _no_cache sibling bypasses the cache; the body runs again. + assert_eq!(s.cached_method_no_cache(7), 21); + assert_eq!( + NO_CACHE_SIBLING_CALLS.load(Ordering::SeqCst), + 2, + "_no_cache sibling must bypass the cache and run the body" + ); +} + +// ── FIX 2d: #[concurrent_cached(in_impl = true, force_refresh = "{ ... }")] ── +// Verifies that force_refresh composes with the concurrent in_impl path: a +// bypass call recomputes even when the entry is cached. + +struct ConcImplRefresher; + +static CONC_IMPL_FR_CALLS: AtomicUsize = AtomicUsize::new(0); +static CONC_IMPL_FR_SOURCE: AtomicUsize = AtomicUsize::new(1); + +impl ConcImplRefresher { + #[concurrent_cached( + in_impl = true, + key = "i32", + convert = "{ k }", + force_refresh = "{ bypass }" + )] + fn conc_impl_load(&self, k: i32, bypass: bool) -> usize { + CONC_IMPL_FR_CALLS.fetch_add(1, Ordering::SeqCst); + let _ = bypass; // consumed by the generated force_refresh guard + (k as usize) + CONC_IMPL_FR_SOURCE.load(Ordering::SeqCst) + } +} + +#[test] +fn concurrent_in_impl_force_refresh_bypasses_cache() { + CONC_IMPL_FR_CALLS.store(0, Ordering::SeqCst); + CONC_IMPL_FR_SOURCE.store(1, Ordering::SeqCst); + let r = ConcImplRefresher; + // Miss: 2 + 1 = 3, cached under key 2. + assert_eq!(r.conc_impl_load(2, false), 3); + assert_eq!(CONC_IMPL_FR_CALLS.load(Ordering::SeqCst), 1); + // Hit: body not re-run. + assert_eq!(r.conc_impl_load(2, false), 3); + assert_eq!(CONC_IMPL_FR_CALLS.load(Ordering::SeqCst), 1); + // Force-refresh: body re-runs with updated source, overwrites entry. + CONC_IMPL_FR_SOURCE.store(100, Ordering::SeqCst); + assert_eq!(r.conc_impl_load(2, true), 102); + assert_eq!(CONC_IMPL_FR_CALLS.load(Ordering::SeqCst), 2); + // Subsequent normal call reads the refreshed entry. + assert_eq!(r.conc_impl_load(2, false), 102); + assert_eq!(CONC_IMPL_FR_CALLS.load(Ordering::SeqCst), 2); +} + +// ── (#149): ttl_millis recompute (sub-second TTL) ────────────────────────── +// Gated on `time_stores` because the sub-second TTL store requires it. + +#[cfg(feature = "time_stores")] +mod ttl_millis_tests { + use super::*; + use std::thread::sleep; + use std::time::Duration; + + static MILLIS_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[cached(ttl_millis = 50)] + fn millis_fn(x: i32) -> i32 { + MILLIS_CALLS.fetch_add(1, Ordering::SeqCst); + x + } + + #[test] + fn ttl_millis_recomputes_after_expiry() { + MILLIS_CALLS.store(0, Ordering::SeqCst); + assert_eq!(millis_fn(7), 7); + assert_eq!(millis_fn(7), 7); + assert_eq!( + MILLIS_CALLS.load(Ordering::SeqCst), + 1, + "within TTL: cache hit" + ); + sleep(Duration::from_millis(70)); + assert_eq!(millis_fn(7), 7); + assert_eq!( + MILLIS_CALLS.load(Ordering::SeqCst), + 2, + "after ttl_millis expiry: recompute" + ); + } + + // ttl_millis on the `#[concurrent_cached]` default in-memory sharded path + // (ShardedTtlCache): sub-second TTL is honored exactly in memory. + static CONC_MILLIS_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[concurrent_cached(ttl_millis = 50)] + fn conc_millis_fn(x: i32) -> i32 { + CONC_MILLIS_CALLS.fetch_add(1, Ordering::SeqCst); + x + } + + #[test] + fn concurrent_ttl_millis_recomputes_after_expiry() { + CONC_MILLIS_CALLS.store(0, Ordering::SeqCst); + assert_eq!(conc_millis_fn(7), 7); + assert_eq!(conc_millis_fn(7), 7); + assert_eq!( + CONC_MILLIS_CALLS.load(Ordering::SeqCst), + 1, + "within TTL: cache hit" + ); + sleep(Duration::from_millis(70)); + assert_eq!(conc_millis_fn(7), 7); + assert_eq!( + CONC_MILLIS_CALLS.load(Ordering::SeqCst), + 2, + "after ttl_millis expiry: recompute" + ); + } + + // ttl_millis on `#[once]`: the single cached value expires sub-second and is + // recomputed on the next call (the timestamped `Option` path, not a TtlCache). + static ONCE_MILLIS_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[once(ttl_millis = 50)] + fn once_millis_fn() -> usize { + ONCE_MILLIS_CALLS.fetch_add(1, Ordering::SeqCst) + 1 + } + + #[test] + fn once_ttl_millis_recomputes_after_expiry() { + ONCE_MILLIS_CALLS.store(0, Ordering::SeqCst); + // First call computes; the second is served from the single cached value. + assert_eq!(once_millis_fn(), 1); + assert_eq!(once_millis_fn(), 1); + assert_eq!( + ONCE_MILLIS_CALLS.load(Ordering::SeqCst), + 1, + "within TTL: cache hit" + ); + sleep(Duration::from_millis(70)); + // After the sub-second TTL expires the body re-runs, yielding the next value. + assert_eq!(once_millis_fn(), 2); + assert_eq!( + ONCE_MILLIS_CALLS.load(Ordering::SeqCst), + 2, + "after ttl_millis expiry: recompute" + ); + } + + // ── FIX 2b: #[cached(in_impl = true, ttl_millis = N)] on a method ───── + // The function-local timestamped static caches within the TTL and recomputes + // after expiry, mirroring the free-function ttl_millis path but on a method. + + struct TtlImplStruct; + + static TTL_IMPL_CALLS: AtomicUsize = AtomicUsize::new(0); + + impl TtlImplStruct { + #[cached(in_impl = true, ttl_millis = 50)] + fn ttl_method(&self, x: i32) -> i32 { + TTL_IMPL_CALLS.fetch_add(1, Ordering::SeqCst); + x + } + } + + #[test] + fn in_impl_ttl_millis_caches_and_recomputes() { + TTL_IMPL_CALLS.store(0, Ordering::SeqCst); + let s = TtlImplStruct; + assert_eq!(s.ttl_method(9), 9); + assert_eq!(s.ttl_method(9), 9); + assert_eq!( + TTL_IMPL_CALLS.load(Ordering::SeqCst), + 1, + "within TTL: in_impl method must serve from cache" + ); + sleep(Duration::from_millis(70)); + assert_eq!(s.ttl_method(9), 9); + assert_eq!( + TTL_IMPL_CALLS.load(Ordering::SeqCst), + 2, + "after ttl_millis expiry: in_impl method must recompute" + ); + } + + // ── FIX 2c: #[once(ttl_millis = N, force_refresh = "{ ... }")] ──────── + // `force_refresh` bypasses the single shared value before the TTL expires. + // This is distinct from plain `#[once(ttl_millis)]` (expiry-driven recompute) + // and plain `#[once(force_refresh)]` (no TTL): here both compose. + + static ONCE_TTL_FR_CALLS: AtomicUsize = AtomicUsize::new(0); + static ONCE_TTL_FR_SOURCE: AtomicUsize = AtomicUsize::new(1); + + // Long TTL (600 s) so expiry does not drive any recompute during the test; + // only the force_refresh bypass does. + #[once(ttl_millis = 600_000, force_refresh = "{ bypass }")] + fn once_ttl_fr(bypass: bool) -> usize { + let _ = bypass; // consumed by the generated force_refresh guard + ONCE_TTL_FR_CALLS.fetch_add(1, Ordering::SeqCst); + ONCE_TTL_FR_SOURCE.load(Ordering::SeqCst) + } + + #[test] + fn once_ttl_millis_force_refresh_recomputes_before_expiry() { + ONCE_TTL_FR_CALLS.store(0, Ordering::SeqCst); + ONCE_TTL_FR_SOURCE.store(10, Ordering::SeqCst); + // Miss: body runs, single value = 10 cached. + assert_eq!(once_ttl_fr(false), 10); + assert_eq!(ONCE_TTL_FR_CALLS.load(Ordering::SeqCst), 1); + // Hit within TTL: body not re-run even though source changes. + ONCE_TTL_FR_SOURCE.store(99, Ordering::SeqCst); + assert_eq!(once_ttl_fr(false), 10, "within TTL: cached value returned"); + assert_eq!(ONCE_TTL_FR_CALLS.load(Ordering::SeqCst), 1); + // Force-refresh before TTL expiry: body re-runs and overwrites. + assert_eq!(once_ttl_fr(true), 99, "force_refresh recomputed new source"); + assert_eq!(ONCE_TTL_FR_CALLS.load(Ordering::SeqCst), 2); + // Subsequent non-bypass call serves the refreshed value. + assert_eq!(once_ttl_fr(false), 99, "later call sees overwritten value"); + assert_eq!(ONCE_TTL_FR_CALLS.load(Ordering::SeqCst), 2); + } + + // ── #[cached(ttl_millis = N, force_refresh = "{ ... }")] ───────────────── + // Covers the `#[cached]` path: force_refresh bypasses a cached entry before + // the TTL expires, recomputes the body, and overwrites the slot. A subsequent + // normal call confirms the overwritten value is served from the cache. + + static CACHED_TTL_FR_CALLS: AtomicUsize = AtomicUsize::new(0); + static CACHED_TTL_FR_SOURCE: AtomicUsize = AtomicUsize::new(1); + + // Long TTL so only force_refresh (not expiry) drives any recompute during + // the test. `bypass` is excluded from the key via `key`/`convert`. + #[cached( + key = "i32", + convert = "{ x }", + ttl_millis = 600_000, + force_refresh = "{ bypass }" + )] + fn cached_ttl_fr(x: i32, bypass: bool) -> usize { + let _ = bypass; // consumed by the generated force_refresh guard + CACHED_TTL_FR_CALLS.fetch_add(1, Ordering::SeqCst); + x as usize + CACHED_TTL_FR_SOURCE.load(Ordering::SeqCst) + } + + #[test] + fn cached_ttl_millis_force_refresh_recomputes_before_expiry() { + CACHED_TTL_FR_CALLS.store(0, Ordering::SeqCst); + CACHED_TTL_FR_SOURCE.store(1, Ordering::SeqCst); + // Miss: 3 + 1 = 4, cached. + assert_eq!(cached_ttl_fr(3, false), 4); + assert_eq!(CACHED_TTL_FR_CALLS.load(Ordering::SeqCst), 1); + // Hit within TTL: body not re-run even though source changes. + CACHED_TTL_FR_SOURCE.store(100, Ordering::SeqCst); + assert_eq!( + cached_ttl_fr(3, false), + 4, + "within TTL: cached value returned" + ); + assert_eq!(CACHED_TTL_FR_CALLS.load(Ordering::SeqCst), 1); + // Force-refresh before TTL expiry: body re-runs (3 + 100 = 103) and overwrites. + assert_eq!( + cached_ttl_fr(3, true), + 103, + "force_refresh recomputed new source" + ); + assert_eq!(CACHED_TTL_FR_CALLS.load(Ordering::SeqCst), 2); + // Subsequent non-bypass call serves the refreshed (overwritten) value. + assert_eq!( + cached_ttl_fr(3, false), + 103, + "later call sees overwritten value" + ); + assert_eq!(CACHED_TTL_FR_CALLS.load(Ordering::SeqCst), 2); + } + + // ── #[cached(max_size = N, ttl_millis = M)] (LruTtlCache path) ──────────── + // `max_size` + `ttl_millis` selects the bounded sub-second TTL store + // (LruTtlCache). The entry caches within the TTL and recomputes after expiry. + + static LRU_MILLIS_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[cached(max_size = 10, ttl_millis = 50)] + fn lru_millis_fn(x: i32) -> i32 { + LRU_MILLIS_CALLS.fetch_add(1, Ordering::SeqCst); + x + } + + #[test] + fn lru_ttl_millis_recomputes_after_expiry() { + LRU_MILLIS_CALLS.store(0, Ordering::SeqCst); + assert_eq!(lru_millis_fn(7), 7); + assert_eq!(lru_millis_fn(7), 7); + assert_eq!( + LRU_MILLIS_CALLS.load(Ordering::SeqCst), + 1, + "within TTL: cache hit" + ); + sleep(Duration::from_millis(70)); + assert_eq!(lru_millis_fn(7), 7); + assert_eq!( + LRU_MILLIS_CALLS.load(Ordering::SeqCst), + 2, + "after ttl_millis expiry: recompute" + ); + } + + // ── #[concurrent_cached(in_impl = true, ttl_millis = N)] on a method ────── + // Mirrors the `#[cached]` in_impl ttl_millis path on the concurrent macro: + // the method caches within the TTL and recomputes after expiry. + + struct ConcTtlImplStruct; + + static CONC_TTL_IMPL_CALLS: AtomicUsize = AtomicUsize::new(0); + + impl ConcTtlImplStruct { + #[concurrent_cached(in_impl = true, ttl_millis = 50)] + fn ttl_method(&self, x: i32) -> i32 { + CONC_TTL_IMPL_CALLS.fetch_add(1, Ordering::SeqCst); + x + } + } + + #[test] + fn concurrent_in_impl_ttl_millis_caches_and_recomputes() { + CONC_TTL_IMPL_CALLS.store(0, Ordering::SeqCst); + let s = ConcTtlImplStruct; + assert_eq!(s.ttl_method(9), 9); + assert_eq!(s.ttl_method(9), 9); + assert_eq!( + CONC_TTL_IMPL_CALLS.load(Ordering::SeqCst), + 1, + "within TTL: in_impl method must serve from cache" + ); + sleep(Duration::from_millis(70)); + assert_eq!(s.ttl_method(9), 9); + assert_eq!( + CONC_TTL_IMPL_CALLS.load(Ordering::SeqCst), + 2, + "after ttl_millis expiry: in_impl method must recompute" + ); + } + + // ── #[once(in_impl = true, ttl_millis = N)] on a method ─────────────────── + // The function-local timestamped single value caches within the TTL and + // recomputes after expiry, on a `self`-method. + + struct OnceTtlImplStruct; + + static ONCE_TTL_IMPL_CALLS: AtomicUsize = AtomicUsize::new(0); + + impl OnceTtlImplStruct { + #[once(in_impl = true, ttl_millis = 50)] + fn ttl_method(&self) -> usize { + ONCE_TTL_IMPL_CALLS.fetch_add(1, Ordering::SeqCst) + 1 + } + } + + #[test] + fn once_in_impl_ttl_millis_caches_and_recomputes() { + ONCE_TTL_IMPL_CALLS.store(0, Ordering::SeqCst); + let s = OnceTtlImplStruct; + // First call computes; the second is served from the single cached value. + assert_eq!(s.ttl_method(), 1); + assert_eq!(s.ttl_method(), 1); + assert_eq!( + ONCE_TTL_IMPL_CALLS.load(Ordering::SeqCst), + 1, + "within TTL: in_impl once method must serve the cached value" + ); + sleep(Duration::from_millis(70)); + // After the sub-second TTL expires the body re-runs, yielding the next value. + assert_eq!(s.ttl_method(), 2); + assert_eq!( + ONCE_TTL_IMPL_CALLS.load(Ordering::SeqCst), + 2, + "after ttl_millis expiry: in_impl once method must recompute" + ); + } + + // ── #[concurrent_cached(ttl_millis = N, force_refresh = "{ ... }")] ────── + // The concurrent analogue of `cached_ttl_millis_force_refresh_recomputes_before_expiry`: + // a long TTL keeps the entry fresh, and only the force_refresh bypass drives a + // recompute, proving bypass recomputes within the TTL window. + + static CONC_TTL_FR_CALLS: AtomicUsize = AtomicUsize::new(0); + static CONC_TTL_FR_SOURCE: AtomicUsize = AtomicUsize::new(1); + + #[concurrent_cached( + key = "i32", + convert = "{ x }", + ttl_millis = 600_000, + force_refresh = "{ bypass }" + )] + fn conc_ttl_fr(x: i32, bypass: bool) -> usize { + let _ = bypass; // consumed by the generated force_refresh guard + CONC_TTL_FR_CALLS.fetch_add(1, Ordering::SeqCst); + x as usize + CONC_TTL_FR_SOURCE.load(Ordering::SeqCst) + } + + #[test] + fn concurrent_ttl_millis_force_refresh_recomputes_before_expiry() { + CONC_TTL_FR_CALLS.store(0, Ordering::SeqCst); + CONC_TTL_FR_SOURCE.store(1, Ordering::SeqCst); + // Miss: 3 + 1 = 4, cached. + assert_eq!(conc_ttl_fr(3, false), 4); + assert_eq!(CONC_TTL_FR_CALLS.load(Ordering::SeqCst), 1); + // Hit within TTL: body not re-run even though source changes. + CONC_TTL_FR_SOURCE.store(100, Ordering::SeqCst); + assert_eq!( + conc_ttl_fr(3, false), + 4, + "within TTL: cached value returned" + ); + assert_eq!(CONC_TTL_FR_CALLS.load(Ordering::SeqCst), 1); + // Force-refresh before TTL expiry: body re-runs (3 + 100 = 103) and overwrites. + assert_eq!( + conc_ttl_fr(3, true), + 103, + "force_refresh recomputed new source" + ); + assert_eq!(CONC_TTL_FR_CALLS.load(Ordering::SeqCst), 2); + // Subsequent non-bypass call serves the refreshed (overwritten) value. + assert_eq!( + conc_ttl_fr(3, false), + 103, + "later call sees overwritten value" + ); + assert_eq!(CONC_TTL_FR_CALLS.load(Ordering::SeqCst), 2); + } + + // ── #[concurrent_cached(refresh = true, ttl_millis = N)] ───────────────── + // Behavioral smoke-test: `refresh = true` is now a plain `bool` (not + // `Option`). The cache compiles and caches correctly. `refresh = false` + // (the default) is the baseline; `refresh = true` also caches correctly and the + // store is constructed with `refresh_on_hit(true)`. We verify caching behavior + // on the plain `refresh = false` path here; the TTL-renewal side effect of + // `refresh_on_hit(true)` cannot be tested without sleeping past the TTL again. + + static REFRESH_CONC_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[concurrent_cached(ttl_millis = 600_000, refresh = true)] + fn conc_refresh_fn(x: i32) -> i32 { + REFRESH_CONC_CALLS.fetch_add(1, Ordering::SeqCst); + x + } + + #[test] + fn concurrent_cached_refresh_bool_compiles_and_caches() { + REFRESH_CONC_CALLS.store(0, Ordering::SeqCst); + // First call: miss, body runs. + assert_eq!(conc_refresh_fn(7), 7); + assert_eq!(REFRESH_CONC_CALLS.load(Ordering::SeqCst), 1); + // Second call: cache hit, body does not re-run. + assert_eq!(conc_refresh_fn(7), 7); + assert_eq!( + REFRESH_CONC_CALLS.load(Ordering::SeqCst), + 1, + "refresh = true (bool) still caches: second call must be a hit" + ); + } +} + +// ── TTL spellings: `ttl` (Duration expr), `ttl_secs`, `ttl_millis` ───────── +// The 3-way ttl API exposes the same underlying time-based TTL store through +// three attribute spellings. These tests prove each spelling actually caches a +// hit and then recomputes after the TTL expires, on every macro (in-memory +// path). `ttl` and `ttl_millis` use a sub-second duration so expiry is fast; +// `ttl_secs` uses the 1 s minimum and waits just past it. +#[cfg(feature = "time_stores")] +mod ttl_spelling_tests { + use super::*; + use std::thread::sleep; + use std::time::Duration; + + // ── `ttl = "Duration::from_millis(50)"` (the Duration-expression form) ── + static TTL_EXPR_CACHED_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[cached(ttl = "core::time::Duration::from_millis(50)")] + fn ttl_expr_cached(x: i32) -> i32 { + TTL_EXPR_CACHED_CALLS.fetch_add(1, Ordering::SeqCst); + x + } + + #[test] + fn ttl_expr_cached_recomputes_after_expiry() { + TTL_EXPR_CACHED_CALLS.store(0, Ordering::SeqCst); + assert_eq!(ttl_expr_cached(7), 7); + assert_eq!(ttl_expr_cached(7), 7); + assert_eq!( + TTL_EXPR_CACHED_CALLS.load(Ordering::SeqCst), + 1, + "within TTL: cache hit" + ); + sleep(Duration::from_millis(70)); + assert_eq!(ttl_expr_cached(7), 7); + assert_eq!( + TTL_EXPR_CACHED_CALLS.load(Ordering::SeqCst), + 2, + "after `ttl` Duration expiry: recompute" + ); + } + + static TTL_EXPR_ONCE_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[once(ttl = "core::time::Duration::from_millis(50)")] + fn ttl_expr_once() -> usize { + TTL_EXPR_ONCE_CALLS.fetch_add(1, Ordering::SeqCst) + 1 + } + + #[test] + fn ttl_expr_once_recomputes_after_expiry() { + TTL_EXPR_ONCE_CALLS.store(0, Ordering::SeqCst); + assert_eq!(ttl_expr_once(), 1); + assert_eq!(ttl_expr_once(), 1); + assert_eq!( + TTL_EXPR_ONCE_CALLS.load(Ordering::SeqCst), + 1, + "within TTL: cache hit" + ); + sleep(Duration::from_millis(70)); + assert_eq!(ttl_expr_once(), 2); + assert_eq!( + TTL_EXPR_ONCE_CALLS.load(Ordering::SeqCst), + 2, + "after `ttl` Duration expiry: recompute" + ); + } + + static TTL_EXPR_CONC_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[concurrent_cached(ttl = "core::time::Duration::from_millis(50)")] + fn ttl_expr_conc(x: i32) -> i32 { + TTL_EXPR_CONC_CALLS.fetch_add(1, Ordering::SeqCst); + x + } + + #[test] + fn ttl_expr_concurrent_recomputes_after_expiry() { + TTL_EXPR_CONC_CALLS.store(0, Ordering::SeqCst); + assert_eq!(ttl_expr_conc(7), 7); + assert_eq!(ttl_expr_conc(7), 7); + assert_eq!( + TTL_EXPR_CONC_CALLS.load(Ordering::SeqCst), + 1, + "within TTL: cache hit" + ); + sleep(Duration::from_millis(70)); + assert_eq!(ttl_expr_conc(7), 7); + assert_eq!( + TTL_EXPR_CONC_CALLS.load(Ordering::SeqCst), + 2, + "after `ttl` Duration expiry: recompute" + ); + } + + // ── `ttl_secs = 1` (whole-seconds form; 1 s is the minimum) ──────────── + static TTL_SECS_CACHED_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[cached(ttl_secs = 1)] + fn ttl_secs_cached(x: i32) -> i32 { + TTL_SECS_CACHED_CALLS.fetch_add(1, Ordering::SeqCst); + x + } + + #[test] + fn ttl_secs_cached_recomputes_after_expiry() { + TTL_SECS_CACHED_CALLS.store(0, Ordering::SeqCst); + assert_eq!(ttl_secs_cached(7), 7); + assert_eq!(ttl_secs_cached(7), 7); + assert_eq!( + TTL_SECS_CACHED_CALLS.load(Ordering::SeqCst), + 1, + "within TTL: cache hit" + ); + sleep(Duration::from_millis(1_100)); + assert_eq!(ttl_secs_cached(7), 7); + assert_eq!( + TTL_SECS_CACHED_CALLS.load(Ordering::SeqCst), + 2, + "after `ttl_secs` expiry: recompute" + ); + } + + static TTL_SECS_ONCE_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[once(ttl_secs = 1)] + fn ttl_secs_once() -> usize { + TTL_SECS_ONCE_CALLS.fetch_add(1, Ordering::SeqCst) + 1 + } + + #[test] + fn ttl_secs_once_recomputes_after_expiry() { + TTL_SECS_ONCE_CALLS.store(0, Ordering::SeqCst); + assert_eq!(ttl_secs_once(), 1); + assert_eq!(ttl_secs_once(), 1); + assert_eq!( + TTL_SECS_ONCE_CALLS.load(Ordering::SeqCst), + 1, + "within TTL: cache hit" + ); + sleep(Duration::from_millis(1_100)); + assert_eq!(ttl_secs_once(), 2); + assert_eq!( + TTL_SECS_ONCE_CALLS.load(Ordering::SeqCst), + 2, + "after `ttl_secs` expiry: recompute" + ); + } + + static TTL_SECS_CONC_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[concurrent_cached(ttl_secs = 1)] + fn ttl_secs_conc(x: i32) -> i32 { + TTL_SECS_CONC_CALLS.fetch_add(1, Ordering::SeqCst); + x + } + + #[test] + fn ttl_secs_concurrent_recomputes_after_expiry() { + TTL_SECS_CONC_CALLS.store(0, Ordering::SeqCst); + assert_eq!(ttl_secs_conc(7), 7); + assert_eq!(ttl_secs_conc(7), 7); + assert_eq!( + TTL_SECS_CONC_CALLS.load(Ordering::SeqCst), + 1, + "within TTL: cache hit" + ); + sleep(Duration::from_millis(1_100)); + assert_eq!(ttl_secs_conc(7), 7); + assert_eq!( + TTL_SECS_CONC_CALLS.load(Ordering::SeqCst), + 2, + "after `ttl_secs` expiry: recompute" + ); + } + + // ── `ttl_millis = 50` (millisecond form) on all three macros ─────────── + // (The dedicated `ttl_millis_tests` module also covers this; these mirror + // the `ttl`/`ttl_secs` cases so all three spellings sit side by side.) + static TTL_MILLIS_CACHED_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[cached(ttl_millis = 50)] + fn ttl_millis_cached(x: i32) -> i32 { + TTL_MILLIS_CACHED_CALLS.fetch_add(1, Ordering::SeqCst); + x + } + + #[test] + fn ttl_millis_cached_recomputes_after_expiry() { + TTL_MILLIS_CACHED_CALLS.store(0, Ordering::SeqCst); + assert_eq!(ttl_millis_cached(7), 7); + assert_eq!(ttl_millis_cached(7), 7); + assert_eq!( + TTL_MILLIS_CACHED_CALLS.load(Ordering::SeqCst), + 1, + "within TTL: cache hit" + ); + sleep(Duration::from_millis(70)); + assert_eq!(ttl_millis_cached(7), 7); + assert_eq!( + TTL_MILLIS_CACHED_CALLS.load(Ordering::SeqCst), + 2, + "after `ttl_millis` expiry: recompute" + ); + } + + static TTL_MILLIS_ONCE_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[once(ttl_millis = 50)] + fn ttl_millis_once() -> usize { + TTL_MILLIS_ONCE_CALLS.fetch_add(1, Ordering::SeqCst) + 1 + } + + #[test] + fn ttl_millis_once_recomputes_after_expiry() { + TTL_MILLIS_ONCE_CALLS.store(0, Ordering::SeqCst); + assert_eq!(ttl_millis_once(), 1); + assert_eq!(ttl_millis_once(), 1); + assert_eq!( + TTL_MILLIS_ONCE_CALLS.load(Ordering::SeqCst), + 1, + "within TTL: cache hit" + ); + sleep(Duration::from_millis(70)); + assert_eq!(ttl_millis_once(), 2); + assert_eq!( + TTL_MILLIS_ONCE_CALLS.load(Ordering::SeqCst), + 2, + "after `ttl_millis` expiry: recompute" + ); + } + + static TTL_MILLIS_CONC_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[concurrent_cached(ttl_millis = 50)] + fn ttl_millis_conc(x: i32) -> i32 { + TTL_MILLIS_CONC_CALLS.fetch_add(1, Ordering::SeqCst); + x + } + + #[test] + fn ttl_millis_concurrent_recomputes_after_expiry() { + TTL_MILLIS_CONC_CALLS.store(0, Ordering::SeqCst); + assert_eq!(ttl_millis_conc(7), 7); + assert_eq!(ttl_millis_conc(7), 7); + assert_eq!( + TTL_MILLIS_CONC_CALLS.load(Ordering::SeqCst), + 1, + "within TTL: cache hit" + ); + sleep(Duration::from_millis(70)); + assert_eq!(ttl_millis_conc(7), 7); + assert_eq!( + TTL_MILLIS_CONC_CALLS.load(Ordering::SeqCst), + 2, + "after `ttl_millis` expiry: recompute" + ); + } +} + +// ── FIX B: #[once(sync_writes, force_refresh)] predicate evaluated once ────── +// Before the fix, `do_set_return_block` in the `SyncWriteMode::Default` arm +// expanded the force_refresh predicate TWICE: once inside the read-lock block +// and again in the write-lock re-check. A side-effecting predicate therefore +// ran twice on every write path (cache miss or bypass call). +// +// After the fix, the predicate is hoisted into a single `__cached_force_refreshing` +// binding before both checks, so it is evaluated AT MOST ONCE per call. +// +// This test uses a predicate that always returns `false` (never force-refresh) +// and increments a counter as a side effect. On a cache miss (first call), the +// write path is taken; pre-fix the counter reaches 2, post-fix it stays at 1. + +static ONCE_SW_FR_PRED_COUNT: AtomicUsize = AtomicUsize::new(0); +static ONCE_SW_FR_BODY_COUNT: AtomicUsize = AtomicUsize::new(0); + +// NOTE: must be call-exclusive to `once_sync_writes_force_refresh_predicate_eval_count`. +// The cache static is module-global (not in_impl) and cannot be reset, so no +// other test may call this function. +#[once( + sync_writes, + force_refresh = "{ ONCE_SW_FR_PRED_COUNT.fetch_add(1, Ordering::SeqCst); false }" +)] +fn once_sync_writes_fr(x: usize) -> usize { + ONCE_SW_FR_BODY_COUNT.fetch_add(1, Ordering::SeqCst); + x +} + +#[test] +fn once_sync_writes_force_refresh_predicate_eval_count() { + ONCE_SW_FR_PRED_COUNT.store(0, Ordering::SeqCst); + ONCE_SW_FR_BODY_COUNT.store(0, Ordering::SeqCst); + + // First call: cache miss. The write path is taken. + // Pre-fix: predicate runs in the read-lock block AND in the write-lock + // re-check => ONCE_SW_FR_PRED_COUNT would be 2. + // Post-fix: predicate is hoisted into a single binding => count == 1. + let _ = once_sync_writes_fr(42); + assert_eq!( + ONCE_SW_FR_BODY_COUNT.load(Ordering::SeqCst), + 1, + "body must run exactly once on a cache miss" + ); + assert_eq!( + ONCE_SW_FR_PRED_COUNT.load(Ordering::SeqCst), + 1, + "force_refresh predicate must be evaluated EXACTLY ONCE per call, not twice (#FIX-B)" + ); + + // Second call: cache warm, force_refresh returns false => served from cache. + // The predicate runs once more (from the read-lock path). + let _ = once_sync_writes_fr(42); + assert_eq!( + ONCE_SW_FR_BODY_COUNT.load(Ordering::SeqCst), + 1, + "body must not run again on a cache hit" + ); + assert_eq!( + ONCE_SW_FR_PRED_COUNT.load(Ordering::SeqCst), + 2, + "predicate evaluated once per call (2 calls total)" + ); +} + +// ── FIX C: default-key Option<&mut T> does not move the argument ───────────── +// Before the fix, the default-key path for `Option<&mut T>` emitted +// `name.map(|__cached_v| __cached_v.to_owned())`, which MOVES `name`. The +// generated `_no_cache` call then tried to reuse `name` after the move, causing +// a compile error. +// +// After the fix, `name.as_deref().map(|__cached_v| __cached_v.to_owned())` is +// emitted. `as_deref()` takes `&self` without consuming the Option, so `name` +// remains usable. + +static OPT_MUT_REF_BODY_COUNT: AtomicUsize = AtomicUsize::new(0); + +#[cached] +fn opt_mut_ref_cached(s: Option<&mut String>) -> usize { + OPT_MUT_REF_BODY_COUNT.fetch_add(1, Ordering::SeqCst); + s.as_deref().map_or(0, |v| v.len()) +} + +#[test] +fn opt_mut_ref_default_key_compiles_and_caches() { + OPT_MUT_REF_BODY_COUNT.store(0, Ordering::SeqCst); + + // Two calls with equal keys (same string content) must hit the cache on the + // second call: the body should run exactly once. + let mut a = String::from("hello"); + let mut b = String::from("hello"); + let r1 = opt_mut_ref_cached(Some(&mut a)); + let r2 = opt_mut_ref_cached(Some(&mut b)); + assert_eq!(r1, 5); + assert_eq!(r2, 5); + assert_eq!( + OPT_MUT_REF_BODY_COUNT.load(Ordering::SeqCst), + 1, + "Option<&mut String> with equal keys: body must run exactly once (cache hit on second call)" + ); + + // A call with a different key must miss. + let mut c = String::from("world!"); + let r3 = opt_mut_ref_cached(Some(&mut c)); + assert_eq!(r3, 6); + assert_eq!(OPT_MUT_REF_BODY_COUNT.load(Ordering::SeqCst), 2); + + // None key must also be cacheable. + let r4 = opt_mut_ref_cached(None); + let r5 = opt_mut_ref_cached(None); + assert_eq!(r4, 0); + assert_eq!(r5, 0); + assert_eq!( + OPT_MUT_REF_BODY_COUNT.load(Ordering::SeqCst), + 3, + "None key: body runs once, second call is a cache hit" + ); +} + +// ── async in_impl: #[once(in_impl = true)] on an async self-method ───────── +// `#[once]` stores one shared value for all callers. On an async in_impl method +// the body must run exactly once across repeated awaits with the same receiver. + +#[cfg(feature = "async")] +mod async_in_impl_tests { + use super::*; + + struct AsyncSvc; + + static ASYNC_ONCE_CALLS: AtomicUsize = AtomicUsize::new(0); + + impl AsyncSvc { + #[once(in_impl = true)] + async fn load(&self, x: i32) -> i32 { + ASYNC_ONCE_CALLS.fetch_add(1, Ordering::SeqCst); + x * 2 + } + } + + #[tokio::test] + async fn async_in_impl_once_caches_across_awaits() { + ASYNC_ONCE_CALLS.store(0, Ordering::SeqCst); + let s = AsyncSvc; + // First await computes and caches the single shared value. + assert_eq!(s.load(5).await, 10); + // Later awaits (even with a different arg) serve the single cached value; + // the body runs exactly once. + assert_eq!( + s.load(7).await, + 10, + "once: single value shared across awaits" + ); + assert_eq!( + ASYNC_ONCE_CALLS.load(Ordering::SeqCst), + 1, + "async in_impl once: body runs exactly once" + ); + } + + // ── async in_impl: #[cached(in_impl = true)] on an async method ───────── + // The keyed cache stores a separate entry per argument. The body runs once per + // unique key and subsequent awaits with the same arg serve from the cache. + + struct AsyncCachedSvc; + + static ASYNC_CACHED_CALLS: AtomicUsize = AtomicUsize::new(0); + + impl AsyncCachedSvc { + #[cached(in_impl = true)] + async fn compute(&self, x: i32) -> i32 { + ASYNC_CACHED_CALLS.fetch_add(1, Ordering::SeqCst); + x * 3 + } + } + + // Note: `ASYNC_CACHED_CALLS` is reset below; the in_impl cache is function-local + // and cannot be reset from here, so this test must remain the sole caller of `compute`. + #[tokio::test] + async fn async_in_impl_cached_caches_per_key() { + ASYNC_CACHED_CALLS.store(0, Ordering::SeqCst); + let s = AsyncCachedSvc; + // First await for x=4: miss, body runs, result cached. + assert_eq!(s.compute(4).await, 12); + assert_eq!(ASYNC_CACHED_CALLS.load(Ordering::SeqCst), 1); + // Second await with the same arg: cache hit, body not re-run. + assert_eq!(s.compute(4).await, 12); + assert_eq!( + ASYNC_CACHED_CALLS.load(Ordering::SeqCst), + 1, + "async in_impl cached: second await with same arg must be a cache hit" + ); + // Different arg: new key, body runs again. + assert_eq!(s.compute(5).await, 15); + assert_eq!(ASYNC_CACHED_CALLS.load(Ordering::SeqCst), 2); + } + + // ── async in_impl: #[concurrent_cached(in_impl = true)] on an async method ── + // The concurrent sharded cache stores a separate entry per argument. The body + // runs once per unique key and subsequent awaits serve from the cache. + + struct AsyncConcSvc; + + static ASYNC_CONC_CALLS: AtomicUsize = AtomicUsize::new(0); + + impl AsyncConcSvc { + #[concurrent_cached(in_impl = true)] + async fn fetch(&self, x: i32) -> i32 { + ASYNC_CONC_CALLS.fetch_add(1, Ordering::SeqCst); + x + 10 + } + } + + // Note: `ASYNC_CONC_CALLS` is reset below; the in_impl cache is function-local + // and cannot be reset from here, so this test must remain the sole caller of `fetch`. + #[tokio::test] + async fn async_in_impl_concurrent_caches_per_key() { + ASYNC_CONC_CALLS.store(0, Ordering::SeqCst); + let s = AsyncConcSvc; + // First await for x=7: miss, body runs, result cached. + assert_eq!(s.fetch(7).await, 17); + assert_eq!(ASYNC_CONC_CALLS.load(Ordering::SeqCst), 1); + // Second await with the same arg: cache hit, body not re-run. + assert_eq!(s.fetch(7).await, 17); + assert_eq!( + ASYNC_CONC_CALLS.load(Ordering::SeqCst), + 1, + "async in_impl concurrent_cached: second await with same arg must be a cache hit" + ); + // Different arg: new key, body runs again. + assert_eq!(s.fetch(3).await, 13); + assert_eq!(ASYNC_CONC_CALLS.load(Ordering::SeqCst), 2); + } +} + +// ── #5: the `unbound` attribute is removed; plain `#[cached]` is the default +// unbounded store. These tests lock the behavior that replaced the +// removed attribute: a bare `#[cached]` (no max_size/ttl/expires) +// produces a working unbounded cache. `#[cached(unbound)]` is now a +// compile error, covered by the `cached_unbound_attr_removed` trybuild +// golden; here we prove the positive replacement behavior. +mod unbound_default_tests { + use super::*; + + // Each test uses its own `#[cached]` fn (hence its own cache static and + // counter) so the two tests never share a cache or counter across the + // single test binary. Counts are asserted as absolute values that hold for + // the sole caller of each function. + + static UNBOUND_REPEAT_CALLS: AtomicUsize = AtomicUsize::new(0); + + // No `max_size`, `ttl`, or `expires`: the default store is an `UnboundCache`. + #[cached] + fn unbound_repeat(x: u32) -> u32 { + UNBOUND_REPEAT_CALLS.fetch_add(1, Ordering::SeqCst); + x * 2 + } + + #[test] + fn plain_cached_caches_repeated_same_arg() { + // First call for x=21: miss, body runs. + assert_eq!(unbound_repeat(21), 42); + assert_eq!(UNBOUND_REPEAT_CALLS.load(Ordering::SeqCst), 1); + // Repeated same-arg call: cache hit, body does not re-run. This is the + // behavior the removed `unbound` attribute used to opt into and is now + // the `#[cached]` default. + assert_eq!(unbound_repeat(21), 42); + assert_eq!( + UNBOUND_REPEAT_CALLS.load(Ordering::SeqCst), + 1, + "plain #[cached] (no unbound attr) must cache repeated same-arg calls" + ); + } + + static UNBOUND_FILL_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[cached] + fn unbound_fill(x: u32) -> u32 { + UNBOUND_FILL_CALLS.fetch_add(1, Ordering::SeqCst); + x * 2 + } + + #[test] + fn plain_cached_is_unbounded_no_eviction() { + // Insert many distinct keys, far more than any LRU default would retain. + for i in 100..1100u32 { + assert_eq!(unbound_fill(i), i * 2); + } + let after_fill = UNBOUND_FILL_CALLS.load(Ordering::SeqCst); + assert_eq!(after_fill, 1000, "1000 distinct keys each computed once"); + // The very first key inserted must still be cached (unbounded: no + // eviction). A bounded store would have evicted it by now and the body + // would re-run, bumping the counter. + assert_eq!(unbound_fill(100), 200); + assert_eq!( + UNBOUND_FILL_CALLS.load(Ordering::SeqCst), + after_fill, + "default #[cached] is unbounded: the earliest key is never evicted" + ); + } +} + +// ── #8: `#[concurrent_cached]` `refresh` is now a plain `bool` (parity with +// `#[cached]`). `refresh = false` is the default and no longer trips the +// expires+refresh or refresh+create conflict checks (previously +// `refresh = Some(false)` made those combinations a compile error). These +// are compile-and-behavior tests locking that `refresh = false` is inert. +mod refresh_false_no_conflict_tests { + use super::*; + + // Per-value expiry store payload: implements `Expires`. Used to prove + // `refresh = false` does NOT conflict with `expires = true` (the conflict + // only fires for `refresh = true`). + #[derive(Clone)] + struct NeverExpires(u32); + + impl cached::Expires for NeverExpires { + fn is_expired(&self) -> bool { + false + } + } + + static REFRESH_FALSE_EXPIRES_CALLS: AtomicUsize = AtomicUsize::new(0); + + // `refresh = false` + `expires = true`: would have been a hard conflict when + // `refresh` was `Option` and `Some(false)` was set. Now compiles and + // behaves as a plain expires cache. + #[concurrent_cached(expires = true, refresh = false)] + fn refresh_false_expires(x: u32) -> NeverExpires { + REFRESH_FALSE_EXPIRES_CALLS.fetch_add(1, Ordering::SeqCst); + NeverExpires(x) + } + + #[test] + fn refresh_false_does_not_conflict_with_expires() { + REFRESH_FALSE_EXPIRES_CALLS.store(0, Ordering::SeqCst); + assert_eq!(refresh_false_expires(9).0, 9); + assert_eq!(REFRESH_FALSE_EXPIRES_CALLS.load(Ordering::SeqCst), 1); + // Cache hit: body not re-run. The value never expires. + assert_eq!(refresh_false_expires(9).0, 9); + assert_eq!( + REFRESH_FALSE_EXPIRES_CALLS.load(Ordering::SeqCst), + 1, + "refresh = false + expires = true must compile and cache" + ); + } +} + +// ── Item 2 positive guard: a VALID `name` still compiles and caches ────────── +// The `name` validation rejects non-identifier strings (see the +// `*_name_invalid_ident` trybuild fixtures). This guard proves the validation +// did not over-reject: a legal Rust identifier in `name` produces a working +// cache static under that exact name and memoizes across calls. + +static VALID_NAME_CALLS: AtomicUsize = AtomicUsize::new(0); + +#[cached(name = "MY_CACHE")] +fn valid_name_caches(x: u32) -> u32 { + VALID_NAME_CALLS.fetch_add(1, Ordering::SeqCst); + x + 1 +} + +#[test] +fn valid_name_compiles_and_caches() { + VALID_NAME_CALLS.store(0, Ordering::SeqCst); + + // First call for key 5: cache miss, body runs. + assert_eq!(valid_name_caches(5), 6); + assert_eq!(VALID_NAME_CALLS.load(Ordering::SeqCst), 1); + + // Repeat of key 5: cache hit, body must not run. + assert_eq!(valid_name_caches(5), 6); + assert_eq!( + VALID_NAME_CALLS.load(Ordering::SeqCst), + 1, + "a valid `name` must produce a working memoizing cache" + ); + + // A different key is a distinct entry: body runs once more. + assert_eq!(valid_name_caches(10), 11); + assert_eq!(VALID_NAME_CALLS.load(Ordering::SeqCst), 2); + + // The cache static is named exactly `MY_CACHE` (proves `name` took effect). + // If the identifier were not honored this reference would not resolve. + use cached::Cached; + assert!(MY_CACHE.0.read().cache_size() >= 2); +} + +// ── Item 9 positive guard: `sync_writes` is STILL valid on `#[once]` ───────── +// Item 9 rejects `sync_lock`/`unsync_reads` on `#[once]`, but `sync_writes` +// (and `sync_writes = "default"`/`= true`) must remain accepted because they +// drive `#[once]` codegen. These guards prove the rejection did not over-reach. +// Each function's cache static is module-global and cannot be reset, so each is +// call-exclusive to its own test. + +static ONCE_SW_DEFAULT_CALLS: AtomicUsize = AtomicUsize::new(0); + +#[once(sync_writes = "default")] +fn once_sync_writes_default(x: usize) -> usize { + ONCE_SW_DEFAULT_CALLS.fetch_add(1, Ordering::SeqCst); + x * 2 +} + +#[test] +fn once_sync_writes_default_compiles_and_caches() { + ONCE_SW_DEFAULT_CALLS.store(0, Ordering::SeqCst); + + // First call: cache miss, body runs. + assert_eq!(once_sync_writes_default(21), 42); + assert_eq!(ONCE_SW_DEFAULT_CALLS.load(Ordering::SeqCst), 1); + + // `#[once]` stores a single value for ALL arguments: a different argument + // still returns the first cached value and does not re-run the body. + assert_eq!(once_sync_writes_default(100), 42); + assert_eq!( + ONCE_SW_DEFAULT_CALLS.load(Ordering::SeqCst), + 1, + "`sync_writes = \"default\"` on `#[once]` must still compile and cache the one value" + ); +} + +static ONCE_SW_TRUE_CALLS: AtomicUsize = AtomicUsize::new(0); + +#[once(sync_writes = true)] +fn once_sync_writes_true(x: usize) -> usize { + ONCE_SW_TRUE_CALLS.fetch_add(1, Ordering::SeqCst); + x + 7 +} + +#[test] +fn once_sync_writes_true_compiles_and_caches() { + ONCE_SW_TRUE_CALLS.store(0, Ordering::SeqCst); + + assert_eq!(once_sync_writes_true(1), 8); + assert_eq!(ONCE_SW_TRUE_CALLS.load(Ordering::SeqCst), 1); + + // Single shared value: a hit on any later call. + assert_eq!(once_sync_writes_true(999), 8); + assert_eq!( + ONCE_SW_TRUE_CALLS.load(Ordering::SeqCst), + 1, + "`sync_writes = true` on `#[once]` must still compile and cache" + ); +} + +// ── Item #1: sync_writes defaults to ByKey for #[cached] ───────────────────── + +// Counter proves whether the body ran once or twice for a given key. +static CACHED_BY_KEY_DEFAULT_CALLS: AtomicUsize = AtomicUsize::new(0); + +// Bare #[cached] must default to ByKey, so concurrent first-calls for the same +// key produce only one body execution (the second waits for the first to write). +#[cached(key = "u32", convert = { k })] +fn cached_by_key_default(k: u32) -> u32 { + CACHED_BY_KEY_DEFAULT_CALLS.fetch_add(1, Ordering::SeqCst); + k * 2 +} + +#[test] +fn test_cached_by_key_default_deduplicates() { + use std::sync::Arc; + use std::sync::Barrier; + use std::thread; + + CACHED_BY_KEY_DEFAULT_CALLS.store(0, Ordering::SeqCst); + + // Use a barrier to force threads to reach the call site simultaneously. + let barrier = Arc::new(Barrier::new(4)); + let mut handles = vec![]; + for _ in 0..4 { + let b = Arc::clone(&barrier); + handles.push(thread::spawn(move || { + b.wait(); + cached_by_key_default(1) + })); + } + for h in handles { + h.join().expect("thread panicked"); + } + + let calls = CACHED_BY_KEY_DEFAULT_CALLS.load(Ordering::SeqCst); + // With ByKey default the body must run exactly once for key=1; all other + // threads wait on the key lock and return the cached value. + assert_eq!( + calls, 1, + "bare #[cached] must default to ByKey: body should run once, ran {calls}" + ); +} + +// sync_writes = false restores the old Disabled behavior: concurrent threads +// for the same key each compute independently (race possible, but no dedup). +static CACHED_SW_FALSE_CALLS: AtomicUsize = AtomicUsize::new(0); + +#[cached(key = "u32", convert = { k }, sync_writes = false)] +fn cached_sw_false(k: u32) -> u32 { + CACHED_SW_FALSE_CALLS.fetch_add(1, Ordering::SeqCst); + k * 3 +} + +#[test] +fn test_cached_sync_writes_false_double_compute() { + // With sync_writes = false (Disabled) the static is a plain RwLock. + // Verify the static type is not a tuple by accessing it directly. + CACHED_SW_FALSE_CALLS.store(0, Ordering::SeqCst); + assert_eq!(cached_sw_false(5), 15); + // Reading the lock directly (not .0) proves the static is the bare lock type, + // which is only true when sync_writes is Disabled. + use cached::Cached; + let hits_before = CACHED_SW_FALSE.read().cache_hits(); + assert_eq!(cached_sw_false(5), 15); // cache hit + let hits_after = CACHED_SW_FALSE.read().cache_hits(); + assert!( + hits_after > hits_before, + "sync_writes = false: cache should hit on repeated call" + ); +} + +// result_fallback = true on a bare #[cached] must NOT error; it silently selects +// Disabled sync_writes (since ByKey is incompatible with result_fallback). +// This test verifies the combination compiles and caches correctly. +#[cfg(feature = "time_stores")] +static CACHED_RF_NO_SW_CALLS: AtomicUsize = AtomicUsize::new(0); + +#[cfg(feature = "time_stores")] +#[cached(ttl_secs = 3600, result_fallback = true, key = "u32", convert = { k })] +fn cached_result_fallback_no_sync_writes(k: u32) -> Result { + CACHED_RF_NO_SW_CALLS.fetch_add(1, Ordering::SeqCst); + Ok(k) +} + +#[cfg(feature = "time_stores")] +#[test] +fn test_cached_result_fallback_no_explicit_sync_writes_compiles() { + CACHED_RF_NO_SW_CALLS.store(0, Ordering::SeqCst); + let v = cached_result_fallback_no_sync_writes(7).unwrap(); + assert_eq!(v, 7); + let cached_v = cached_result_fallback_no_sync_writes(7).unwrap(); + assert_eq!(cached_v, 7); + // Body runs only once (second call is a cache hit). + assert_eq!(CACHED_RF_NO_SW_CALLS.load(Ordering::SeqCst), 1); +} + +// ── Item #2: unquoted syn::Expr for code-valued attributes ─────────────────── + +// Unquoted `convert = { format!("{a}") }` (no quotes around the block). +static UNQUOTED_CONVERT_CALLS: AtomicUsize = AtomicUsize::new(0); + +#[cached(key = "String", convert = { format!("{a}") })] +fn unquoted_convert(a: u32) -> u32 { + UNQUOTED_CONVERT_CALLS.fetch_add(1, Ordering::SeqCst); + a +} + +#[test] +fn test_cached_unquoted_convert_compiles_and_caches() { + UNQUOTED_CONVERT_CALLS.store(0, Ordering::SeqCst); + assert_eq!(unquoted_convert(3), 3); + assert_eq!(unquoted_convert(3), 3); // cache hit + assert_eq!(UNQUOTED_CONVERT_CALLS.load(Ordering::SeqCst), 1); +} + +// Unquoted `create`: a bare expression (previously panicked the macro) and a +// single-expression block (previously tripped `unused_braces` in value position) +// must both compile and cache. The example `kitchen_sink` is the `-D warnings` +// regression guard for the lint; this guards the parse/cache behavior. +#[cached(ty = "cached::UnboundCache", create = cached::UnboundCache::new())] +fn unquoted_create_bare(x: u32) -> u32 { + x + 1 +} + +#[cached(ty = "cached::UnboundCache", create = { cached::UnboundCache::new() })] +fn unquoted_create_block(x: u32) -> u32 { + x + 1 +} + +#[test] +fn test_cached_unquoted_create_forms_compile_and_cache() { + assert_eq!(unquoted_create_bare(1), 2); + assert_eq!(unquoted_create_bare(1), 2); // cache hit + assert_eq!(unquoted_create_block(1), 2); + assert_eq!(unquoted_create_block(1), 2); // cache hit +} + +// Legacy quoted `convert = "{ n + 1 }"` must still work. +static QUOTED_CONVERT_CALLS: AtomicUsize = AtomicUsize::new(0); + +#[cached(key = "u32", convert = "{ n + 1 }")] +fn quoted_convert(n: u32) -> u32 { + QUOTED_CONVERT_CALLS.fetch_add(1, Ordering::SeqCst); + n +} + +#[test] +fn test_cached_legacy_quoted_convert_compiles_and_caches() { + QUOTED_CONVERT_CALLS.store(0, Ordering::SeqCst); + assert_eq!(quoted_convert(10), 10); + assert_eq!(quoted_convert(10), 10); // cache hit — same key (n+1=11) + assert_eq!(QUOTED_CONVERT_CALLS.load(Ordering::SeqCst), 1); +} + +// Unquoted `force_refresh = { k == 0 }` must compile and bypass the cache when +// the predicate evaluates to true. +static UNQUOTED_FR_CALLS: AtomicUsize = AtomicUsize::new(0); +static UNQUOTED_FR_SRC: AtomicUsize = AtomicUsize::new(42); + +// convert = { k % 100 } maps all keys to a small set of cache slots; +// force_refresh = { k == 0 } bypasses the cache when k is zero. +// The body also reads k (via UNQUOTED_FR_SRC) to suppress unused-variable lint. +#[cached(key = "u32", convert = { k % 100 }, force_refresh = { k == 0 })] +fn unquoted_force_refresh(k: u32) -> u32 { + UNQUOTED_FR_CALLS.fetch_add(1, Ordering::SeqCst); + // Use k to ensure the body produces a key-dependent result. + UNQUOTED_FR_SRC.load(Ordering::SeqCst) as u32 + (k % 100) +} + +#[test] +fn test_cached_unquoted_force_refresh_compiles_and_works() { + UNQUOTED_FR_CALLS.store(0, Ordering::SeqCst); + UNQUOTED_FR_SRC.store(10, Ordering::SeqCst); + + // k == 1 => force_refresh = false: normal caching; body returns src(10) + 1%100 = 11. + assert_eq!(unquoted_force_refresh(1), 11); + assert_eq!(unquoted_force_refresh(1), 11); // cache hit + assert_eq!(UNQUOTED_FR_CALLS.load(Ordering::SeqCst), 1); + + // Change underlying source. + UNQUOTED_FR_SRC.store(99, Ordering::SeqCst); + + // k == 0 => force_refresh = true: bypasses cache, re-runs body; returns src(99) + 0%100 = 99. + assert_eq!(unquoted_force_refresh(0), 99); + assert_eq!(UNQUOTED_FR_CALLS.load(Ordering::SeqCst), 2); +} + +// ── Item #3: map_error optional on fallible concurrent paths ───────────────── + +// A disk-backed concurrent_cached function whose error type implements +// From must compile without an explicit `map_error` closure. +// The macro generates `.map_err(Into::into)?` automatically. +// Use Box as the error type: its From impl is +// unambiguous because the blanket `From for Box` is the +// only applicable conversion. +#[cfg(all(feature = "redb_store", feature = "proc_macro"))] +mod disk_no_map_error_tests { + use cached::macros::concurrent_cached; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static DISK_NO_MAP_ERR_CALLS: AtomicUsize = AtomicUsize::new(0); + + // No `map_error` attribute: the macro generates `Into::into` implicitly. + // `Box` implements `From` + // via the blanket `From`, giving unambiguous inference. + #[concurrent_cached(disk = true, ttl_secs = 60)] + fn disk_fn_no_map_error(n: u32) -> Result> { + DISK_NO_MAP_ERR_CALLS.fetch_add(1, Ordering::SeqCst); + Ok(n * 2) + } + + #[test] + fn test_disk_concurrent_without_map_error_compiles_and_caches() { + // The primary assertion: the function compiles and returns the correct value. + // The disk (redb) cache is persistent across test runs, so the body may or may + // not run on any given run. We verify correctness of the return value only. + assert_eq!( + disk_fn_no_map_error(3).unwrap(), + 6, + "disk_fn_no_map_error(3) must return Ok(6)" + ); + // A second call must also return the correct value (either from cache or body). + assert_eq!( + disk_fn_no_map_error(3).unwrap(), + 6, + "disk_fn_no_map_error(3) repeated call must return Ok(6)" + ); + } +} + +// ── Item #9: companions_vis knob ───────────────────────────────────────────── + +// Verify that `companions_vis = "pub(crate)"` causes the no_cache companion +// and prime_cache companion to be generated with `pub(crate)` visibility. +// We test this indirectly: if companions_vis works the test module can call +// the companion fn that would otherwise be private or less visible. +mod companions_vis_tests { + use cached::macros::cached; + + // pub fn with companions_vis = "pub(crate)": the companion + // `test_companions_vis_fn_no_cache` must be callable from this module. + #[cached(key = "u32", convert = { n }, companions_vis = "pub(crate)")] + pub fn companions_vis_fn(n: u32) -> u32 { + n * 7 + } + + #[test] + fn test_companions_vis_pub_crate_produces_pub_crate_companions() { + // Call the no_cache companion directly — this only compiles if it is + // pub(crate) (or more visible). If companions_vis is not respected the + // companion would be `pub` (matching the fn), but here we check it is + // accessible, which is the positive signal. + let direct = companions_vis_fn_no_cache(2); + assert_eq!( + direct, 14, + "companions_vis: no_cache companion returned wrong value" + ); + } + + // Default (no companions_vis): companion inherits the fn's visibility. + #[cached(key = "u32", convert = { n })] + pub fn default_companions_vis_fn(n: u32) -> u32 { + n + 1 + } + + #[test] + fn test_companions_vis_default_inherits_fn_visibility() { + // The no_cache companion should be callable (pub inherited). + let direct = default_companions_vis_fn_no_cache(5); + assert_eq!( + direct, 6, + "default companions_vis: no_cache companion returned wrong value" + ); + } +} diff --git a/tests/v3_macros_ui.rs b/tests/v3_macros_ui.rs new file mode 100644 index 00000000..0cf5be6c --- /dev/null +++ b/tests/v3_macros_ui.rs @@ -0,0 +1,96 @@ +/*! +Trybuild compile-fail goldens for the 3.0 macro changes. + +These cover the new attribute validations: +- `ttl` + `ttl_millis`, `ttl` + `ttl_secs`, and `ttl_secs` + `ttl_millis` + mutual exclusion (the 3-way exclusion) on all three macros. +- the old `ttl = ` whole-seconds form rejected with the migration + message pointing at `ttl_secs`/`ttl_millis`, on all three macros. +- `ttl_millis = 0` rejection (#149) on all three macros. +- `expires` + `ttl_millis` mutual exclusion (#149) on all three macros. +- an unparseable `ttl` Duration expression on all three macros. +- an unparseable `force_refresh` expression on all three macros (#146). +- a generic (type-param) function and a generic `in_impl` method without + `key`/`convert` (the generic rejection, #80) on both `#[cached]` and + `#[concurrent_cached]`. +- a const-generic function without `key`/`convert` on both `#[cached]` and + `#[concurrent_cached]` (const params have the same static-naming problem as + type params and are now rejected with the same message). +- `in_impl = true` on an associated function without a `self` receiver (the + `in_impl`-requires-self rejection) on all three macros. +- a `create` block combined with `ttl_millis` (the create-conflict rejection, + #149) on both `#[cached]` and `#[concurrent_cached]`. + +All of these fire during macro expansion before any feature-gated store type is +emitted, so `proc_macro` alone is sufficient (no `time_stores` needed). +*/ + +#![cfg(feature = "proc_macro")] + +#[test] +fn compile_fail_v3_macros() { + let t = trybuild::TestCases::new(); + t.compile_fail("tests/ui/cached_ttl_and_ttl_millis_exclusive.rs"); + t.compile_fail("tests/ui/cached_ttl_millis_zero.rs"); + t.compile_fail("tests/ui/once_ttl_millis_zero.rs"); + t.compile_fail("tests/ui/concurrent_cached_ttl_millis_zero.rs"); + t.compile_fail("tests/ui/once_ttl_and_ttl_millis_exclusive.rs"); + t.compile_fail("tests/ui/concurrent_cached_ttl_and_ttl_millis_exclusive.rs"); + t.compile_fail("tests/ui/cached_ttl_ttl_secs_exclusive.rs"); + t.compile_fail("tests/ui/once_ttl_ttl_secs_exclusive.rs"); + t.compile_fail("tests/ui/concurrent_cached_ttl_ttl_secs_exclusive.rs"); + t.compile_fail("tests/ui/cached_ttl_secs_and_ttl_millis_exclusive.rs"); + t.compile_fail("tests/ui/once_ttl_secs_and_ttl_millis_exclusive.rs"); + t.compile_fail("tests/ui/concurrent_cached_ttl_secs_and_ttl_millis_exclusive.rs"); + t.compile_fail("tests/ui/cached_ttl_unparseable_duration.rs"); + t.compile_fail("tests/ui/once_ttl_unparseable_duration.rs"); + t.compile_fail("tests/ui/concurrent_cached_ttl_unparseable_duration.rs"); + t.compile_fail("tests/ui/cached_ttl_integer_migration.rs"); + t.compile_fail("tests/ui/once_ttl_integer_migration.rs"); + t.compile_fail("tests/ui/concurrent_cached_ttl_integer_migration.rs"); + t.compile_fail("tests/ui/cached_expires_and_ttl_millis_exclusive.rs"); + t.compile_fail("tests/ui/once_expires_and_ttl_millis_exclusive.rs"); + t.compile_fail("tests/ui/concurrent_cached_expires_and_ttl_millis_exclusive.rs"); + t.compile_fail("tests/ui/cached_force_refresh_unparseable.rs"); + t.compile_fail("tests/ui/once_force_refresh_unparseable.rs"); + t.compile_fail("tests/ui/concurrent_cached_force_refresh_unparseable.rs"); + t.compile_fail("tests/ui/cached_generic_requires_convert.rs"); + t.compile_fail("tests/ui/cached_const_generic_requires_convert.rs"); + t.compile_fail("tests/ui/cached_in_impl_generic_requires_convert.rs"); + t.compile_fail("tests/ui/concurrent_cached_generic_requires_convert.rs"); + t.compile_fail("tests/ui/concurrent_cached_const_generic_requires_convert.rs"); + t.compile_fail("tests/ui/concurrent_cached_in_impl_generic_requires_convert.rs"); + t.compile_fail("tests/ui/cached_ttl_millis_create_conflict.rs"); + t.compile_fail("tests/ui/cached_refresh_create_conflict.rs"); + t.compile_fail("tests/ui/concurrent_cached_ttl_millis_create_conflict.rs"); + t.compile_fail("tests/ui/cached_in_impl_requires_self.rs"); + t.compile_fail("tests/ui/once_in_impl_requires_self.rs"); + t.compile_fail("tests/ui/concurrent_cached_in_impl_requires_self.rs"); + // Item 2: `name` must be a valid Rust identifier + t.compile_fail("tests/ui/cached_name_invalid_ident.rs"); + t.compile_fail("tests/ui/once_name_invalid_ident.rs"); + t.compile_fail("tests/ui/concurrent_cached_name_invalid_ident.rs"); + // Item 2 edge cases: leading digit and reserved keyword are also rejected + // (same spanned message) and must not reach `Ident::new` (which panics on + // a keyword). + t.compile_fail("tests/ui/cached_name_leading_digit.rs"); + t.compile_fail("tests/ui/cached_name_keyword.rs"); + // Item 11: `ShardHasher: Clone` supertrait - a non-Clone custom hasher is rejected. + t.compile_fail("tests/ui/sharded_non_clone_shard_hasher.rs"); + // Negative surface for the concurrent trait split: non-TTL sharded stores do not + // implement `ConcurrentCacheTtl`, so `set_ttl` does not exist on them even under + // the prelude glob. + t.compile_fail("tests/ui/sharded_unbound_no_set_ttl.rs"); + // Item 9: `#[cached]`-only attributes rejected on other macros + t.compile_fail("tests/ui/once_sync_lock_unsupported.rs"); + t.compile_fail("tests/ui/once_unsync_reads_unsupported.rs"); + t.compile_fail("tests/ui/concurrent_cached_sync_writes_buckets_unsupported.rs"); + t.compile_fail("tests/ui/concurrent_cached_sync_lock_unsupported.rs"); + t.compile_fail("tests/ui/concurrent_cached_unsync_reads_unsupported.rs"); + // Item #1: explicit sync_writes = "by_key" combined with result_fallback errors. + t.compile_fail("tests/ui/cached_result_fallback_sync_writes_by_key.rs"); + // Item #2: malformed unquoted convert block (syntax error) rejected. + t.compile_fail("tests/ui/cached_convert_malformed_unquoted.rs"); + // Item #2: map_error = 5 (non-closure expression) rejected. + t.compile_fail("tests/ui/concurrent_cached_map_error_non_closure.rs"); +} diff --git a/tests/v3_per_entry_expiry.rs b/tests/v3_per_entry_expiry.rs new file mode 100644 index 00000000..a928735d --- /dev/null +++ b/tests/v3_per_entry_expiry.rs @@ -0,0 +1,277 @@ +//! Formal tests for the per-entry `expires_at: Option` semantics +//! introduced in v3 (shard #7). +//! +//! Key invariants under test: +//! +//! 1. `set_ttl` is future-only: an entry inserted before the call keeps its +//! original `expires_at`; only entries inserted AFTER the call use the new TTL. +//! +//! 2. `refresh_on_hit` recomputes `expires_at = now + current_ttl` on every live +//! hit, extending the deadline from the moment of access rather than insert. +//! When the current TTL is zero (disabled), a hit must preserve the existing +//! `expires_at` (i.e. it must NOT clear it to `None`). +//! +//! All items are gated `#[cfg(feature = "time_stores")]`. +#![cfg(feature = "time_stores")] + +use cached::time::Duration; +use cached::{CacheTtl, Cached, ConcurrentCached, LruTtlCache, ShardedTtlCache, TtlCache}; + +// Enough time to let a SHORT-ttl entry expire; chosen to be comfortably > SHORT. +// Use a generously wide TTL so CI runners under load don't race the wall-clock. +const SHORT: Duration = Duration::from_millis(200); +const LONG: Duration = Duration::from_secs(60); +const SLEEP: std::time::Duration = std::time::Duration::from_millis(500); + +// ─────────────────────────── TtlCache: set_ttl is future-only ──────────────── + +/// After `set_ttl(SHORT)` on a populated cache, entries inserted before the call +/// retain their original LONG expiry and must survive past `SHORT`. +#[test] +fn ttl_cache_set_ttl_does_not_retroactively_expire_existing_entries() { + let mut c = TtlCache::::builder() + .ttl(LONG) + .build() + .expect("build TtlCache"); + + c.cache_set(1, 100); // expires_at = now + LONG (60s) + c.set_ttl(SHORT); // future inserts get expires_at = now + SHORT (30ms) + + std::thread::sleep(SLEEP); // 80ms elapsed + + // Entry 1 was inserted under LONG and must still be live after 80ms. + assert_eq!( + c.cache_get(&1), + Some(&100), + "pre-set_ttl entry must keep its original LONG expires_at" + ); +} + +/// After `set_ttl(SHORT)` on a populated cache, a NEW entry inserted after the +/// call uses SHORT and must have expired after sleeping past it. +#[test] +fn ttl_cache_set_ttl_applies_to_new_inserts() { + let mut c = TtlCache::::builder() + .ttl(LONG) + .build() + .expect("build TtlCache"); + + c.set_ttl(SHORT); + c.cache_set(2, 200); // expires_at = now + SHORT (30ms) + + std::thread::sleep(SLEEP); // 80ms > 30ms + + assert_eq!( + c.cache_get(&2), + None, + "entry inserted after set_ttl(SHORT) must expire at the new deadline" + ); +} + +// ─────────────────────────── TtlCache: refresh_on_hit ──────────────────────── + +/// `refresh_on_hit` must recompute `expires_at = now + ttl` on every live hit, +/// extending the deadline. After multiple hits spaced SHORT/2 apart the entry +/// must still be live because each hit pushed the deadline forward. +#[test] +fn ttl_cache_refresh_on_hit_extends_expires_at() { + let mut c = TtlCache::::builder() + .ttl(SHORT) + .refresh_on_hit(true) + .build() + .expect("build TtlCache"); + + c.cache_set(1, 10); // expires_at = now + SHORT + + // Sleep half of SHORT, then read: refresh extends to now+SHORT again. + std::thread::sleep(std::time::Duration::from_millis(100)); + assert_eq!( + c.cache_get(&1), + Some(&10), + "entry must still be live at half-SHORT (< SHORT)" + ); + + // Sleep half of SHORT again (100ms more from the read = 200ms from read, + // but the refreshed deadline is now+SHORT from the read, so still live). + std::thread::sleep(std::time::Duration::from_millis(100)); + assert_eq!( + c.cache_get(&1), + Some(&10), + "refresh must have extended the deadline; entry must still be live" + ); +} + +/// When the current TTL is 0 (disabled), a `refresh_on_hit` must NOT set +/// `expires_at = None` on an entry that already has a concrete expiry; the +/// existing `expires_at` must be preserved. +#[test] +fn ttl_cache_refresh_on_hit_with_disabled_ttl_preserves_existing_expires_at() { + let mut c = TtlCache::::builder() + .ttl(LONG) // entry gets expires_at = now + 60s + .refresh_on_hit(true) + .build() + .expect("build TtlCache"); + + c.cache_set(1, 10); // expires_at = now + 60s + + // Disable TTL; future inserts get expires_at = None, but entry 1 still has now+60s. + c.set_ttl(Duration::ZERO); + + // Hit entry 1 with refresh enabled and current TTL = 0. + // The refresh must preserve the existing expires_at (now+60s), not clear it. + assert_eq!(c.cache_get(&1), Some(&10)); + + // Entry must still be live after the refresh hit. + assert_eq!( + c.cache_get(&1), + Some(&10), + "refresh_on_hit with disabled TTL must not change the existing expires_at" + ); +} + +// ─────────────────────────── LruTtlCache: set_ttl is future-only ───────────── + +/// Same future-only contract on LruTtlCache: pre-`set_ttl` entries retain their +/// original expires_at and survive past the new (shorter) TTL. +#[test] +fn lru_ttl_cache_set_ttl_does_not_retroactively_expire_existing_entries() { + let mut c = LruTtlCache::::builder() + .max_size(8) + .ttl(LONG) + .build() + .expect("build LruTtlCache"); + + c.cache_set(1, 100); // expires_at = now + 60s + c.set_ttl(SHORT); // future inserts get expires_at = now + 30ms + + std::thread::sleep(SLEEP); // 80ms elapsed + + assert_eq!( + c.cache_get(&1), + Some(&100), + "LruTtlCache: pre-set_ttl entry must keep its original LONG expires_at" + ); +} + +/// After `set_ttl(SHORT)` on LruTtlCache, a new insert uses SHORT. +#[test] +fn lru_ttl_cache_set_ttl_applies_to_new_inserts() { + let mut c = LruTtlCache::::builder() + .max_size(8) + .ttl(LONG) + .build() + .expect("build LruTtlCache"); + + c.set_ttl(SHORT); + c.cache_set(2, 200); // expires_at = now + 30ms + + std::thread::sleep(SLEEP); + + assert_eq!( + c.cache_get(&2), + None, + "LruTtlCache: entry inserted after set_ttl(SHORT) must expire" + ); +} + +// ─────────────────────────── LruTtlCache: refresh_on_hit ───────────────────── + +/// refresh_on_hit extends the deadline on LruTtlCache, same as TtlCache. +#[test] +fn lru_ttl_cache_refresh_on_hit_extends_expires_at() { + let mut c = LruTtlCache::::builder() + .max_size(8) + .ttl(SHORT) + .refresh_on_hit(true) + .build() + .expect("build LruTtlCache"); + + c.cache_set(1, 10); // expires_at = now + SHORT + + std::thread::sleep(std::time::Duration::from_millis(100)); + assert_eq!( + c.cache_get(&1), + Some(&10), + "LRU: entry must still be live at half-SHORT" + ); + + std::thread::sleep(std::time::Duration::from_millis(100)); + assert_eq!( + c.cache_get(&1), + Some(&10), + "LRU: refresh must have extended the deadline; entry must still be live" + ); +} + +// ─────────────────────────── ShardedTtlCache: set_ttl is future-only ───────── + +/// The concurrent ShardedTtlCache must also honour the future-only contract for +/// `set_ttl`. A pre-call entry retains its original expiry; a post-call insert +/// uses the new TTL. +#[test] +fn sharded_ttl_cache_set_ttl_does_not_retroactively_expire_existing_entries() { + let c = ShardedTtlCache::::builder() + .ttl(LONG) + .shards(1) + .build() + .expect("build ShardedTtlCache"); + + c.cache_set(1, 100).unwrap(); // expires_at = now + 60s + c.set_ttl(SHORT); // future inserts get expires_at = now + 30ms + + std::thread::sleep(SLEEP); // 80ms elapsed + + assert_eq!( + c.cache_get(&1), + Ok(Some(100)), + "ShardedTtlCache: pre-set_ttl entry must keep its LONG expires_at" + ); +} + +/// After `set_ttl(SHORT)` on ShardedTtlCache, a new insert uses SHORT and expires. +#[test] +fn sharded_ttl_cache_set_ttl_applies_to_new_inserts() { + let c = ShardedTtlCache::::builder() + .ttl(LONG) + .shards(1) + .build() + .expect("build ShardedTtlCache"); + + c.set_ttl(SHORT); + c.cache_set(2, 200).unwrap(); // expires_at = now + 30ms + + std::thread::sleep(SLEEP); + + assert_eq!( + c.cache_get(&2), + Ok(None), + "ShardedTtlCache: entry inserted after set_ttl(SHORT) must expire" + ); +} + +/// refresh_on_hit on ShardedTtlCache extends the deadline. +#[test] +fn sharded_ttl_cache_refresh_on_hit_extends_expires_at() { + let c = ShardedTtlCache::::builder() + .ttl(SHORT) + .shards(1) + .refresh_on_hit(true) + .build() + .expect("build ShardedTtlCache"); + + c.cache_set(1, 10).unwrap(); // expires_at = now + SHORT + + std::thread::sleep(std::time::Duration::from_millis(100)); + assert_eq!( + c.cache_get(&1), + Ok(Some(10)), + "Sharded: entry must still be live at half-SHORT" + ); + + std::thread::sleep(std::time::Duration::from_millis(100)); + assert_eq!( + c.cache_get(&1), + Ok(Some(10)), + "Sharded: refresh must have extended the deadline; entry must still be live" + ); +} diff --git a/tests/v3_redis_backward_read.rs b/tests/v3_redis_backward_read.rs new file mode 100644 index 00000000..1331f160 --- /dev/null +++ b/tests/v3_redis_backward_read.rs @@ -0,0 +1,219 @@ +/*! +Integration tests for Redis store backward-compatibility and millisecond TTL precision. + +All tests require a live Redis server. They gate on the `redis_store` feature and skip +cleanly when `CACHED_REDIS_CONNECTION_STRING` is not set (return early without panicking). + +Covered: +- M7b: backward read of a pre-3.0 JSON-encoded entry (written directly via raw redis, + read back transparently via `RedisCache::cache_get`). +- M7c: msgpack round-trip with a structured (non-string) value type through `RedisCache`. +- M7a: sub-second TTL precision -- `PTTL` confirms the key was written with millisecond + granularity rather than being rounded up to a whole second. +*/ + +#![cfg(feature = "redis_store")] + +use std::time::Duration; + +use cached::{ConcurrentCached, RedisCache}; + +const ENV_KEY: &str = "CACHED_REDIS_CONNECTION_STRING"; + +/// Return the connection string from the env var, or skip (return from the caller) if absent. +macro_rules! conn_or_skip { + () => { + match std::env::var(ENV_KEY) { + Ok(s) => s, + Err(_) => return, + } + }; +} + +// ─────────────────────────────── M7b: backward read ────────────────────────── +// +// Write a key in the OLD 2.x JSON format (`{"value": , "version": 1}`) directly +// via the raw redis client at the exact key a `RedisCache` with the matching +// namespace/prefix would read, then assert `cache_get` returns the value +// transparently. + +#[test] +fn redis_backward_read_legacy_json_entry() { + let _conn_url = conn_or_skip!(); + + let prefix = "v3_backward_read_legacy"; + let namespace = ""; + + let cache = RedisCache::::builder() + .prefix(prefix) + .namespace(namespace) + .ttl(Duration::from_secs(60)) + .build() + .expect("build RedisCache"); + + cache.cache_clear().expect("clear"); + + // The key scheme for namespace="" prefix="v3_backward_read_legacy" key="hello" is + // "v3_backward_read_legacy:hello" (namespace skipped when empty). + let raw_key = format!("{prefix}:hello"); + let legacy_value = r#"{"value":"world","version":1}"#; + + // Write the legacy JSON directly into Redis with a generous TTL. + let conn_str = cache.connection_string(); + let mut raw = redis::Client::open(conn_str.reveal()) + .expect("raw client") + .get_connection() + .expect("raw connection"); + + redis::cmd("SET") + .arg(&raw_key) + .arg(legacy_value) + .arg("EX") + .arg(60i64) + .query::<()>(&mut raw) + .expect("SET legacy entry"); + + // RedisCache must transparently deserialize the legacy JSON entry. + let got = cache + .cache_get(&"hello".to_string()) + .expect("cache_get legacy"); + assert_eq!( + got, + Some("world".to_string()), + "cache_get must transparently read a pre-3.0 JSON-encoded entry" + ); + + // Now write a fresh entry through the store (msgpack) and read it back. + cache + .cache_set("fresh_key".to_string(), "fresh_val".to_string()) + .expect("cache_set msgpack"); + let got2 = cache + .cache_get(&"fresh_key".to_string()) + .expect("cache_get msgpack"); + assert_eq!( + got2, + Some("fresh_val".to_string()), + "cache_get must read a store-written (msgpack) entry" + ); + + cache.cache_clear().expect("clean up"); +} + +// ─────────────────────────────── M7c: structured-value msgpack round-trip ──── +// +// A derived struct value is set through a `RedisCache` and +// retrieved by `cache_get`. Proves the msgpack encoding handles non-primitive +// value types correctly. + +#[derive(Clone, PartialEq, Debug, serde::Serialize, serde::Deserialize)] +struct Point { + x: i32, + y: i32, + label: String, +} + +#[test] +fn redis_msgpack_round_trip_struct_value() { + let _conn_url = conn_or_skip!(); + + let prefix = "v3_msgpack_struct_rt"; + + let cache = RedisCache::::builder() + .prefix(prefix) + .namespace("") + .ttl(Duration::from_secs(60)) + .build() + .expect("build RedisCache"); + + cache.cache_clear().expect("clear"); + + let key = "origin".to_string(); + let point = Point { + x: 42, + y: -7, + label: "test-point".to_string(), + }; + + // Miss: not yet in cache. + assert_eq!( + cache.cache_get(&key).expect("cache_get miss"), + None, + "first cache_get must return None" + ); + + // Set. + cache + .cache_set(key.clone(), point.clone()) + .expect("cache_set Point"); + + // Hit: must deserialize back to the same value. + let got = cache.cache_get(&key).expect("cache_get hit"); + assert_eq!( + got, + Some(point), + "cache_get must return the exact struct that was set" + ); + + cache.cache_clear().expect("clean up"); +} + +// ─────────────────────────────── M7a: sub-second TTL precision (PTTL) ──────── +// +// Set an entry with a sub-second TTL (750 ms), then query PTTL on the raw +// connection and assert the remaining TTL is in a plausible millisecond band: +// > 0 (key has not already expired) +// <= 750 (the key was NOT rounded up to a whole second) +// This certifies that the store writes with `PSETEX` (millisecond precision), +// not `SETEX` (which would round to 1000 ms). + +#[test] +fn redis_subsecond_ttl_precision_via_pttl() { + let _conn_url = conn_or_skip!(); + + let prefix = "v3_subsecond_ttl_pttl"; + let ttl_ms = 750u64; + + let cache = RedisCache::::builder() + .prefix(prefix) + .namespace("") + .ttl(Duration::from_millis(ttl_ms)) + .build() + .expect("build RedisCache with 750ms TTL"); + + cache.cache_clear().expect("clear"); + + cache + .cache_set("k".to_string(), "v".to_string()) + .expect("cache_set"); + + // Query PTTL (millisecond TTL) on the raw key. + let conn_str = cache.connection_string(); + let mut raw = redis::Client::open(conn_str.reveal()) + .expect("raw client") + .get_connection() + .expect("raw connection"); + + let pttl: i64 = redis::cmd("PTTL") + .arg(format!("{prefix}:k")) + .query(&mut raw) + .expect("PTTL query"); + + assert!( + pttl > 0, + "PTTL must be positive (key must not have already expired); got {pttl}" + ); + assert!( + pttl <= ttl_ms as i64, + "PTTL must be <= {ttl_ms} ms (certifies millisecond precision, not whole-second rounding); got {pttl}" + ); + + // Ensure the entry is also readable (store is consistent). + let got = cache.cache_get(&"k".to_string()).expect("cache_get"); + assert_eq!( + got, + Some("v".to_string()), + "cache_get must return the value" + ); + + cache.cache_clear().expect("clean up"); +} diff --git a/tests/v3_serialize_set.rs b/tests/v3_serialize_set.rs new file mode 100644 index 00000000..d2a520f1 --- /dev/null +++ b/tests/v3_serialize_set.rs @@ -0,0 +1,137 @@ +//! (#196): the `#[concurrent_cached]` redis/disk set paths use the borrowed +//! setter (`SerializeCached::cache_set_ref` / `SerializeCachedAsync::async_cache_set_ref`) +//! instead of cloning the value before an owned `cache_set`. These tests prove the +//! set/get round-trip still works through the new call path on the disk (redb) store, +//! across the plain-`Result`, `with_cached_flag`, and async arms. The redis path is +//! covered at compile time by the feature builds (no live redis server in CI here). +//! +//! The borrowed setter now applies to ANY store that implements `SerializeCached` via the +//! autoref shim, including custom `ty`/`create` stores like `RedbCache`. The `*_custom_redb` +//! test exercises that path. + +#![cfg(all(feature = "redb_store", feature = "proc_macro"))] + +use cached::RedbCache; +use cached::macros::concurrent_cached; +use std::sync::atomic::{AtomicU32, Ordering}; +use thiserror::Error; + +#[derive(Error, Debug, PartialEq, Clone)] +enum SerializeSetError { + #[error("disk error `{0}`")] + Disk(String), +} + +static PLAIN_CALLS: AtomicU32 = AtomicU32::new(0); + +#[concurrent_cached( + disk = true, + map_error = r##"|e| SerializeSetError::Disk(format!("{e:?}"))"## +)] +fn disk_plain(n: u32) -> Result { + PLAIN_CALLS.fetch_add(1, Ordering::SeqCst); + Ok(n * 2) +} + +#[test] +fn disk_plain_result_round_trips_via_cache_set_ref() { + use cached::ConcurrentCached; + // redb disk caches persist on disk across test runs; clear first so the + // call-count assertion is deterministic. + DISK_PLAIN.cache_clear().expect("clear disk cache"); + PLAIN_CALLS.store(0, Ordering::SeqCst); + // First call computes and stores via the borrowed setter. + assert_eq!(disk_plain(21), Ok(42)); + // Second call is served from the cache (body not re-run). + assert_eq!(disk_plain(21), Ok(42)); + assert_eq!(PLAIN_CALLS.load(Ordering::SeqCst), 1); +} + +static FLAG_CALLS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +#[concurrent_cached( + disk = true, + with_cached_flag = true, + map_error = r##"|e| SerializeSetError::Disk(format!("{e:?}"))"## +)] +fn disk_flag(n: u32) -> Result, SerializeSetError> { + FLAG_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(cached::Return::new(n + 1)) +} + +#[test] +fn disk_with_cached_flag_round_trips_via_cache_set_ref() { + use cached::ConcurrentCached; + DISK_FLAG.cache_clear().expect("clear disk cache"); + FLAG_CALLS.store(0, std::sync::atomic::Ordering::SeqCst); + let first = disk_flag(100).unwrap(); + assert_eq!(*first, 101); + assert!(!first.was_cached); + let second = disk_flag(100).unwrap(); + assert_eq!(*second, 101); + assert!(second.was_cached); + // Body must have run exactly once: the second call is a cache hit. + assert_eq!(FLAG_CALLS.load(std::sync::atomic::Ordering::SeqCst), 1); +} + +// Custom `ty`/`create` redb store: `RedbCache` implements `SerializeCached`, so the +// autoref shim picks the borrowed `cache_set_ref` path (no value clone) even for a +// `ty`/`create` store. Still round-trips correctly. +static CUSTOM_CALLS: AtomicU32 = AtomicU32::new(0); + +#[concurrent_cached( + map_error = r##"|e| SerializeSetError::Disk(format!("{e:?}"))"##, + ty = "cached::RedbCache", + create = r##" { RedbCache::builder().name("serialize_set_custom_redb").build().expect("build redb") } "## +)] +fn custom_redb(n: u32) -> Result { + CUSTOM_CALLS.fetch_add(1, Ordering::SeqCst); + Ok(n + 7) +} + +#[test] +fn custom_redb_round_trips_via_cache_set_ref() { + use cached::ConcurrentCached; + // Clear the persisted store so the borrowed-set path actually runs (a re-run + // would otherwise be a pure cache hit and skip the path under test). + CUSTOM_REDB.cache_clear().expect("clear disk cache"); + CUSTOM_CALLS.store(0, Ordering::SeqCst); + assert_eq!(custom_redb(3), Ok(10)); + assert_eq!(custom_redb(3), Ok(10)); + assert_eq!(CUSTOM_CALLS.load(Ordering::SeqCst), 1); +} + +#[cfg(feature = "async")] +mod async_disk { + use super::*; + + static ASYNC_CALLS: AtomicU32 = AtomicU32::new(0); + + #[concurrent_cached( + disk = true, + map_error = r##"|e| SerializeSetError::Disk(format!("{e:?}"))"## + )] + async fn disk_async(n: u32) -> Result { + ASYNC_CALLS.fetch_add(1, Ordering::SeqCst); + Ok(n * 3) + } + + #[tokio::test] + async fn disk_async_round_trips_via_async_cache_set_ref() { + use cached::ConcurrentCached; + // The async cache static is a lazily-initialized `OnceCell`: make one + // call to initialize it, then clear the persisted store so the + // borrowed-set path actually runs (a re-run would otherwise be a pure + // cache hit and skip the path under test). + let _ = disk_async(4).await; + DISK_ASYNC + .get() + .expect("cache initialized by the call above") + .cache_clear() + .expect("clear disk cache"); + ASYNC_CALLS.store(0, Ordering::SeqCst); + assert_eq!(disk_async(4).await, Ok(12)); + assert_eq!(disk_async(4).await, Ok(12)); + assert_eq!(ASYNC_CALLS.load(Ordering::SeqCst), 1); + } +} diff --git a/tests/v3_sharded_zero_ttl.rs b/tests/v3_sharded_zero_ttl.rs new file mode 100644 index 00000000..41aeea46 --- /dev/null +++ b/tests/v3_sharded_zero_ttl.rs @@ -0,0 +1,889 @@ +//! Outside-in coverage for the zero-TTL semantics on the sharded TTL stores +//! (`ShardedTtlCache` / `ShardedLruTtlCache`), pinning I2. +//! +//! Semantic (v3): `set_ttl(Duration::ZERO)` means "expiry disabled / no expiry" and is +//! exactly equivalent to `unset_ttl()`. A zero ttl is the single sentinel for "disabled"; +//! it does NOT mean "expire immediately". `set_ttl(nonzero)` re-arms expiry. The builder +//! still rejects a zero ttl, and `try_set_ttl(0)` still returns `SetTtlError::ZeroTtl` — +//! disabling is done via `set_ttl(0)` or `unset_ttl()`. +//! +//! This module covers: the full state-transition cycle with prior-value semantics, the +//! `evict`/`on_evict` no-op under a disabled ttl, the LruTtl refresh-on-hit and +//! expiry-status read paths, `cache_get_or_set_with`, `Debug`, `deep_clone` propagation, +//! and a concurrency stress that flips the ttl while other threads read and write. + +#![cfg(feature = "time_stores")] + +use cached::time::Duration; +use cached::{ + ConcurrentCacheEvict, ConcurrentCached, ConcurrentCloneCached, ShardedLruTtlCache, + ShardedTtlCache, +}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + +// ───────────────────────────────────────────────────────────────────────────── +// State-transition cycle + prior-value semantics +// +// A zero ttl is the disabled sentinel, so `set_ttl(0)` and `unset_ttl()` are observably +// identical: both store the disabled state and the cache's ttl resolves to `None`. +// Each `set_ttl`/`unset_ttl` must return the *prior* resolved ttl, where the disabled +// state (zero or unset) reads back as `None` and a non-zero ttl reads back as `Some(d)`. +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn sharded_ttl_state_transition_cycle_returns_prior_ttl() { + let cache: ShardedTtlCache = ShardedTtlCache::builder() + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedTtlCache"); + + // set(nonzero): prior is the builder's 60s. + assert_eq!( + cache.set_ttl(Duration::from_secs(30)), + Some(Duration::from_secs(60)), + "set_ttl must return the builder ttl as prior" + ); + assert_eq!(cache.ttl(), Some(Duration::from_secs(30))); + + // set(zero): prior is the previous 30s; ttl now resolves to None (disabled). + assert_eq!( + cache.set_ttl(Duration::ZERO), + Some(Duration::from_secs(30)), + "set_ttl(ZERO) must return the prior non-zero ttl" + ); + assert_eq!( + cache.ttl(), + None, + "a zero ttl disables expiry — ttl resolves to None" + ); + + // unset from the disabled state: prior is None (already disabled). + assert_eq!( + cache.unset_ttl(), + None, + "unset_ttl after a zero set must report None as prior (already disabled)" + ); + assert_eq!(cache.ttl(), None, "after unset, ttl resolves to None"); + + // set(zero) again from the disabled state: prior is None (idempotent disable). + assert_eq!( + cache.set_ttl(Duration::ZERO), + None, + "set_ttl(ZERO) from the disabled state must report None as prior" + ); + assert_eq!(cache.ttl(), None); + + // set(nonzero) from a disabled state: prior is None (was disabled). + assert_eq!( + cache.set_ttl(Duration::from_secs(5)), + None, + "set_ttl(nonzero) from a disabled state must report None as prior" + ); + assert_eq!(cache.ttl(), Some(Duration::from_secs(5))); + + // unset from a nonzero state: prior is Some(5s). + assert_eq!( + cache.unset_ttl(), + Some(Duration::from_secs(5)), + "unset_ttl must report the prior non-zero ttl" + ); + assert_eq!(cache.ttl(), None); + + // unset again from the already-disabled state: prior is None (idempotent). + assert_eq!( + cache.unset_ttl(), + None, + "unset_ttl on an already-disabled cache must report None" + ); +} + +#[test] +fn sharded_lru_ttl_state_transition_cycle_returns_prior_ttl() { + let cache: ShardedLruTtlCache = ShardedLruTtlCache::builder() + .max_size(8) + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedLruTtlCache"); + + assert_eq!( + cache.set_ttl(Duration::from_secs(30)), + Some(Duration::from_secs(60)) + ); + assert_eq!(cache.set_ttl(Duration::ZERO), Some(Duration::from_secs(30))); + assert_eq!(cache.ttl(), None, "zero ttl disables expiry"); + assert_eq!(cache.unset_ttl(), None, "already disabled"); + assert_eq!(cache.ttl(), None); + assert_eq!(cache.set_ttl(Duration::ZERO), None); + assert_eq!(cache.ttl(), None); + assert_eq!( + cache.set_ttl(Duration::from_secs(5)), + None, + "set_ttl(nonzero) from a disabled state must report None as prior" + ); + assert_eq!(cache.unset_ttl(), Some(Duration::from_secs(5))); + assert_eq!(cache.unset_ttl(), None); +} + +// ───────────────────────────────────────────────────────────────────────────── +// set_ttl(0) is observably equivalent to unset_ttl(): a just-inserted entry survives. +// +// The entry inserted under a live ttl must remain present after disabling expiry via +// either route, and a newly inserted entry must also persist (never expires). +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn sharded_ttl_set_zero_disables_expiry_like_unset() { + let cache: ShardedTtlCache = ShardedTtlCache::builder() + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedTtlCache"); + + cache.cache_set(1, 10).unwrap(); + // Disable via set_ttl(0): the existing entry must remain live. + cache.set_ttl(Duration::ZERO); + assert_eq!( + cache.cache_get(&1), + Ok(Some(10)), + "set_ttl(0) must NOT expire a just-inserted entry" + ); + cache.cache_set(2, 20).unwrap(); + assert_eq!( + cache.cache_get(&2), + Ok(Some(20)), + "entries inserted under a disabled ttl never expire" + ); + + // Re-arm with a real ttl, then disable again via unset_ttl(): same observable result. + cache.set_ttl(Duration::from_secs(60)); + cache.unset_ttl(); + assert_eq!( + cache.cache_get(&1), + Ok(Some(10)), + "unset_ttl must also keep entries live" + ); +} + +#[test] +fn sharded_lru_ttl_set_zero_disables_expiry_like_unset() { + let cache: ShardedLruTtlCache = ShardedLruTtlCache::builder() + .max_size(8) + .shards(1) + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedLruTtlCache"); + + cache.cache_set(1, 10).unwrap(); + cache.set_ttl(Duration::ZERO); + assert_eq!( + cache.cache_get(&1), + Ok(Some(10)), + "set_ttl(0) must NOT expire a just-inserted LRU entry" + ); + + cache.set_ttl(Duration::from_secs(60)); + cache.unset_ttl(); + assert_eq!(cache.cache_get(&1), Ok(Some(10))); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Re-arming expiry: a non-zero set after a disabled ttl makes FUTURE inserts +// expirable, but entries inserted while TTL was disabled keep expires_at = None. +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn sharded_ttl_set_nonzero_after_disable_only_affects_future_inserts() { + let cache: ShardedTtlCache = ShardedTtlCache::builder() + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedTtlCache"); + + cache.set_ttl(Duration::ZERO); + // Entry 1 is inserted while TTL is disabled: gets expires_at = None. + cache.cache_set(1, 10).unwrap(); + assert_eq!(cache.cache_get(&1), Ok(Some(10))); + + // Re-arm with a short ttl: only FUTURE inserts get a real expires_at. + cache.set_ttl(Duration::from_millis(20)); + // Entry 2 is inserted now with the short TTL: gets expires_at = now + 20ms. + cache.cache_set(2, 20).unwrap(); + + std::thread::sleep(std::time::Duration::from_millis(60)); + + // Entry 1 (inserted under disabled TTL) must still be live (expires_at = None). + assert_eq!( + cache.cache_get(&1), + Ok(Some(10)), + "entry inserted under disabled ttl keeps expires_at=None; must survive re-arming" + ); + // Entry 2 (inserted under the short TTL) must have expired. + assert_eq!( + cache.cache_get(&2), + Ok(None), + "entry inserted after set_ttl(nonzero) must expire at the new deadline" + ); +} + +#[test] +fn sharded_lru_ttl_set_nonzero_after_disable_only_affects_future_inserts() { + let cache: ShardedLruTtlCache = ShardedLruTtlCache::builder() + .max_size(8) + .shards(1) + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedLruTtlCache"); + + cache.set_ttl(Duration::ZERO); + // Entry 1 inserted while disabled: expires_at = None. + cache.cache_set(1, 10).unwrap(); + assert_eq!(cache.cache_get(&1), Ok(Some(10))); + + cache.set_ttl(Duration::from_millis(20)); + // Entry 2 inserted after re-arming: expires_at = now + 20ms. + cache.cache_set(2, 20).unwrap(); + + std::thread::sleep(std::time::Duration::from_millis(60)); + + // Entry 1 (expires_at = None) must still be live. + assert_eq!( + cache.cache_get(&1), + Ok(Some(10)), + "disabled-TTL entry keeps expires_at=None; must survive re-arming" + ); + // Entry 2 must have expired. + assert_eq!( + cache.cache_get(&2), + Ok(None), + "entry inserted after set_ttl(nonzero) must expire at the new deadline on LRU store" + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// evict() / ConcurrentCacheEvict is a no-op under a disabled (zero) ttl. +// +// With expiry disabled, no entry is ever expired, so an explicit sweep removes nothing +// and does not fire on_evict — identical to the unset case. +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn sharded_ttl_evict_is_noop_under_zero_ttl() { + let count = Arc::new(AtomicU64::new(0)); + let count2 = count.clone(); + let cache = ShardedTtlCache::::builder() + .ttl(Duration::from_secs(60)) + .shards(1) + .on_evict(move |_, _| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build() + .expect("build ShardedTtlCache"); + + for i in 0..10u32 { + cache.cache_set(i, i).unwrap(); + } + cache.set_ttl(Duration::ZERO); + + let removed = ConcurrentCacheEvict::evict(&cache); + assert_eq!(removed, 0, "evict under a disabled ttl must remove nothing"); + assert_eq!( + count.load(Ordering::Relaxed), + 0, + "on_evict must not fire under a disabled ttl" + ); + assert_eq!(cache.metrics().evictions, Some(0)); + assert_eq!(cache.len(), 10, "all entries survive a disabled-ttl evict"); +} + +#[test] +fn sharded_lru_ttl_evict_is_noop_under_zero_ttl() { + let count = Arc::new(AtomicU64::new(0)); + let count2 = count.clone(); + let cache = ShardedLruTtlCache::::builder() + .max_size(64) + .shards(1) + .ttl(Duration::from_secs(60)) + .on_evict(move |_, _| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build() + .expect("build ShardedLruTtlCache"); + + for i in 0..10u32 { + cache.cache_set(i, i).unwrap(); + } + cache.set_ttl(Duration::ZERO); + + let removed = ConcurrentCacheEvict::evict(&cache); + assert_eq!(removed, 0, "evict under a disabled ttl must remove nothing"); + assert_eq!( + count.load(Ordering::Relaxed), + 0, + "on_evict must not fire under a disabled ttl" + ); + assert_eq!(cache.len(), 10); +} + +// evict() under unset ttl must also be a no-op — confirms set_ttl(0) and unset_ttl() +// behave identically for the explicit sweep. +#[test] +fn sharded_ttl_evict_under_unset_ttl_is_noop() { + let cache = ShardedTtlCache::::builder() + .ttl(Duration::from_secs(60)) + .shards(1) + .build() + .expect("build ShardedTtlCache"); + for i in 0..5u32 { + cache.cache_set(i, i).unwrap(); + } + cache.unset_ttl(); + assert_eq!( + ConcurrentCacheEvict::evict(&cache), + 0, + "evict under unset ttl must remove nothing" + ); + assert_eq!(cache.len(), 5, "entries must survive an unset-ttl evict"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// cache_remove / cache_remove_entry under a disabled (zero) ttl. +// +// A disabled-ttl entry is live (never expired): cache_remove must return Some(v), +// and cache_remove_entry must return Some too. +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn sharded_ttl_remove_under_zero_ttl_returns_live_value() { + let cache = ShardedTtlCache::::builder() + .ttl(Duration::from_secs(60)) + .shards(1) + .build() + .expect("build ShardedTtlCache"); + cache.cache_set(1, 100).unwrap(); + cache.cache_set(2, 200).unwrap(); + cache.set_ttl(Duration::ZERO); + + assert_eq!( + cache.cache_remove(&1), + Ok(Some(100)), + "cache_remove must return the live value for a disabled-ttl entry" + ); + assert_eq!( + cache.cache_remove_entry(&2), + Ok(Some((2, 200))), + "cache_remove_entry must return Some for a disabled-ttl entry" + ); +} + +#[test] +fn sharded_lru_ttl_remove_under_zero_ttl_returns_live_value() { + let cache = ShardedLruTtlCache::::builder() + .max_size(64) + .shards(1) + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedLruTtlCache"); + cache.cache_set(1, 100).unwrap(); + cache.cache_set(2, 200).unwrap(); + cache.set_ttl(Duration::ZERO); + + assert_eq!( + cache.cache_remove(&1), + Ok(Some(100)), + "cache_remove must return the live value for a disabled-ttl LRU entry" + ); + assert_eq!( + cache.cache_remove_entry(&2), + Ok(Some((2, 200))), + "cache_remove_entry must return Some for a disabled-ttl LRU entry" + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// LruTtl-specific: refresh_on_hit under a disabled ttl keeps the entry live. +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn sharded_lru_ttl_refresh_on_hit_keeps_zero_ttl_entry_live() { + let cache = ShardedLruTtlCache::::builder() + .max_size(8) + .shards(1) + .refresh_on_hit(true) + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedLruTtlCache"); + + cache.cache_set(1, 10).unwrap(); + assert_eq!(cache.cache_get(&1), Ok(Some(10)), "live before disable"); + + cache.set_ttl(Duration::ZERO); + assert_eq!( + cache.cache_get(&1), + Ok(Some(10)), + "with expiry disabled, the entry stays live across hits" + ); + assert_eq!(cache.len(), 1, "disabled-ttl entry must not be removed"); +} + +#[test] +fn sharded_ttl_refresh_on_hit_keeps_zero_ttl_entry_live() { + let cache = ShardedTtlCache::::builder() + .shards(1) + .refresh_on_hit(true) + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedTtlCache"); + + cache.cache_set(1, 10).unwrap(); + assert_eq!(cache.cache_get(&1), Ok(Some(10))); + + cache.set_ttl(Duration::ZERO); + assert_eq!( + cache.cache_get(&1), + Ok(Some(10)), + "with expiry disabled, the entry stays live across hits" + ); + assert_eq!(cache.len(), 1); +} + +// ───────────────────────────────────────────────────────────────────────────── +// ConcurrentCloneCached expiry-status reads under a disabled (zero) ttl. +// +// cache_get_with_expiry_status / cache_peek_with_expiry_status must report +// (Some(v), false) — the entry is live (never expired) — with no side effects. +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn sharded_ttl_expiry_status_reads_report_zero_ttl_as_live() { + let cache = ShardedTtlCache::::builder() + .shards(1) + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedTtlCache"); + cache.cache_set(1, 42).unwrap(); + cache.set_ttl(Duration::ZERO); + + let (val, expired) = ConcurrentCloneCached::cache_get_with_expiry_status(&cache, &1); + assert_eq!(val, Some(42), "expiry-status read must return the value"); + assert!(!expired, "disabled-ttl entry must report expired=false"); + + let (pval, pexpired) = ConcurrentCloneCached::cache_peek_with_expiry_status(&cache, &1); + assert_eq!(pval, Some(42)); + assert!(!pexpired, "peek must report a disabled-ttl entry as live"); + assert_eq!( + cache.metrics().evictions, + Some(0), + "no eviction from status reads" + ); +} + +#[test] +fn sharded_lru_ttl_expiry_status_reads_report_zero_ttl_as_live() { + let cache = ShardedLruTtlCache::::builder() + .max_size(8) + .shards(1) + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedLruTtlCache"); + cache.cache_set(1, 42).unwrap(); + cache.set_ttl(Duration::ZERO); + + let (val, expired) = ConcurrentCloneCached::cache_get_with_expiry_status(&cache, &1); + assert_eq!(val, Some(42), "expiry-status read must return the value"); + assert!(!expired, "disabled-ttl LRU entry must report expired=false"); + + let (pval, pexpired) = ConcurrentCloneCached::cache_peek_with_expiry_status(&cache, &1); + assert_eq!(pval, Some(42)); + assert!( + !pexpired, + "peek must report a disabled-ttl LRU entry as live" + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// cache_get_or_set_with under a disabled (zero) ttl. +// +// The first call computes and stores the value; subsequent calls hit the live entry, +// so the closure runs exactly once. +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn sharded_ttl_get_or_set_with_caches_under_zero_ttl() { + let cache = ShardedTtlCache::::builder() + .shards(1) + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedTtlCache"); + cache.set_ttl(Duration::ZERO); + + let calls = Arc::new(AtomicU64::new(0)); + for _ in 0..3 { + let calls = calls.clone(); + let v = cache + .cache_get_or_set_with(1, || { + calls.fetch_add(1, Ordering::Relaxed); + 7 + }) + .unwrap(); + assert_eq!(v, 7, "closure value is returned each time"); + } + assert_eq!( + calls.load(Ordering::Relaxed), + 1, + "disabled ttl keeps the entry live -> closure runs once" + ); + assert_eq!( + cache.cache_get(&1), + Ok(Some(7)), + "the entry persists under a disabled ttl" + ); +} + +#[test] +fn sharded_lru_ttl_get_or_set_with_caches_under_zero_ttl() { + let cache = ShardedLruTtlCache::::builder() + .max_size(8) + .shards(1) + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedLruTtlCache"); + cache.set_ttl(Duration::ZERO); + + let calls = Arc::new(AtomicU64::new(0)); + for _ in 0..3 { + let calls = calls.clone(); + let v = cache + .cache_get_or_set_with(1, || { + calls.fetch_add(1, Ordering::Relaxed); + 7 + }) + .unwrap(); + assert_eq!(v, 7); + } + assert_eq!( + calls.load(Ordering::Relaxed), + 1, + "disabled ttl keeps the entry live on the LRU store too" + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Debug output: both a zero ttl and an unset ttl resolve to None and print as None. +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn sharded_ttl_debug_prints_disabled_ttl_as_none() { + let cache = ShardedTtlCache::::builder() + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedTtlCache"); + + cache.set_ttl(Duration::ZERO); + let zero_dbg = format!("{cache:?}"); + assert!( + zero_dbg.contains("ttl: None"), + "a disabled (zero) ttl must Debug-print as None, got: {zero_dbg}" + ); + + cache.unset_ttl(); + let unset_dbg = format!("{cache:?}"); + assert!( + unset_dbg.contains("ttl: None"), + "unset ttl must Debug-print as None, got: {unset_dbg}" + ); + + // A real ttl prints as Some(..). + cache.set_ttl(Duration::from_secs(5)); + let some_dbg = format!("{cache:?}"); + assert!( + some_dbg.contains("ttl: Some("), + "a non-zero ttl must Debug-print as Some(..), got: {some_dbg}" + ); +} + +#[test] +fn sharded_lru_ttl_debug_prints_disabled_ttl_as_none() { + let cache = ShardedLruTtlCache::::builder() + .max_size(8) + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedLruTtlCache"); + + cache.set_ttl(Duration::ZERO); + let zero_dbg = format!("{cache:?}"); + assert!( + zero_dbg.contains("ttl: None"), + "a disabled (zero) ttl must Debug-print as None, got: {zero_dbg}" + ); + + cache.unset_ttl(); + let unset_dbg = format!("{cache:?}"); + assert!( + unset_dbg.contains("ttl: None"), + "unset ttl must Debug-print as None, got: {unset_dbg}" + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// deep_clone carries the disabled ttl: a zero-ttl source clones to a never-expire cache, +// identical to an unset source. +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn sharded_ttl_deep_clone_carries_disabled_ttl() { + let src = ShardedTtlCache::::builder() + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedTtlCache"); + src.set_ttl(Duration::ZERO); + + let clone = src.deep_clone(); + assert_eq!( + clone.ttl(), + None, + "deep_clone must carry the disabled ttl (resolves to None)" + ); + clone.cache_set(1, 10).unwrap(); + assert_eq!( + clone.cache_get(&1), + Ok(Some(10)), + "the deep-cloned disabled-ttl cache must never expire entries" + ); +} + +#[test] +fn sharded_ttl_deep_clone_carries_unset_ttl() { + let src = ShardedTtlCache::::builder() + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedTtlCache"); + src.unset_ttl(); + + let clone = src.deep_clone(); + assert_eq!( + clone.ttl(), + None, + "deep_clone of an unset cache must stay unset" + ); + clone.cache_set(1, 10).unwrap(); + assert_eq!( + clone.cache_get(&1), + Ok(Some(10)), + "the deep-cloned unset cache must never expire" + ); +} + +#[test] +fn sharded_ttl_deep_clone_carries_nonzero_ttl() { + let src = ShardedTtlCache::::builder() + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedTtlCache"); + src.set_ttl(Duration::from_secs(30)); + + let clone = src.deep_clone(); + assert_eq!( + clone.ttl(), + Some(Duration::from_secs(30)), + "deep_clone must carry a non-zero ttl unchanged" + ); +} + +#[test] +fn sharded_lru_ttl_deep_clone_carries_disabled_and_nonzero_ttl() { + let src = ShardedLruTtlCache::::builder() + .max_size(8) + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedLruTtlCache"); + + src.set_ttl(Duration::ZERO); + let zclone = src.deep_clone(); + assert_eq!( + zclone.ttl(), + None, + "deep_clone must carry the disabled ttl on the LRU store" + ); + zclone.cache_set(1, 10).unwrap(); + assert_eq!(zclone.cache_get(&1), Ok(Some(10))); + + src.set_ttl(Duration::from_secs(30)); + let nclone = src.deep_clone(); + assert_eq!( + nclone.ttl(), + Some(Duration::from_secs(30)), + "deep_clone must carry a non-zero ttl on the LRU store" + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Concurrency: one thread flips set_ttl(nonzero) / set_ttl(ZERO) / unset_ttl while +// others insert and read. Asserts no panic / no deadlock, reads never return another +// key's value, and the cache stays usable and metrics internally sane afterward. +// ───────────────────────────────────────────────────────────────────────────── + +#[test] +fn sharded_ttl_concurrent_ttl_flips_stay_consistent() { + let cache = Arc::new( + ShardedTtlCache::::builder() + .ttl(Duration::from_secs(60)) + .shards(4) + .build() + .expect("build ShardedTtlCache"), + ); + let stop = Arc::new(AtomicBool::new(false)); + const KEYS: u32 = 64; + const ITERS: usize = 2_000; + + let mut handles = Vec::new(); + + // Flipper: cycles through nonzero -> zero(disabled) -> unset(disabled). + { + let cache = cache.clone(); + let stop = stop.clone(); + handles.push(std::thread::spawn(move || { + let mut i = 0usize; + while !stop.load(Ordering::Relaxed) { + match i % 3 { + 0 => { + cache.set_ttl(Duration::from_secs(60)); + } + 1 => { + cache.set_ttl(Duration::ZERO); + } + _ => { + cache.unset_ttl(); + } + } + i += 1; + } + })); + } + + // Writers. + for w in 0..2 { + let cache = cache.clone(); + handles.push(std::thread::spawn(move || { + for n in 0..ITERS { + let k = ((n as u32).wrapping_add(w)) % KEYS; + cache.cache_set(k, k.wrapping_mul(10)).unwrap(); + } + })); + } + + // Readers — each read returns Ok (Infallible); a present value is always this key's. + for _ in 0..2 { + let cache = cache.clone(); + handles.push(std::thread::spawn(move || { + for n in 0..ITERS { + let k = (n as u32) % KEYS; + let got = cache.cache_get(&k).expect("infallible"); + if let Some(v) = got { + assert_eq!(v, k.wrapping_mul(10), "read must return this key's value"); + } + } + })); + } + + std::thread::sleep(std::time::Duration::from_millis(50)); + stop.store(true, Ordering::Relaxed); + for h in handles { + h.join().expect("no thread panicked"); + } + + // Settle to a known ttl and verify the cache is still usable and consistent. + cache.set_ttl(Duration::from_secs(60)); + cache.clear(); + cache.cache_set(1, 10).unwrap(); + assert_eq!( + cache.cache_get(&1), + Ok(Some(10)), + "cache must remain usable after concurrent ttl flips" + ); + + let m = cache.metrics(); + assert!( + m.entry_count <= KEYS as usize, + "size must never exceed the distinct key count, got {}", + m.entry_count + ); +} + +#[test] +fn sharded_lru_ttl_concurrent_ttl_flips_stay_consistent() { + let cache = Arc::new( + ShardedLruTtlCache::::builder() + .max_size(256) + .shards(4) + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedLruTtlCache"), + ); + let stop = Arc::new(AtomicBool::new(false)); + const KEYS: u32 = 64; + const ITERS: usize = 2_000; + + let mut handles = Vec::new(); + + { + let cache = cache.clone(); + let stop = stop.clone(); + handles.push(std::thread::spawn(move || { + let mut i = 0usize; + while !stop.load(Ordering::Relaxed) { + match i % 3 { + 0 => { + cache.set_ttl(Duration::from_secs(60)); + } + 1 => { + cache.set_ttl(Duration::ZERO); + } + _ => { + cache.unset_ttl(); + } + } + i += 1; + } + })); + } + + for w in 0..2 { + let cache = cache.clone(); + handles.push(std::thread::spawn(move || { + for n in 0..ITERS { + let k = ((n as u32).wrapping_add(w)) % KEYS; + cache.cache_set(k, k.wrapping_mul(10)).unwrap(); + } + })); + } + + for _ in 0..2 { + let cache = cache.clone(); + handles.push(std::thread::spawn(move || { + for n in 0..ITERS { + let k = (n as u32) % KEYS; + let got = cache.cache_get(&k).expect("infallible"); + if let Some(v) = got { + assert_eq!(v, k.wrapping_mul(10), "read must return this key's value"); + } + } + })); + } + + std::thread::sleep(std::time::Duration::from_millis(50)); + stop.store(true, Ordering::Relaxed); + for h in handles { + h.join().expect("no thread panicked"); + } + + cache.set_ttl(Duration::from_secs(60)); + cache.clear(); + cache.cache_set(1, 10).unwrap(); + assert_eq!(cache.cache_get(&1), Ok(Some(10))); + + let m = cache.metrics(); + assert!( + m.entry_count <= KEYS as usize, + "size must never exceed the distinct key count, got {}", + m.entry_count + ); +} diff --git a/tests/v3_single_owner_zero_ttl.rs b/tests/v3_single_owner_zero_ttl.rs new file mode 100644 index 00000000..51adc015 --- /dev/null +++ b/tests/v3_single_owner_zero_ttl.rs @@ -0,0 +1,579 @@ +//! Outside-in certification of the v3 "`set_ttl(Duration::ZERO)` == expiry disabled" +//! semantic on the SINGLE-OWNER time stores (`TtlCache`, `LruTtlCache`). +//! +//! # v3 per-entry expiry semantics +//! +//! As of v3, each entry stores an absolute `expires_at: Option` computed +//! at INSERT time from the TTL that was active when the entry was inserted. +//! `set_ttl` only affects FUTURE inserts; existing entries keep their original +//! `expires_at`. `set_ttl(Duration::ZERO)` makes new entries never expire +//! (`expires_at = None`), but entries already in the cache still expire at their +//! original deadline. +//! +//! Tests that used to verify that `set_ttl(ZERO)` retroactively kept already- +//! inserted entries live have been updated to match the new contract. +#![cfg(feature = "time_stores")] + +use cached::time::Duration; +use cached::{ + CacheTtl, Cached, CachedIter, CachedPeek, CloneCached, LruTtlCache, SetTtlError, TtlCache, +}; + +// A duration short enough that any nonzero ttl used in these tests expires +// well before the sleep. Must be shorter than SLEEP. +const SHORT: Duration = Duration::from_millis(30); +const SLEEP: std::time::Duration = std::time::Duration::from_millis(80); + +// ─────────────────────────── TtlCache ─────────────────────────── + +// Entries inserted BEFORE set_ttl(ZERO) keep their original expires_at and +// therefore still expire. Entries inserted AFTER never expire. +#[test] +fn ttl_cache_set_zero_only_affects_future_inserts() { + let mut c = TtlCache::::builder() + .ttl(SHORT) + .build() + .expect("build TtlCache"); + + // Insert under SHORT ttl; this entry gets expires_at = now + 30ms. + c.cache_set(1, 10); + + let prev = c.set_ttl(Duration::ZERO); + assert_eq!(prev, Some(SHORT), "set_ttl returns the prior ttl"); + assert_eq!(c.ttl(), None, "zero ttl resolves to None"); + + // Insert AFTER set_ttl(ZERO); this entry gets expires_at = None (never-expires). + c.cache_set(2, 20); + + std::thread::sleep(SLEEP); // 80ms > 30ms: entry 1 has expired + + // Entry 1 (inserted before set_ttl) must be expired now. + assert_eq!( + c.cache_get(&1), + None, + "entry inserted before set_ttl(ZERO) must expire at its original deadline" + ); + + // Entry 2 (inserted after set_ttl(ZERO)) must still be live. + assert_eq!( + c.cache_get(&2), + Some(&20), + "entry inserted after set_ttl(ZERO) must never expire" + ); +} + +#[test] +fn ttl_cache_pre_zero_entry_expired_on_all_paths() { + let mut c = TtlCache::::builder() + .ttl(SHORT) + .build() + .expect("build TtlCache"); + c.cache_set(1, 10); + c.set_ttl(Duration::ZERO); + std::thread::sleep(SLEEP); + + // cache_peek must not see the expired entry. + assert_eq!( + c.cache_peek(&1), + None, + "cache_peek must not return an expired entry" + ); + + // CloneCached status reads must report expired. + let (val, expired) = c.cache_peek_with_expiry_status(&1); + assert_eq!(val, Some(10)); + assert!(expired, "peek_with_expiry_status must report expired entry"); + + let (val2, expired2) = c.cache_get_with_expiry_status(&1); + assert_eq!(val2, Some(10)); + assert!(expired2, "get_with_expiry_status must report expired entry"); + + // iter must not yield the expired entry. + let items: Vec<(u32, u32)> = c.iter().map(|(k, v)| (*k, *v)).collect(); + assert!(items.is_empty(), "iter must exclude expired entries"); +} + +#[test] +fn ttl_cache_post_zero_entry_live_on_all_paths() { + let mut c = TtlCache::::builder() + .ttl(SHORT) + .build() + .expect("build TtlCache"); + c.set_ttl(Duration::ZERO); + // Insert AFTER disabling; entry gets expires_at = None. + c.cache_set(1, 10); + std::thread::sleep(SLEEP); + + // iter must yield the entry. + let items: Vec<(u32, u32)> = c.iter().map(|(k, v)| (*k, *v)).collect(); + assert_eq!( + items, + vec![(1, 10)], + "iter must include entries whose expires_at is None" + ); + + // cache_peek must see it. + assert_eq!(c.cache_peek(&1), Some(&10)); + + // CloneCached status reads must report not-expired. + let (val, expired) = c.cache_peek_with_expiry_status(&1); + assert_eq!(val, Some(10)); + assert!(!expired, "peek_with_expiry_status must report live entry"); + + let (val2, expired2) = c.cache_get_with_expiry_status(&1); + assert_eq!(val2, Some(10)); + assert!(!expired2, "get_with_expiry_status must report live entry"); + + // Plain cache_get must hit. + assert_eq!(c.cache_get(&1), Some(&10)); +} + +#[test] +fn ttl_cache_evict_expires_pre_zero_entries_but_not_post_zero() { + let mut c = TtlCache::::builder() + .ttl(SHORT) + .build() + .expect("build TtlCache"); + // Insert 3 entries under SHORT ttl. + for i in 0..3u32 { + c.cache_set(i, i * 10); + } + c.set_ttl(Duration::ZERO); + // Insert 2 entries under disabled (never-expire) ttl. + c.cache_set(10, 100); + c.cache_set(11, 110); + + std::thread::sleep(SLEEP); + + // evict() must only remove the 3 expired entries (keys 0-2), not the never-expire ones. + let removed = c.evict(); + assert_eq!( + removed, 3, + "evict must remove exactly the 3 expired entries" + ); + assert_eq!(c.cache_size(), 2, "two never-expire entries must remain"); + assert_eq!(c.cache_get(&10), Some(&100)); + assert_eq!(c.cache_get(&11), Some(&110)); +} + +#[test] +fn ttl_cache_disabled_ttl_get_or_set_with_recomputes_expired_entry() { + // When an entry was inserted under SHORT ttl and that ttl has elapsed, + // get_or_set_with_mut must recompute (the entry is expired), not hit. + let mut c = TtlCache::::builder() + .ttl(SHORT) + .build() + .expect("build TtlCache"); + c.cache_set(1, 10); + c.set_ttl(Duration::ZERO); + std::thread::sleep(SLEEP); + + let v = c.cache_get_or_set_with_mut(1, || 999); + assert_eq!( + *v, 999, + "expired entry must be replaced by get_or_set; new entry never expires" + ); + // The newly inserted entry must itself never expire. + std::thread::sleep(SLEEP); + assert_eq!(c.cache_get(&1), Some(&999)); +} + +#[cfg(feature = "async")] +#[tokio::test] +async fn ttl_cache_disabled_ttl_async_get_or_set_recomputes_expired_entry() { + use cached::CachedAsync; + let mut c = TtlCache::::builder() + .ttl(SHORT) + .build() + .expect("build TtlCache"); + c.cache_set(1, 10); + c.set_ttl(Duration::ZERO); + std::thread::sleep(SLEEP); + + let v = c.async_cache_get_or_set_with(1, || async { 999 }).await; + assert_eq!(*v, 999, "async get_or_set must recompute the expired entry"); +} + +// gap 4: full state machine + prior-value contract on TtlCache. + +#[test] +fn ttl_cache_set_unset_ttl_state_machine_and_prior_values() { + let mut c = TtlCache::::builder() + .ttl(Duration::from_secs(60)) + .build() + .expect("build TtlCache"); + assert_eq!(c.ttl(), Some(Duration::from_secs(60))); + + // built(60) -> set(30): returns prior Some(60). + assert_eq!( + c.set_ttl(Duration::from_secs(30)), + Some(Duration::from_secs(60)) + ); + assert_eq!(c.ttl(), Some(Duration::from_secs(30))); + + // set(30) -> set(0): returns prior Some(30); ttl resolves None. + assert_eq!(c.set_ttl(Duration::ZERO), Some(Duration::from_secs(30))); + assert_eq!(c.ttl(), None); + + // set(0) -> set(0) again: prior was disabled so returns None. + assert_eq!( + c.set_ttl(Duration::ZERO), + None, + "setting zero when already disabled reports no prior ttl" + ); + + // set(0) -> set(nonzero): prior was disabled => None. + assert_eq!( + c.set_ttl(Duration::from_secs(15)), + None, + "re-arming from disabled reports no prior ttl" + ); + assert_eq!(c.ttl(), Some(Duration::from_secs(15))); + + // set(15) -> unset(): returns prior Some(15); ttl None. + assert_eq!(c.unset_ttl(), Some(Duration::from_secs(15))); + assert_eq!(c.ttl(), None); + + // unset when already disabled => None. + assert_eq!( + c.unset_ttl(), + None, + "unset when disabled reports no prior ttl" + ); +} + +#[test] +fn ttl_cache_set_zero_and_unset_both_disable_future_expiry() { + // Both set_ttl(ZERO) and unset_ttl() should make FUTURE inserts never expire. + let mut via_zero = TtlCache::::builder() + .ttl(SHORT) + .build() + .expect("build"); + let mut via_unset = TtlCache::::builder() + .ttl(SHORT) + .build() + .expect("build"); + + // Disable expiry first, then insert. + assert_eq!(via_zero.set_ttl(Duration::ZERO), Some(SHORT)); + assert_eq!(via_unset.unset_ttl(), Some(SHORT)); + + via_zero.cache_set(1, 10); + via_unset.cache_set(1, 10); + + assert_eq!(via_zero.ttl(), via_unset.ttl()); + assert_eq!(via_zero.ttl(), None); + + std::thread::sleep(SLEEP); + // Both entries (inserted after disabling) must still be live. + assert_eq!(via_zero.cache_get(&1), Some(&10)); + assert_eq!(via_unset.cache_get(&1), Some(&10)); +} + +// ─────────────────────────── LruTtlCache ─────────────────────────── + +#[test] +fn lru_ttl_cache_set_zero_only_affects_future_inserts() { + let mut c = LruTtlCache::::builder() + .max_size(8) + .ttl(SHORT) + .build() + .expect("build LruTtlCache"); + c.cache_set(1, 10); + c.cache_set(2, 20); + + assert_eq!(c.set_ttl(Duration::ZERO), Some(SHORT)); + assert_eq!(c.ttl(), None); + + // Insert AFTER disabling; these get expires_at = None. + c.cache_set(3, 30); + c.cache_set(4, 40); + + std::thread::sleep(SLEEP); + + // Entries 1 and 2 (inserted before disabling) must be expired. + assert_eq!( + c.cache_get(&1), + None, + "entry 1 inserted before set_ttl(ZERO) must expire" + ); + assert_eq!( + c.cache_get(&2), + None, + "entry 2 inserted before set_ttl(ZERO) must expire" + ); + + // Entries 3 and 4 (inserted after disabling) must be live. + assert_eq!(c.cache_get(&3), Some(&30)); + assert_eq!(c.cache_get(&4), Some(&40)); +} + +#[test] +fn lru_ttl_cache_post_zero_entry_live_on_iter_peek_status_and_orders() { + let mut c = LruTtlCache::::builder() + .max_size(8) + .ttl(SHORT) + .build() + .expect("build LruTtlCache"); + + // Disable first, then insert; entries get expires_at = None. + c.set_ttl(Duration::ZERO); + c.cache_set(1, 10); + c.cache_set(2, 20); + std::thread::sleep(SLEEP); + + // iter (CachedIter) must include both. + let mut items: Vec<(u32, u32)> = c.iter().map(|(k, v)| (*k, *v)).collect(); + items.sort_unstable(); + assert_eq!(items, vec![(1, 10), (2, 20)]); + + // iter_order / key_order / value_order all filter by entry_live and must + // include the never-expiring entries. + assert_eq!(c.iter_order().len(), 2, "iter_order must keep live entries"); + assert_eq!(c.key_order().len(), 2, "key_order must keep live entries"); + assert_eq!( + c.value_order().len(), + 2, + "value_order must keep live entries" + ); + + // peek and status reads. + assert_eq!(c.cache_peek(&1), Some(&10)); + let (v, exp) = c.cache_peek_with_expiry_status(&1); + assert_eq!((v, exp), (Some(10), false)); + let (v2, exp2) = c.cache_get_with_expiry_status(&2); + assert_eq!((v2, exp2), (Some(20), false)); + + assert_eq!(c.cache_get(&1), Some(&10)); +} + +#[test] +fn lru_ttl_cache_evict_expires_pre_zero_entries_but_not_post_zero() { + let mut c = LruTtlCache::::builder() + .max_size(16) + .ttl(SHORT) + .build() + .expect("build LruTtlCache"); + + // Insert 5 entries under SHORT ttl, then disable, then insert 2 more. + for i in 0..5u32 { + c.cache_set(i, i * 10); + } + c.set_ttl(Duration::ZERO); + c.cache_set(10, 100); + c.cache_set(11, 110); + + std::thread::sleep(SLEEP); + + let removed = c.evict(); + assert_eq!( + removed, 5, + "evict must remove exactly the 5 expired entries" + ); + assert_eq!(c.cache_size(), 2, "two never-expire entries must remain"); + assert_eq!(c.cache_get(&10), Some(&100)); + assert_eq!(c.cache_get(&11), Some(&110)); +} + +#[test] +fn lru_ttl_cache_retain_keeps_never_expire_entries() { + let mut c = LruTtlCache::::builder() + .max_size(16) + .ttl(SHORT) + .build() + .expect("build LruTtlCache"); + + // Disable first, insert never-expire entries. + c.set_ttl(Duration::ZERO); + for i in 0..5u32 { + c.cache_set(i, i * 10); + } + std::thread::sleep(SLEEP); + + // retain by even keys. + c.retain(|k, _v| k % 2 == 0); + let mut kept: Vec = c.iter().map(|(k, _)| *k).collect(); + kept.sort_unstable(); + assert_eq!( + kept, + vec![0, 2, 4], + "retain must keep live never-expire entries matching the predicate" + ); +} + +#[test] +fn lru_ttl_cache_disabled_ttl_get_or_set_with_recomputes_expired_entry() { + let mut c = LruTtlCache::::builder() + .max_size(8) + .ttl(SHORT) + .build() + .expect("build LruTtlCache"); + c.cache_set(1, 10); + c.set_ttl(Duration::ZERO); + std::thread::sleep(SLEEP); + + let v = c.cache_get_or_set_with_mut(1, || 999); + assert_eq!( + *v, 999, + "expired entry must be replaced; new entry inserted under disabled ttl never expires" + ); + // The new entry must itself never expire. + std::thread::sleep(SLEEP); + assert_eq!(c.cache_get(&1), Some(&999)); +} + +#[cfg(feature = "async")] +#[tokio::test] +async fn lru_ttl_cache_disabled_ttl_async_get_or_set_recomputes_expired_entry() { + use cached::CachedAsync; + let mut c = LruTtlCache::::builder() + .max_size(8) + .ttl(SHORT) + .build() + .expect("build LruTtlCache"); + c.cache_set(1, 10); + c.set_ttl(Duration::ZERO); + std::thread::sleep(SLEEP); + let v = c.async_cache_get_or_set_with(1, || async { 999 }).await; + assert_eq!( + *v, 999, + "async get_or_set must recompute the expired LRU entry" + ); +} + +#[test] +fn lru_ttl_cache_set_unset_ttl_state_machine_and_prior_values() { + let mut c = LruTtlCache::::builder() + .max_size(8) + .ttl(Duration::from_secs(60)) + .build() + .expect("build LruTtlCache"); + assert_eq!(c.ttl(), Some(Duration::from_secs(60))); + assert_eq!( + c.set_ttl(Duration::from_secs(30)), + Some(Duration::from_secs(60)) + ); + assert_eq!(c.set_ttl(Duration::ZERO), Some(Duration::from_secs(30))); + assert_eq!(c.ttl(), None); + assert_eq!(c.set_ttl(Duration::ZERO), None); + assert_eq!(c.set_ttl(Duration::from_secs(15)), None); + assert_eq!(c.ttl(), Some(Duration::from_secs(15))); + assert_eq!(c.unset_ttl(), Some(Duration::from_secs(15))); + assert_eq!(c.ttl(), None); + assert_eq!(c.unset_ttl(), None); +} + +// gap 5: build()/try_set_ttl still reject zero (regression guard) for the +// single-owner stores. (Sharded build rejection lives in unit tests; this file +// pins the single-owner public path here alongside the rest.) + +#[test] +fn single_owner_build_rejects_zero_ttl() { + let ttl_built = TtlCache::::builder().ttl(Duration::ZERO).build(); + assert!( + matches!( + ttl_built, + Err(cached::BuildError::InvalidValue { field: "ttl", .. }) + ), + "TtlCache build must reject a zero ttl, got {ttl_built:?}" + ); + + let lru_built = LruTtlCache::::builder() + .max_size(4) + .ttl(Duration::ZERO) + .build(); + assert!( + matches!( + lru_built, + Err(cached::BuildError::InvalidValue { field: "ttl", .. }) + ), + "LruTtlCache build must reject a zero ttl, got {lru_built:?}" + ); +} + +#[test] +fn single_owner_try_set_ttl_rejects_zero_but_set_ttl_disables() { + let mut c = TtlCache::::builder() + .ttl(Duration::from_secs(60)) + .build() + .unwrap(); + assert_eq!(c.try_set_ttl(Duration::ZERO), Err(SetTtlError::ZeroTtl)); + assert_eq!( + c.ttl(), + Some(Duration::from_secs(60)), + "rejected try_set_ttl must not change ttl" + ); + // The non-strict route disables. + assert_eq!(c.set_ttl(Duration::ZERO), Some(Duration::from_secs(60))); + assert_eq!(c.ttl(), None); + + let mut l = LruTtlCache::::builder() + .max_size(4) + .ttl(Duration::from_secs(60)) + .build() + .unwrap(); + assert_eq!(l.try_set_ttl(Duration::ZERO), Err(SetTtlError::ZeroTtl)); + assert_eq!(l.ttl(), Some(Duration::from_secs(60))); + assert_eq!(l.set_ttl(Duration::ZERO), Some(Duration::from_secs(60))); + assert_eq!(l.ttl(), None); +} + +// gap 6: As of the v3 zero-ttl-disables change, TtlSortedCache is now IN SCOPE and +// consistent with TtlCache / LruTtlCache: a zero ttl disables expiry for future +// inserts (entries never expire). Its `ttl()` still reports the raw configured +// duration (Some(ZERO), never resolved to None like the per-entry stores), and +// `set_ttl` always returns `Some(prev)`. `unset_ttl` now sets the ttl to zero and +// returns None. Guard the new never-expires semantic. + +#[test] +fn ttl_sorted_cache_zero_disables_expiry() { + use cached::TtlSortedCache; + let mut c = TtlSortedCache::::builder() + .ttl(Duration::from_secs(60)) + .build() + .expect("build TtlSortedCache"); + + // ttl() reports the raw value, including after a zero set, NOT None. + assert_eq!(CacheTtl::ttl(&c), Some(Duration::from_secs(60))); + let prev = CacheTtl::set_ttl(&mut c, Duration::from_secs(30)); + assert_eq!( + prev, + Some(Duration::from_secs(60)), + "set_ttl always returns Some(prev)" + ); + + let prev_zero = CacheTtl::set_ttl(&mut c, Duration::ZERO); + assert_eq!(prev_zero, Some(Duration::from_secs(30))); + assert_eq!( + CacheTtl::ttl(&c), + Some(Duration::ZERO), + "TtlSortedCache ttl() must report the raw zero, NOT resolve it to None" + ); + + // unset_ttl sets the stored ttl to zero and returns None. + assert_eq!( + CacheTtl::unset_ttl(&mut c), + None, + "unset_ttl returns None on TtlSortedCache" + ); + assert_eq!( + CacheTtl::ttl(&c), + Some(Duration::ZERO), + "unset_ttl leaves the ttl at zero (disabled)" + ); + + // set_ttl(0) disables expiry for future inserts: a freshly inserted entry is + // stored with no expiry and is retrievable indefinitely. + c.cache_set(1, 10); + assert_eq!( + c.cache_get(&1), + Some(&10), + "TtlSortedCache with a zero ttl must keep just-inserted entries live (never expires)" + ); + std::thread::sleep(std::time::Duration::from_millis(20)); + assert_eq!( + c.cache_get(&1), + Some(&10), + "the entry must persist (never expires) under zero ttl" + ); +} diff --git a/tests/v3_traits.rs b/tests/v3_traits.rs new file mode 100644 index 00000000..cebfc1f8 --- /dev/null +++ b/tests/v3_traits.rs @@ -0,0 +1,2159 @@ +//! Integration tests for the 3.0 trait additions and audit-fix batch: +//! - `*_mut` get-or-set variants (#179) +//! - `SerializeCached`/`SerializeCachedAsync` borrowed set (#196) +//! - `CacheSetError` concrete error type for `cache_try_set` +//! - `ConcurrentCached::refresh_on_hit` getter default and override +//! - `ConcurrentCached::cache_get_or_set_with` / `async_cache_get_or_set_with` +//! - `store()` getter removal verified via public API + +use cached::{Cached, LruCache, UnboundCache}; + +/// The `_mut` variants return a mutable reference that callers can mutate +/// in place; the resulting change is observable on the next read. +#[test] +fn cache_get_or_set_with_mut_returns_mutable_ref() { + let mut cache: UnboundCache = + UnboundCache::builder().build().expect("build UnboundCache"); + + // Insert via the mutable variant and mutate the returned `&mut V`. + let v: &mut u32 = cache.cache_get_or_set_with_mut(1, || 10); + assert_eq!(*v, 10); + *v += 5; + assert_eq!(cache.cache_get(&1), Some(&15)); + + // The shared-reference variant returns `&V` (it sees the mutated value on hit). + let shared: &u32 = cache.cache_get_or_set_with(1, || 999); + assert_eq!(*shared, 15); +} + +#[test] +fn cache_try_get_or_set_with_mut_returns_mutable_ref() { + let mut cache: UnboundCache = + UnboundCache::builder().build().expect("build UnboundCache"); + + // Err: propagated, nothing cached. + let result: Result<&mut u32, ()> = cache.cache_try_get_or_set_with_mut(1, || Err(())); + assert!(result.is_err()); + assert_eq!(cache.cache_get(&1), None); + + // Ok miss: value inserted; mutate through the returned `&mut V`. + let v: &mut u32 = cache + .cache_try_get_or_set_with_mut(1, || Ok::(10)) + .unwrap(); + assert_eq!(*v, 10); + *v *= 2; + assert_eq!(cache.cache_get(&1), Some(&20)); + + // Shared-ref fallible variant returns `Result<&V, E>`. + let shared: &u32 = cache + .cache_try_get_or_set_with(1, || Ok::(999)) + .unwrap(); + assert_eq!(*shared, 20); +} + +#[test] +fn lru_cache_get_or_set_with_mut_returns_mutable_ref() { + let mut cache: LruCache = LruCache::builder() + .max_size(10) + .build() + .expect("build LruCache"); + + // Miss: body runs, value inserted; mutate through the returned `&mut V`. + let v: &mut u32 = cache.cache_get_or_set_with_mut(1, || 10); + assert_eq!(*v, 10); + *v += 5; + assert_eq!(cache.cache_get(&1), Some(&15)); + + // Hit: body does not run; returns the mutated value. + let hit: &mut u32 = cache.cache_get_or_set_with_mut(1, || 999); + assert_eq!(*hit, 15); +} + +#[test] +fn lru_cache_try_get_or_set_with_mut_returns_mutable_ref() { + let mut cache: LruCache = LruCache::builder() + .max_size(10) + .build() + .expect("build LruCache"); + + // Err: propagated, nothing cached. + let result: Result<&mut u32, ()> = cache.cache_try_get_or_set_with_mut(1, || Err(())); + assert!(result.is_err()); + assert_eq!(cache.cache_get(&1), None); + + // Ok miss: value inserted; mutate through the returned `&mut V`. + let v: &mut u32 = cache + .cache_try_get_or_set_with_mut(1, || Ok::(10)) + .unwrap(); + assert_eq!(*v, 10); + *v *= 2; + assert_eq!(cache.cache_get(&1), Some(&20)); + + // Hit: body does not run; stored value returned. + let hit: &mut u32 = cache + .cache_try_get_or_set_with_mut(1, || Ok::(999)) + .unwrap(); + assert_eq!(*hit, 20); +} + +// ExpiringCache values must implement Expires; use a simple never-expiring wrapper. +mod expiring_cache_mut { + use cached::{Cached, Expires, ExpiringCache}; + + // A value that never expires. ExpiringCache requires V: Expires. + #[derive(Debug, PartialEq, Clone)] + struct Never(u32); + + impl Expires for Never { + fn is_expired(&self) -> bool { + false + } + } + + /// `cache_get_or_set_with_mut` on ExpiringCache: value computed once on miss, + /// returned from cache on hit (body does not run again). + #[test] + fn expiring_cache_get_or_set_with_mut() { + let mut cache: ExpiringCache = ExpiringCache::builder() + .build() + .expect("build ExpiringCache"); + + // Miss: body runs, value inserted. + let v: &mut Never = cache.cache_get_or_set_with_mut(1, || Never(10)); + assert_eq!(*v, Never(10)); + + // Mutate in place and confirm the change is visible on a subsequent get. + v.0 += 5; + assert_eq!(cache.cache_get(&1), Some(&Never(15))); + + // Hit: body does not run; returns the previously stored (mutated) value. + let hit: &mut Never = cache.cache_get_or_set_with_mut(1, || Never(999)); + assert_eq!(*hit, Never(15)); + } + + /// `cache_try_get_or_set_with_mut` on ExpiringCache: Err from setter is propagated + /// and the key is not inserted; Ok path stores and returns a mutable ref. + #[test] + fn expiring_cache_try_get_or_set_with_mut() { + let mut cache: ExpiringCache = ExpiringCache::builder() + .build() + .expect("build ExpiringCache"); + + // Err: propagated, nothing cached. + let result: Result<&mut Never, ()> = cache.cache_try_get_or_set_with_mut(1, || Err(())); + assert!(result.is_err()); + assert_eq!(cache.cache_get(&1), None); + + // Ok miss: value inserted. + let v: &mut Never = cache + .cache_try_get_or_set_with_mut(1, || Ok::(Never(20))) + .unwrap(); + assert_eq!(*v, Never(20)); + v.0 *= 2; + assert_eq!(cache.cache_get(&1), Some(&Never(40))); + + // Hit: body does not run; stored value returned. + let hit: &mut Never = cache + .cache_try_get_or_set_with_mut(1, || Ok::(Never(999))) + .unwrap(); + assert_eq!(*hit, Never(40)); + } +} + +#[cfg(feature = "time_stores")] +mod ttl_sorted_cache_mut { + use cached::{Cached, TtlSortedCache}; + use std::time::Duration; + + /// `cache_get_or_set_with_mut` on TtlSortedCache: value computed once on miss, + /// returned from cache on hit (body does not run again). + #[test] + fn ttl_sorted_cache_get_or_set_with_mut() { + let mut cache: TtlSortedCache = TtlSortedCache::builder() + .ttl(Duration::from_secs(60)) + .build() + .expect("build TtlSortedCache"); + + // Miss: body runs, value inserted. + let v: &mut u32 = cache.cache_get_or_set_with_mut(1, || 10); + assert_eq!(*v, 10); + + // Mutate in place and confirm the change persists. + *v += 5; + assert_eq!(cache.cache_get(&1), Some(&15)); + + // Hit: body does not run; stored (mutated) value returned. + let hit: &mut u32 = cache.cache_get_or_set_with_mut(1, || 999); + assert_eq!(*hit, 15); + } + + /// `cache_try_get_or_set_with_mut` on TtlSortedCache: Err from setter is propagated + /// and the key is not inserted; Ok path stores and returns a mutable ref. + #[test] + fn ttl_sorted_cache_try_get_or_set_with_mut() { + let mut cache: TtlSortedCache = TtlSortedCache::builder() + .ttl(Duration::from_secs(60)) + .build() + .expect("build TtlSortedCache"); + + // Err: propagated, nothing cached. + let result: Result<&mut u32, ()> = cache.cache_try_get_or_set_with_mut(1, || Err(())); + assert!(result.is_err()); + assert_eq!(cache.cache_get(&1), None); + + // Ok miss: value inserted. + let v: &mut u32 = cache + .cache_try_get_or_set_with_mut(1, || Ok::(20)) + .unwrap(); + assert_eq!(*v, 20); + *v *= 2; + assert_eq!(cache.cache_get(&1), Some(&40)); + + // Hit: body does not run; stored value returned. + let hit: &mut u32 = cache + .cache_try_get_or_set_with_mut(1, || Ok::(999)) + .unwrap(); + assert_eq!(*hit, 40); + } +} + +// ── try_set_ttl (#10) ───────────────────────────────────────────────────────── + +#[cfg(feature = "time_stores")] +mod try_set_ttl_tests { + use cached::{CacheTtl, Cached, LruTtlCache, SetTtlError, TtlCache, TtlSortedCache}; + use std::time::Duration; + + /// `try_set_ttl` returns `Err(SetTtlError::ZeroTtl)` for a zero Duration + /// and does not change the TTL. + #[test] + fn ttl_cache_try_set_ttl_rejects_zero() { + let mut cache = TtlCache::::builder() + .ttl(Duration::from_secs(10)) + .build() + .expect("build TtlCache"); + let prev_ttl = cache.ttl(); + let result = cache.try_set_ttl(Duration::ZERO); + assert_eq!(result, Err(SetTtlError::ZeroTtl)); + // TTL must be unchanged after a rejected call. + assert_eq!(cache.ttl(), prev_ttl); + } + + /// `try_set_ttl` returns `Ok(prev_ttl)` for a non-zero Duration and + /// updates the TTL. + #[test] + fn ttl_cache_try_set_ttl_accepts_nonzero() { + let mut cache = TtlCache::::builder() + .ttl(Duration::from_secs(10)) + .build() + .expect("build TtlCache"); + let result = cache.try_set_ttl(Duration::from_secs(30)); + assert_eq!(result, Ok(Some(Duration::from_secs(10)))); + assert_eq!(cache.ttl(), Some(Duration::from_secs(30))); + } + + /// `try_set_ttl` on LruTtlCache: same contract as TtlCache. + #[test] + fn lru_ttl_cache_try_set_ttl_rejects_zero() { + let mut cache = LruTtlCache::::builder() + .max_size(8) + .ttl(Duration::from_secs(5)) + .build() + .expect("build LruTtlCache"); + assert_eq!(cache.try_set_ttl(Duration::ZERO), Err(SetTtlError::ZeroTtl)); + } + + #[test] + fn lru_ttl_cache_try_set_ttl_accepts_nonzero() { + let mut cache = LruTtlCache::::builder() + .max_size(8) + .ttl(Duration::from_secs(5)) + .build() + .expect("build LruTtlCache"); + let prev = cache.try_set_ttl(Duration::from_secs(20)); + assert_eq!(prev, Ok(Some(Duration::from_secs(5)))); + assert_eq!(cache.ttl(), Some(Duration::from_secs(20))); + } + + /// `try_set_ttl` on TtlSortedCache: same contract. + #[test] + fn ttl_sorted_cache_try_set_ttl_rejects_zero() { + let mut cache = TtlSortedCache::::builder() + .ttl(Duration::from_secs(15)) + .build() + .expect("build TtlSortedCache"); + assert_eq!(cache.try_set_ttl(Duration::ZERO), Err(SetTtlError::ZeroTtl)); + } + + #[test] + fn ttl_sorted_cache_try_set_ttl_accepts_nonzero() { + let mut cache = TtlSortedCache::::builder() + .ttl(Duration::from_secs(15)) + .build() + .expect("build TtlSortedCache"); + let prev = cache.try_set_ttl(Duration::from_secs(45)); + assert_eq!(prev, Ok(Some(Duration::from_secs(15)))); + assert_eq!(cache.ttl(), Some(Duration::from_secs(45))); + } + + /// Display impl for SetTtlError prints the expected message. + #[test] + fn set_ttl_error_display() { + let msg = format!("{}", SetTtlError::ZeroTtl); + assert_eq!(msg, "ttl must be greater than zero"); + } + + /// `try_set_ttl` is the strict "give me a real ttl" path: it rejects a zero ttl + /// with `Err(ZeroTtl)` and leaves the ttl unchanged. It exists alongside two + /// disabling routes — `set_ttl(0)` and `unset_ttl()` — so callers who want a + /// zero rejected (rather than interpreted as "disable expiry") opt in explicitly. + /// + /// As of the v3 zero-ttl-disables change, `TtlSortedCache` is now consistent with + /// `TtlCache` / `LruTtlCache`: a bypassed `set_ttl(0)` disables expiry for future + /// inserts (the entry never expires) rather than expiring it immediately. + fn assert_zero_ttl_disables_expiry + CacheTtl>(cache: &mut C) { + // Force zero TTL via the panic-free set_ttl, then prove the entry survives. + let _ = cache.set_ttl(Duration::ZERO); + cache.cache_set(7, 70); + assert_eq!( + cache.cache_get(&7), + Some(&70), + "a zero ttl must disable expiry so a just-inserted entry survives", + ); + } + + #[test] + fn ttl_cache_try_set_ttl_rejects_zero_set_ttl_disables() { + let mut cache = TtlCache::::builder() + .ttl(Duration::from_secs(10)) + .build() + .expect("build TtlCache"); + + // try_set_ttl rejects zero without panicking and without touching the ttl. + let prev = cache.ttl(); + assert_eq!(cache.try_set_ttl(Duration::ZERO), Err(SetTtlError::ZeroTtl)); + assert_eq!( + cache.ttl(), + prev, + "rejected try_set_ttl must not change ttl" + ); + + // The cache still works after the rejected call. + cache.cache_set(1, 10); + assert_eq!(cache.cache_get(&1), Some(&10)); + + // set_ttl(ZERO) disables expiry (== unset_ttl): a just-inserted entry survives. + let mut disabled = TtlCache::::builder() + .ttl(Duration::from_secs(10)) + .build() + .expect("build TtlCache"); + let _ = disabled.set_ttl(Duration::ZERO); + assert_eq!(disabled.ttl(), None, "set_ttl(0) resolves ttl to None"); + disabled.cache_set(7, 70); + assert_eq!( + disabled.cache_get(&7), + Some(&70), + "set_ttl(0) must NOT expire a just-inserted entry" + ); + } + + #[test] + fn lru_ttl_cache_try_set_ttl_rejects_zero_set_ttl_disables() { + let mut cache = LruTtlCache::::builder() + .max_size(8) + .ttl(Duration::from_secs(10)) + .build() + .expect("build LruTtlCache"); + + let prev = cache.ttl(); + assert_eq!(cache.try_set_ttl(Duration::ZERO), Err(SetTtlError::ZeroTtl)); + assert_eq!( + cache.ttl(), + prev, + "rejected try_set_ttl must not change ttl" + ); + cache.cache_set(1, 10); + assert_eq!(cache.cache_get(&1), Some(&10)); + + let mut disabled = LruTtlCache::::builder() + .max_size(8) + .ttl(Duration::from_secs(10)) + .build() + .expect("build LruTtlCache"); + let _ = disabled.set_ttl(Duration::ZERO); + assert_eq!(disabled.ttl(), None, "set_ttl(0) resolves ttl to None"); + disabled.cache_set(7, 70); + assert_eq!( + disabled.cache_get(&7), + Some(&70), + "set_ttl(0) must NOT expire a just-inserted LRU entry" + ); + } + + #[test] + fn ttl_sorted_cache_try_set_ttl_prevents_zero_ttl_breakage() { + let mut cache = TtlSortedCache::::builder() + .ttl(Duration::from_secs(10)) + .build() + .expect("build TtlSortedCache"); + + let prev = cache.ttl(); + assert_eq!(cache.try_set_ttl(Duration::ZERO), Err(SetTtlError::ZeroTtl)); + assert_eq!( + cache.ttl(), + prev, + "rejected try_set_ttl must not change ttl" + ); + cache.cache_set(1, 10); + assert_eq!(cache.cache_get(&1), Some(&10)); + + // TtlSortedCache now matches the other TTL stores: a bypassed set_ttl(ZERO) + // disables expiry for future inserts (the entry never expires). + let mut disabled = TtlSortedCache::::builder() + .ttl(Duration::from_secs(10)) + .build() + .expect("build TtlSortedCache"); + assert_zero_ttl_disables_expiry(&mut disabled); + } + + /// `SetTtlError` is a well-formed std error type: it is `Debug`, implements + /// `std::error::Error`, can be boxed as `Box`, and has no source. + #[test] + fn set_ttl_error_is_std_error() { + use std::error::Error; + + let err = SetTtlError::ZeroTtl; + + // Debug formatting works and names the variant. + assert_eq!(format!("{err:?}"), "ZeroTtl"); + + // It implements std::error::Error and can be boxed as a trait object. + let boxed: Box = Box::new(err.clone()); + assert_eq!(boxed.to_string(), "ttl must be greater than zero"); + + // It is a leaf error: no underlying source. + assert!( + err.source().is_none(), + "SetTtlError::ZeroTtl must not report a source" + ); + assert!(boxed.source().is_none()); + } +} + +// ── len / is_empty on ConcurrentCached (Tier3) ──────────────────────────────── + +mod concurrent_len_is_empty { + use cached::{ConcurrentCacheBase, ConcurrentCached, ShardedLruCache, ShardedUnboundCache}; + + /// `len` and `is_empty` on ShardedUnboundCache agree with the number of + /// inserted entries. Uses fully-qualified syntax because the sharded base types + /// have inherent `len`/`is_empty` methods with different signatures (returning + /// plain `usize`/`bool`); the trait methods return `Result>`. + #[test] + fn sharded_unbound_cache_len_is_empty() { + let cache: ShardedUnboundCache = ShardedUnboundCache::builder() + .build() + .expect("build ShardedUnboundCache"); + + // Empty initially. + assert_eq!(ConcurrentCacheBase::is_empty(&cache), Ok(Some(true))); + assert_eq!(ConcurrentCacheBase::len(&cache), Ok(Some(0))); + + cache.cache_set(1, 10).unwrap(); + assert_eq!(ConcurrentCacheBase::is_empty(&cache), Ok(Some(false))); + assert_eq!(ConcurrentCacheBase::len(&cache), Ok(Some(1))); + + cache.cache_set(2, 20).unwrap(); + assert_eq!(ConcurrentCacheBase::len(&cache), Ok(Some(2))); + + cache.cache_remove(&1).unwrap(); + assert_eq!(ConcurrentCacheBase::len(&cache), Ok(Some(1))); + assert_eq!(ConcurrentCacheBase::is_empty(&cache), Ok(Some(false))); + + cache.cache_clear().unwrap(); + assert_eq!(ConcurrentCacheBase::len(&cache), Ok(Some(0))); + assert_eq!(ConcurrentCacheBase::is_empty(&cache), Ok(Some(true))); + } + + /// `len` and `is_empty` on ShardedLruCache. + #[test] + fn sharded_lru_cache_len_is_empty() { + let cache: ShardedLruCache = ShardedLruCache::builder() + .max_size(16) + .build() + .expect("build ShardedLruCache"); + + assert_eq!(ConcurrentCacheBase::is_empty(&cache), Ok(Some(true))); + assert_eq!(ConcurrentCacheBase::len(&cache), Ok(Some(0))); + + cache.cache_set(42, 99).unwrap(); + assert_eq!(ConcurrentCacheBase::len(&cache), Ok(Some(1))); + assert_eq!(ConcurrentCacheBase::is_empty(&cache), Ok(Some(false))); + + cache.cache_set(43, 100).unwrap(); + assert_eq!(ConcurrentCacheBase::len(&cache), Ok(Some(2))); + + cache.cache_reset().unwrap(); + assert_eq!(ConcurrentCacheBase::len(&cache), Ok(Some(0))); + assert_eq!(ConcurrentCacheBase::is_empty(&cache), Ok(Some(true))); + } +} + +// ── async len / is_empty on ConcurrentCachedAsync (Tier3) ───────────────────── + +#[cfg(feature = "async")] +mod concurrent_len_is_empty_async { + #[cfg(feature = "time_stores")] + use cached::ShardedTtlCache; + use cached::{ConcurrentCacheBase, ConcurrentCachedAsync, ShardedUnboundCache}; + + /// Async `len`/`is_empty` on ShardedUnboundCache track the live entry count. + /// Fully-qualified syntax is required because the concrete sharded type has + /// inherent `len`/`is_empty` returning plain `usize`/`bool`; the trait methods + /// return `Result>`. + #[tokio::test] + async fn sharded_unbound_cache_async_len_is_empty() { + let cache: ShardedUnboundCache = ShardedUnboundCache::builder() + .build() + .expect("build ShardedUnboundCache"); + + assert_eq!(ConcurrentCacheBase::is_empty(&cache), Ok(Some(true))); + assert_eq!(ConcurrentCacheBase::len(&cache), Ok(Some(0))); + + ConcurrentCachedAsync::async_cache_set(&cache, 1, 10) + .await + .expect("infallible"); + assert_eq!(ConcurrentCacheBase::is_empty(&cache), Ok(Some(false))); + assert_eq!(ConcurrentCacheBase::len(&cache), Ok(Some(1))); + + ConcurrentCachedAsync::async_cache_set(&cache, 2, 20) + .await + .expect("infallible"); + assert_eq!(ConcurrentCacheBase::len(&cache), Ok(Some(2))); + + ConcurrentCachedAsync::async_cache_clear(&cache) + .await + .expect("infallible"); + assert_eq!(ConcurrentCacheBase::len(&cache), Ok(Some(0))); + assert_eq!(ConcurrentCacheBase::is_empty(&cache), Ok(Some(true))); + } + + /// Async `len`/`is_empty` on ShardedTtlCache, exercising the time-bounded store. + #[cfg(feature = "time_stores")] + #[tokio::test] + async fn sharded_ttl_cache_async_len_is_empty() { + use std::time::Duration; + + let cache: ShardedTtlCache = ShardedTtlCache::builder() + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedTtlCache"); + + assert_eq!(ConcurrentCacheBase::is_empty(&cache), Ok(Some(true))); + assert_eq!(ConcurrentCacheBase::len(&cache), Ok(Some(0))); + + ConcurrentCachedAsync::async_cache_set(&cache, 1, 10) + .await + .expect("infallible"); + ConcurrentCachedAsync::async_cache_set(&cache, 2, 20) + .await + .expect("infallible"); + assert_eq!(ConcurrentCacheBase::len(&cache), Ok(Some(2))); + assert_eq!(ConcurrentCacheBase::is_empty(&cache), Ok(Some(false))); + + ConcurrentCachedAsync::async_cache_reset(&cache) + .await + .expect("infallible"); + assert_eq!(ConcurrentCacheBase::len(&cache), Ok(Some(0))); + assert_eq!(ConcurrentCacheBase::is_empty(&cache), Ok(Some(true))); + } +} + +// ── ConcurrentCloneCached::cache_peek_with_expiry_status (integration) ───────── + +#[cfg(feature = "time_stores")] +mod concurrent_clone_cached_peek { + use cached::{ConcurrentCached, ConcurrentCloneCached, ShardedTtlCache}; + use std::time::Duration; + + /// Through the public `ShardedTtlCache` alias, `cache_peek_with_expiry_status` + /// is side-effect-free: a live entry returns `(Some(v), false)`, an absent key + /// returns `(None, false)`, and neither touches hit/miss/eviction counters. + #[test] + fn peek_live_and_absent_no_counter_change() { + let cache: ShardedTtlCache = ShardedTtlCache::builder() + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedTtlCache"); + + ConcurrentCached::cache_set(&cache, 1, 42).expect("infallible"); + + let before = cache.metrics(); + + let (val, expired) = ConcurrentCloneCached::cache_peek_with_expiry_status(&cache, &1); + assert_eq!(val, Some(42), "live peek returns the value"); + assert!(!expired, "live entry reports expired=false"); + + let (absent, absent_expired) = + ConcurrentCloneCached::cache_peek_with_expiry_status(&cache, &999); + assert_eq!(absent, None, "absent key returns None"); + assert!(!absent_expired, "absent key reports expired=false"); + + let after = cache.metrics(); + assert_eq!(after.hits, before.hits, "peek must not change hits"); + assert_eq!(after.misses, before.misses, "peek must not change misses"); + assert_eq!( + after.evictions, before.evictions, + "peek must not change evictions" + ); + } + + /// An expired entry is returned as a stale fallback (`(Some(v), true)`) and is + /// neither removed nor counted. The entry survives the peek so a later read can + /// still see it. + #[test] + fn peek_expired_returns_stale_without_removal() { + let cache: ShardedTtlCache = ShardedTtlCache::builder() + .ttl(Duration::from_millis(10)) + .build() + .expect("build ShardedTtlCache"); + + ConcurrentCached::cache_set(&cache, 1, 77).expect("infallible"); + std::thread::sleep(Duration::from_millis(50)); + + let before = cache.metrics(); + + let (val, expired) = ConcurrentCloneCached::cache_peek_with_expiry_status(&cache, &1); + assert_eq!(val, Some(77), "expired peek returns the stale value"); + assert!(expired, "expired entry reports expired=true"); + + let after = cache.metrics(); + assert_eq!(after.hits, before.hits, "expired peek must not change hits"); + assert_eq!( + after.misses, before.misses, + "expired peek must not change misses" + ); + assert_eq!( + after.evictions, before.evictions, + "expired peek must not evict" + ); + + // Entry still present (not removed by peek): a second peek still finds it stale. + let (val2, expired2) = ConcurrentCloneCached::cache_peek_with_expiry_status(&cache, &1); + assert_eq!(val2, Some(77), "entry must survive the peek"); + assert!(expired2, "entry must still be expired after peek"); + } +} + +#[cfg(feature = "redb_store")] +mod redb_serialize_cached { + use cached::stores::RedbCache; + use cached::time::Duration; + use cached::{ConcurrentCached, SerializeCached}; + use tempfile::TempDir; + + fn build_cache(dir: &TempDir, name: &str) -> RedbCache { + RedbCache::::builder() + .name(name) + .disk_directory(dir.path()) + .build() + .expect("error building redb cache") + } + + /// `cache_set_ref` takes `&K, &V` (no clone needed at the call site) and + /// round-trips through the same store as `cache_set`. + #[test] + fn cache_set_ref_round_trip() { + let dir = TempDir::new().unwrap(); + let cache = build_cache(&dir, "serialize_cached_round_trip"); + + let key: u32 = 42; + let value: String = "hello".to_string(); + + // Borrowed set: `key` and `value` are still owned by the caller afterward. + let prev = cache + .cache_set_ref(&key, &value) + .expect("cache_set_ref failed"); + assert_eq!(prev, None); + assert_eq!(key, 42); + assert_eq!(value, "hello"); + + // Read back the value written via the borrowed setter. + assert_eq!(cache.cache_get(&key).unwrap(), Some("hello".to_string())); + + // Overwriting returns the previous value (proving same storage as cache_set). + let prev = cache + .cache_set_ref(&key, &"world".to_string()) + .expect("cache_set_ref overwrite failed"); + assert_eq!(prev, Some("hello".to_string())); + assert_eq!(cache.cache_get(&key).unwrap(), Some("world".to_string())); + } + + /// A value written via `cache_set` reads back identically to one written via + /// `cache_set_ref` — the borrowed serialize path is byte-compatible. + #[test] + fn cache_set_ref_matches_cache_set() { + let dir = TempDir::new().unwrap(); + let cache = build_cache(&dir, "serialize_cached_compat"); + + cache.cache_set(1, "owned".to_string()).unwrap(); + cache.cache_set_ref(&2, &"owned".to_string()).unwrap(); + + assert_eq!(cache.cache_get(&1).unwrap(), cache.cache_get(&2).unwrap()); + } + + /// A value written via `cache_set_ref` carries a `created_at` timestamp that the + /// expiry check reads. After sleeping past the TTL the entry must be absent. + #[test] + fn cache_set_ref_ttl_expiry() { + let dir = TempDir::new().unwrap(); + let cache: RedbCache = RedbCache::builder() + .name("serialize_cached_ttl_expiry") + .disk_directory(dir.path()) + .ttl(Duration::from_millis(100)) + .build() + .expect("error building redb cache"); + + let key: u32 = 1; + let value: String = "expires".to_string(); + + let prev = cache + .cache_set_ref(&key, &value) + .expect("cache_set_ref failed"); + assert_eq!(prev, None); + + // Entry is present immediately after insertion. + assert_eq!(cache.cache_get(&key).unwrap(), Some("expires".to_string())); + + // Sleep past the TTL; the entry must now be treated as expired (absent). + std::thread::sleep(std::time::Duration::from_millis(200)); + assert_eq!(cache.cache_get(&key).unwrap(), None); + } +} + +#[cfg(all(feature = "redb_store", feature = "async"))] +mod redb_serialize_cached_async { + use cached::stores::RedbCache; + use cached::{ConcurrentCachedAsync, SerializeCachedAsync}; + use tempfile::TempDir; + + #[tokio::test] + async fn async_cache_set_ref_round_trip() { + let dir = TempDir::new().unwrap(); + let cache: RedbCache = RedbCache::builder() + .name("serialize_cached_async_round_trip") + .disk_directory(dir.path()) + .build() + .expect("error building redb cache"); + + let key: u32 = 7; + let value: String = "async".to_string(); + + let prev = cache + .async_cache_set_ref(&key, &value) + .await + .expect("async_cache_set_ref failed"); + assert_eq!(prev, None); + // Caller still owns the borrowed inputs. + assert_eq!(key, 7); + assert_eq!(value, "async"); + + assert_eq!( + cache.async_cache_get(&key).await.unwrap(), + Some("async".to_string()) + ); + } + + /// Overwriting an existing entry via `async_cache_set_ref` returns the previous value + /// and the store reflects the new value on the next read. + #[tokio::test] + async fn async_cache_set_ref_overwrite() { + let dir = TempDir::new().unwrap(); + let cache: RedbCache = RedbCache::builder() + .name("serialize_cached_async_overwrite") + .disk_directory(dir.path()) + .build() + .expect("error building redb cache"); + + let key: u32 = 99; + + // First insert: no previous value. + let prev = cache + .async_cache_set_ref(&key, &"first".to_string()) + .await + .expect("async_cache_set_ref first failed"); + assert_eq!(prev, None); + + // Overwrite: previous value is returned. + let prev = cache + .async_cache_set_ref(&key, &"second".to_string()) + .await + .expect("async_cache_set_ref overwrite failed"); + assert_eq!(prev, Some("first".to_string())); + + // Store reflects the new value. + assert_eq!( + cache.async_cache_get(&key).await.unwrap(), + Some("second".to_string()) + ); + } +} + +// ── Item 1: CacheSetError ───────────────────────────────────────────────────── + +/// `CacheSetError` is a well-formed concrete `std::error::Error` type: +/// it is `Debug`, has a `Display` impl, and can be boxed as a trait object. +#[test] +fn cache_set_error_is_std_error() { + use cached::CacheSetError; + use std::error::Error; + + let err = CacheSetError::TimeBounds; + + // Debug and Display both work. + assert!(format!("{err:?}").contains("TimeBounds")); + assert_eq!(err.to_string(), "ttl is outside Instant bounds"); + + // It is a leaf error: no source. + assert!(err.source().is_none()); + + // Can be boxed as a trait object. + let boxed: Box = Box::new(CacheSetError::TimeBounds); + assert_eq!(boxed.to_string(), "ttl is outside Instant bounds"); + assert!(boxed.source().is_none()); +} + +/// The default `cache_try_set` on stores that do not override it is infallible: +/// it always returns `Ok(prev)`. The associated `Error` type is `Infallible` +/// for stores that cannot fail (e.g. `UnboundCache`). +#[test] +fn cache_try_set_default_is_infallible() { + use cached::{Cached, UnboundCache}; + + let mut cache: UnboundCache = + UnboundCache::builder().build().expect("build UnboundCache"); + + // First insert: no previous value. + let result: Result, std::convert::Infallible> = cache.cache_try_set(1, 10); + assert_eq!(result.unwrap(), None); + + // Second insert: returns the previous value. + let result: Result, std::convert::Infallible> = cache.cache_try_set(1, 20); + assert_eq!(result.unwrap(), Some(10)); +} + +/// `TtlSortedCache::cache_try_set` returns `Err(CacheSetError::TimeBounds)` when +/// the computed expiry `Instant` would overflow. With a normally-representable TTL +/// it succeeds and returns the previous value. +#[cfg(feature = "time_stores")] +#[test] +fn ttl_sorted_cache_try_set_succeeds_normal_ttl() { + use cached::time::Duration; + use cached::{CacheSetError, Cached, TtlSortedCache}; + + let mut cache = TtlSortedCache::::builder() + .ttl(Duration::from_secs(60)) + .build() + .expect("build TtlSortedCache"); + + // A normal insert via cache_try_set must succeed and return the previous value. + let result: Result, CacheSetError> = cache.cache_try_set(1, 42); + assert_eq!(result.unwrap(), None); + + // A second insert returns the previous value. + let result: Result, CacheSetError> = cache.cache_try_set(1, 99); + assert_eq!(result.unwrap(), Some(42)); +} + +/// `TtlSortedCache::cache_try_set` returns `Err(CacheSetError::TimeBounds)` when +/// the configured ttl makes the computed expiry `Instant` overflow. +/// +/// The overflow is triggered deterministically and portably: the public default ttl +/// drives the expiry (`insert` -> `insert_inner` computes `Instant::now() + self.ttl`), +/// and the builder's `validate_ttl` only rejects a *zero* ttl, so a near-`Duration::MAX` +/// ttl passes `build()` and then overflows `Instant::checked_add` on every platform +/// (no real `Instant` is anywhere near `Duration::MAX` from the epoch). The fallible +/// `cache_try_set` path uses `TtlOverflow::Error`, so it must report the overflow rather +/// than silently saturating or panicking, and the cache must be left unmutated. +/// The associated `type Error = CacheSetError` surfaces it directly without mapping +/// (TtlSortedCache now shares the unified error type with TtlCache / LruTtlCache). +#[cfg(feature = "time_stores")] +#[test] +fn ttl_sorted_cache_try_set_overflow_returns_time_bounds() { + use cached::time::Duration; + use cached::{CacheSetError, Cached, CachedExt, TtlSortedCache}; + + let mut cache = TtlSortedCache::::builder() + .ttl(Duration::MAX) + .build() + .expect("Duration::MAX is non-zero so build() must succeed"); + + let result: Result, CacheSetError> = cache.cache_try_set(1, 42); + assert!( + matches!(result, Err(CacheSetError::TimeBounds)), + "near-MAX ttl must overflow Instant and surface TimeBounds, got {result:?}" + ); + + // The failed try_set must not have stored anything (Error path mutates nothing). + assert_eq!(cache.cache_size(), 0, "overflowing try_set must not insert"); + assert_eq!( + cache.cache_get(&1), + None, + "overflowing try_set must not insert" + ); + + // The ergonomic alias surfaces the same error. + let via_alias: Result, CacheSetError> = cache.try_set(2, 7); + assert!( + matches!(via_alias, Err(CacheSetError::TimeBounds)), + "try_set alias must also surface TimeBounds, got {via_alias:?}" + ); + assert_eq!(cache.cache_size(), 0); +} + +/// `try_set` (the ergonomic alias) delegates to `cache_try_set` and returns the +/// same `Result, Self::Error>` type. +#[cfg(feature = "time_stores")] +#[test] +fn try_set_alias_returns_cache_set_error_for_ttl_sorted_cache() { + use cached::time::Duration; + use cached::{CacheSetError, CachedExt, TtlSortedCache}; + + let mut cache = TtlSortedCache::::builder() + .ttl(Duration::from_secs(60)) + .build() + .expect("build TtlSortedCache"); + + let result: Result, CacheSetError> = cache.try_set(1, 7); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), None); +} + +// ── Item 4b: Cached::Error associated type ──────────────────────────────────── + +/// Infallible stores expose `type Error = Infallible` via the `Cached` trait; +/// `cache_try_set` always returns `Ok` and the result is unwrappable without matching. +#[test] +fn cached_error_associated_type_infallible_for_unbound_cache() { + use cached::{Cached, CachedExt, UnboundCache}; + + let mut cache: UnboundCache = + UnboundCache::builder().build().expect("build UnboundCache"); + + // The type annotation pins the associated type to Infallible at compile time. + // This test fails to compile on the old signature (Result<_, CacheSetError>). + let r1: Result, std::convert::Infallible> = cache.cache_try_set(10, 100); + assert_eq!(r1.unwrap(), None); + + let r2: Result, std::convert::Infallible> = cache.cache_try_set(10, 200); + assert_eq!(r2.unwrap(), Some(100)); + + // try_set alias also uses Self::Error. + let r3: Result, std::convert::Infallible> = cache.try_set(10, 300); + assert_eq!(r3.unwrap(), Some(200)); +} + +/// `LruCache` is also an infallible store; its `Cached::Error` is `Infallible`. +#[test] +fn cached_error_associated_type_infallible_for_lru_cache() { + use cached::{Cached, LruCache}; + + let mut cache: LruCache = LruCache::builder() + .max_size(4) + .build() + .expect("build LruCache"); + + let r: Result, std::convert::Infallible> = cache.cache_try_set(1, 42); + assert_eq!(r.unwrap(), None); +} + +/// `TtlCache` sets `type Error = CacheSetError`; `cache_try_set` surfaces the concrete +/// error type through the associated type without any extra mapping at the call site. +#[cfg(feature = "time_stores")] +#[test] +fn cached_error_associated_type_cache_set_error_for_ttl_cache() { + use cached::time::Duration; + use cached::{CacheSetError, Cached, TtlCache}; + + let mut cache: TtlCache = TtlCache::builder() + .ttl(Duration::from_secs(60)) + .build() + .expect("build TtlCache"); + + let r: Result, CacheSetError> = cache.cache_try_set(1, 99); + assert_eq!(r.unwrap(), None); +} + +/// `LruTtlCache` sets `type Error = CacheSetError` too. +#[cfg(feature = "time_stores")] +#[test] +fn cached_error_associated_type_cache_set_error_for_lru_ttl_cache() { + use cached::time::Duration; + use cached::{CacheSetError, Cached, LruTtlCache}; + + let mut cache: LruTtlCache = LruTtlCache::builder() + .max_size(4) + .ttl(Duration::from_secs(60)) + .build() + .expect("build LruTtlCache"); + + let r: Result, CacheSetError> = cache.cache_try_set(1, 7); + assert_eq!(r.unwrap(), None); +} + +/// `TtlSortedCache` sets `type Error = CacheSetError` (unified with `TtlCache` / +/// `LruTtlCache`), surfacing a shared error type from `cache_try_set`. +#[cfg(feature = "time_stores")] +#[test] +fn cached_error_associated_type_cache_set_error_for_ttl_sorted_cache() { + use cached::time::Duration; + use cached::{CacheSetError, Cached, TtlSortedCache}; + + // Normal TTL: succeeds. + let mut cache: TtlSortedCache = TtlSortedCache::builder() + .ttl(Duration::from_secs(60)) + .build() + .expect("build TtlSortedCache"); + + let r: Result, CacheSetError> = cache.cache_try_set(1, 55); + assert_eq!(r.unwrap(), None); + + // Near-MAX TTL: returns Err(CacheSetError::TimeBounds) directly. + let mut overflow: TtlSortedCache = TtlSortedCache::builder() + .ttl(Duration::MAX) + .build() + .expect("Duration::MAX is non-zero"); + + let r2: Result, CacheSetError> = overflow.cache_try_set(1, 55); + assert!( + matches!(r2, Err(CacheSetError::TimeBounds)), + "expected TimeBounds, got {r2:?}" + ); + assert_eq!(overflow.cache_size(), 0, "failed try_set must not insert"); +} + +// ── Item 5: refresh_on_hit getter on ConcurrentCacheTtl ────────────────────── + +/// `ConcurrentCacheTtl::refresh_on_hit` returns `false` on a freshly built +/// TTL-capable concurrent store whose builder left refresh-on-hit disabled +/// (e.g. `ShardedTtlCache`, which tracks refresh state in an `AtomicBool`). +/// Non-TTL concurrent stores (`ShardedUnboundCache`, ...) do not implement +/// `ConcurrentCacheTtl` at all, so they have no `refresh_on_hit` method. +#[cfg(feature = "time_stores")] +#[test] +fn concurrent_refresh_on_hit_default_false() { + use cached::time::Duration; + use cached::{ConcurrentCacheTtl, ShardedTtlCache}; + + let cache: ShardedTtlCache = ShardedTtlCache::builder() + .ttl(Duration::from_secs(60)) + .build() + .expect("build"); + + assert!(!ConcurrentCacheTtl::refresh_on_hit(&cache)); +} + +/// On a TTL-capable sharded store, the `ConcurrentCacheTtl::set_refresh_on_hit` impl +/// persists the flag in an `AtomicBool`, and the now-required +/// `ConcurrentCacheTtl::refresh_on_hit` getter reads it back through trait dispatch. +/// Previously the getter relied on the trait default and always returned `false` +/// even after `set_refresh_on_hit(true)` — a latent bug now fixed by construction. +#[cfg(feature = "time_stores")] +#[test] +fn concurrent_set_refresh_on_hit_updates_inner_state() { + use cached::time::Duration; + use cached::{ConcurrentCacheTtl, ShardedTtlCache}; + + let cache = ShardedTtlCache::::builder() + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedTtlCache"); + + // Starts false (builder default). + assert!(!ConcurrentCacheTtl::refresh_on_hit(&cache)); + + // `set_refresh_on_hit` returns the previous value (from the AtomicBool swap). + let prev = ConcurrentCacheTtl::set_refresh_on_hit(&cache, true); + assert!(!prev, "previous value must be false"); + + // The trait getter now reflects the setter through trait dispatch. + assert!( + ConcurrentCacheTtl::refresh_on_hit(&cache), + "trait getter must reflect set_refresh_on_hit(true)" + ); + // The inherent `refresh_on_hit()` reads the same AtomicBool. + assert!(cache.refresh_on_hit()); + + // Disable via the trait method. + let prev = ConcurrentCacheTtl::set_refresh_on_hit(&cache, false); + assert!(prev, "previous value must be true"); + assert!(!ConcurrentCacheTtl::refresh_on_hit(&cache)); + assert!(!cache.refresh_on_hit()); +} + +/// Async counterpart of `concurrent_set_refresh_on_hit_updates_inner_state`: +/// `ConcurrentCacheTtl::set_refresh_on_hit` on `ShardedTtlCache` swaps the inner +/// `AtomicBool` (returning the previous flag), and the now-required +/// `ConcurrentCacheTtl::refresh_on_hit` getter reads it back through trait dispatch. +#[cfg(all(feature = "time_stores", feature = "async"))] +#[test] +fn concurrent_async_set_refresh_on_hit_updates_inner_state() { + use cached::time::Duration; + use cached::{ConcurrentCacheTtl, ShardedTtlCache}; + + let cache = ShardedTtlCache::::builder() + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedTtlCache"); + + // Trait-level getter starts false (builder default). + assert!(!ConcurrentCacheTtl::refresh_on_hit(&cache)); + + // Setter swaps the AtomicBool and reports the previous value. + let prev = ConcurrentCacheTtl::set_refresh_on_hit(&cache, true); + assert!(!prev, "previous flag must be false"); + + // Both the inherent and the trait getter reflect the new state. + assert!( + cache.refresh_on_hit(), + "inherent getter must read the swapped flag" + ); + assert!( + ConcurrentCacheTtl::refresh_on_hit(&cache), + "trait getter must reflect set_refresh_on_hit(true)" + ); + + // Round-trip back to false. + let prev = ConcurrentCacheTtl::set_refresh_on_hit(&cache, false); + assert!(prev, "previous flag must be true"); + assert!(!cache.refresh_on_hit()); + assert!(!ConcurrentCacheTtl::refresh_on_hit(&cache)); +} + +/// `ShardedLruTtlCache` is the second sharded TTL store with an overridden +/// `set_refresh_on_hit`. Confirm its now-required `ConcurrentCacheTtl::refresh_on_hit` +/// getter is truthful through trait dispatch (previously it returned the trait-default +/// `false` regardless of the setter). +#[cfg(feature = "time_stores")] +#[test] +fn concurrent_sharded_lru_ttl_refresh_on_hit_getter_reflects_setter() { + use cached::time::Duration; + use cached::{ConcurrentCacheTtl, ShardedLruTtlCache}; + + let cache = ShardedLruTtlCache::::builder() + .max_size(8) + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedLruTtlCache"); + + assert!(!ConcurrentCacheTtl::refresh_on_hit(&cache)); + + let prev = ConcurrentCacheTtl::set_refresh_on_hit(&cache, true); + assert!(!prev, "previous flag must be false"); + assert!( + ConcurrentCacheTtl::refresh_on_hit(&cache), + "trait getter must reflect set_refresh_on_hit(true)" + ); + + let prev = ConcurrentCacheTtl::set_refresh_on_hit(&cache, false); + assert!(prev, "previous flag must be true"); + assert!(!ConcurrentCacheTtl::refresh_on_hit(&cache)); +} + +/// `RedbCache` (disk store) implements `ConcurrentCacheTtl` across both its sync and async +/// surfaces. Confirm its now-required `refresh_on_hit` getter reads the real `AtomicBool` +/// flag through trait dispatch (it shares the impl pattern with the redis stores, which +/// previously returned the trait-default `false`). Server-free. +#[cfg(feature = "redb_store")] +#[test] +fn concurrent_redb_refresh_on_hit_getter_reflects_setter() { + use cached::time::Duration; + use cached::{ConcurrentCacheTtl, RedbCache}; + use tempfile::TempDir; + + let dir = TempDir::new().unwrap(); + let cache: RedbCache = RedbCache::builder() + .name("concurrent_redb_refresh_getter") + .disk_directory(dir.path()) + .ttl(Duration::from_secs(60)) + .build() + .expect("build RedbCache"); + + assert!(!ConcurrentCacheTtl::refresh_on_hit(&cache)); + + let prev = ConcurrentCacheTtl::set_refresh_on_hit(&cache, true); + assert!(!prev, "previous flag must be false"); + assert!( + ConcurrentCacheTtl::refresh_on_hit(&cache), + "trait getter must reflect set_refresh_on_hit(true)" + ); + + let prev = ConcurrentCacheTtl::set_refresh_on_hit(&cache, false); + assert!(prev, "previous flag must be true"); + assert!(!ConcurrentCacheTtl::refresh_on_hit(&cache)); +} + +// ── short remove/remove_entry aliases remain callable for-effect (no #[must_use]) ── + +/// Item 12 locks an intentional asymmetry: `#[must_use]` is on `cache_remove` / +/// `cache_remove_entry` but NOT on the short `remove` / `remove_entry` aliases. This +/// test calls the short aliases purely for-effect (discarding the return value with no +/// `let _ =`); it only compiles cleanly because those aliases are not `#[must_use]`. A +/// regression that added `#[must_use]` to the aliases would raise `unused_must_use` here, +/// and CI runs `clippy --tests -- -D warnings`, so this is an enforced gate. +#[test] +fn short_remove_aliases_callable_for_effect() { + use cached::{Cached, CachedExt, UnboundCache}; + + let mut cache: UnboundCache = + UnboundCache::builder().build().expect("build UnboundCache"); + cache.cache_set(1, 10); + cache.cache_set(2, 20); + + // For-effect calls: return values intentionally dropped (no `let _ =`). + // These would warn if the aliases were #[must_use]. + cache.remove(&1); + cache.remove_entry(&2); + + assert_eq!( + cache.cache_size(), + 0, + "both entries removed via short aliases" + ); + + // Sanity: the must_use'd canonical methods still return the value as before. + cache.cache_set(3, 30); + assert_eq!(cache.cache_remove(&3), Some(30)); +} + +// ── Item 8: cache_get_or_set_with on ConcurrentCached ──────────────────────── + +/// On a miss, `cache_get_or_set_with` calls the factory, stores the result, and +/// returns it. On a hit, the factory is not called. +#[test] +fn concurrent_cache_get_or_set_with_hit_and_miss() { + use cached::{ConcurrentCached, ShardedUnboundCache}; + + let cache: ShardedUnboundCache = + ShardedUnboundCache::builder().build().expect("build"); + + // Miss: factory is invoked and result is stored. + let v = ConcurrentCached::cache_get_or_set_with(&cache, 1, || 42).expect("infallible"); + assert_eq!(v, 42); + + // Confirm it was stored. + assert_eq!(cache.cache_get(&1).unwrap(), Some(42)); + + // Hit: factory must NOT be called (use a panicking closure to verify). + let v = ConcurrentCached::cache_get_or_set_with(&cache, 1, || panic!("must not be called")) + .expect("infallible"); + assert_eq!(v, 42); +} + +/// Locks the get-then-return contract with an explicit invocation counter (rather +/// than a panicking closure): the factory runs exactly once across a miss followed by +/// a hit. On the hit the stored value is returned without recomputation. +#[test] +fn concurrent_cache_get_or_set_with_factory_runs_once() { + use cached::{ConcurrentCached, ShardedUnboundCache}; + use std::sync::atomic::{AtomicUsize, Ordering}; + + let cache: ShardedUnboundCache = + ShardedUnboundCache::builder().build().expect("build"); + + let calls = AtomicUsize::new(0); + + // Miss: factory runs and the result is stored. + let v = ConcurrentCached::cache_get_or_set_with(&cache, 1, || { + calls.fetch_add(1, Ordering::SeqCst); + 42 + }) + .expect("infallible"); + assert_eq!(v, 42); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "miss must invoke the factory once" + ); + + // Hit: factory must NOT run; the previously stored value is returned verbatim. + let v = ConcurrentCached::cache_get_or_set_with(&cache, 1, || { + calls.fetch_add(1, Ordering::SeqCst); + 999 + }) + .expect("infallible"); + assert_eq!( + v, 42, + "hit must return the stored value, not the recomputed one" + ); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "hit must not invoke the factory again" + ); +} + +/// The ergonomic alias `get_or_set_with` delegates to `cache_get_or_set_with`. +#[test] +fn concurrent_get_or_set_with_alias() { + use cached::{ConcurrentCachedExt, ShardedUnboundCache}; + + let cache: ShardedUnboundCache = + ShardedUnboundCache::builder().build().expect("build"); + + let v = ConcurrentCachedExt::get_or_set_with(&cache, 10, || 99).expect("infallible"); + assert_eq!(v, 99); + + // Hit path via alias. + let v2 = ConcurrentCachedExt::get_or_set_with(&cache, 10, || panic!("must not be called")) + .expect("infallible"); + assert_eq!(v2, 99); +} + +// ── Item 8 async: async_cache_get_or_set_with on ConcurrentCachedAsync ──────── + +#[cfg(feature = "async")] +mod async_cache_get_or_set_with_tests { + use cached::{ConcurrentCachedAsync, ShardedUnboundCache}; + + /// On a miss, `async_cache_get_or_set_with` calls the async factory, stores + /// the result, and returns it. On a hit, the factory is not called. + #[tokio::test] + async fn hit_and_miss() { + let cache: ShardedUnboundCache = + ShardedUnboundCache::builder().build().expect("build"); + + // Miss: factory runs. + let v = ConcurrentCachedAsync::async_cache_get_or_set_with(&cache, 1, || async { 55 }) + .await + .expect("infallible"); + assert_eq!(v, 55); + + // Confirm stored. + let stored = ConcurrentCachedAsync::async_cache_get(&cache, &1) + .await + .unwrap(); + assert_eq!(stored, Some(55)); + + // Hit: factory must NOT run. + let v = ConcurrentCachedAsync::async_cache_get_or_set_with(&cache, 1, || async { + panic!("must not be called") + }) + .await + .expect("infallible"); + assert_eq!(v, 55); + } + + /// Counter-based version of the get-then-return contract for the async variant: + /// the async factory runs exactly once across a miss followed by a hit, and the + /// hit returns the stored value without recomputation. + #[tokio::test] + async fn async_factory_runs_once() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let cache: ShardedUnboundCache = + ShardedUnboundCache::builder().build().expect("build"); + + let calls = AtomicUsize::new(0); + + let v = ConcurrentCachedAsync::async_cache_get_or_set_with(&cache, 1, || async { + calls.fetch_add(1, Ordering::SeqCst); + 42 + }) + .await + .expect("infallible"); + assert_eq!(v, 42); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "miss must run the factory once" + ); + + let v = ConcurrentCachedAsync::async_cache_get_or_set_with(&cache, 1, || async { + calls.fetch_add(1, Ordering::SeqCst); + 999 + }) + .await + .expect("infallible"); + assert_eq!(v, 42, "hit returns the stored value"); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "hit must not run the factory again" + ); + } + + /// `async_cache_get_or_set_with` on a TTL-capable async sharded store + /// (`ShardedTtlCache`): a miss computes and stores through the time-bounded + /// store, and a subsequent hit on the live entry skips the factory. + #[cfg(feature = "time_stores")] + #[tokio::test] + async fn ttl_store_hit_and_miss() { + use cached::ShardedTtlCache; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::Duration; + + let cache: ShardedTtlCache = ShardedTtlCache::builder() + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedTtlCache"); + + let calls = AtomicUsize::new(0); + + // Miss: factory runs and the value is stored in the TTL store. + let v = ConcurrentCachedAsync::async_cache_get_or_set_with(&cache, 1, || async { + calls.fetch_add(1, Ordering::SeqCst); + 77 + }) + .await + .expect("infallible"); + assert_eq!(v, 77); + assert_eq!(calls.load(Ordering::SeqCst), 1); + + // Confirm it was stored. + let stored = ConcurrentCachedAsync::async_cache_get(&cache, &1) + .await + .unwrap(); + assert_eq!(stored, Some(77)); + + // Hit on the live entry: factory must NOT run. + let v = ConcurrentCachedAsync::async_cache_get_or_set_with(&cache, 1, || async { + calls.fetch_add(1, Ordering::SeqCst); + 999 + }) + .await + .expect("infallible"); + assert_eq!(v, 77, "live hit returns the stored value"); + assert_eq!( + calls.load(Ordering::SeqCst), + 1, + "live hit must not run the factory" + ); + } +} + +// ── Item 6: DiskCache aliases removed ──────────────────────────────────────── + +// No runtime test needed; the aliases were removed as a compile-time change. +// The existing test `redb_cache_builder_zero_ttl_validation` in tests/cached.rs +// confirms that `RedbCache` is the sole name and that builder validation works. + +// ── Item 7: store() getters removed - public API assertions cover the same ground ── + +/// `UnboundCache` entry count is accessible via `cache_size()`; `store()` is gone. +#[test] +fn unbound_cache_size_via_public_api() { + use cached::Cached; + let mut cache = UnboundCache::::builder().build().unwrap(); + cache.cache_set(1, 10); + cache.cache_set(2, 20); + assert_eq!(cache.cache_size(), 2); + assert!(cache.cache_get(&1).is_some()); + assert!(cache.cache_get(&2).is_some()); +} + +/// `TtlCache` entry count and lookups are accessible via public API; `store()` is gone. +#[cfg(feature = "time_stores")] +#[test] +fn ttl_cache_size_and_lookup_via_public_api() { + use cached::time::Duration; + use cached::{Cached, TtlCache}; + let mut cache = TtlCache::::builder() + .ttl(Duration::from_secs(60)) + .build() + .unwrap(); + cache.cache_set(1, 10); + assert_eq!(cache.cache_size(), 1); + assert_eq!(cache.cache_get(&1), Some(&10)); +} + +/// `LruTtlCache` metrics are accessible directly on the cache; `store()` is gone. +#[cfg(feature = "time_stores")] +#[test] +fn lru_ttl_cache_metrics_via_public_api() { + use cached::time::Duration; + use cached::{Cached, LruTtlCache}; + let mut cache = LruTtlCache::::builder() + .max_size(4) + .ttl(Duration::from_secs(60)) + .build() + .unwrap(); + cache.cache_set(1, 10); + cache.cache_reset_metrics(); + assert!(cache.cache_get(&1).is_some()); + assert_eq!(cache.cache_hits(), Some(1)); + assert_eq!(cache.cache_misses(), Some(0)); +} + +// ── set_ttl(Duration::ZERO) on sharded TTL stores disables expiry (I2) ──────── +// +// The inherent `set_ttl` and the `ConcurrentCached::set_ttl` delegation used to +// `assert!(!ttl.is_zero())` and panic on a zero ttl. In v3 a zero ttl means +// "expiry disabled" — exactly equivalent to `unset_ttl()`: the call returns +// normally and subsequently inserted entries never expire. +#[cfg(feature = "time_stores")] +mod sharded_set_ttl_zero { + use cached::time::Duration; + use cached::{ConcurrentCacheTtl, ConcurrentCached, ShardedLruTtlCache, ShardedTtlCache}; + + #[test] + fn sharded_ttl_inherent_set_ttl_zero_disables_expiry() { + let cache: ShardedTtlCache = ShardedTtlCache::builder() + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedTtlCache"); + + // Inherent set_ttl(ZERO) must not panic and disables expiry (ttl -> None). + let prev = cache.set_ttl(Duration::ZERO); + assert_eq!(prev, Some(Duration::from_secs(60))); + assert_eq!( + cache.ttl(), + None, + "a zero ttl disables expiry (resolves to None)" + ); + + // A freshly inserted entry never expires -> still present. + cache.cache_set(1, 10).unwrap(); + assert_eq!(cache.cache_get(&1), Ok(Some(10))); + } + + #[test] + fn sharded_ttl_trait_set_ttl_zero_disables_expiry() { + let cache: ShardedTtlCache = ShardedTtlCache::builder() + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedTtlCache"); + + // The `ConcurrentCacheTtl::set_ttl` delegation must not panic either. + let prev = ConcurrentCacheTtl::set_ttl(&cache, Duration::ZERO); + assert_eq!(prev, Some(Duration::from_secs(60))); + + cache.cache_set(2, 20).unwrap(); + assert_eq!(cache.cache_get(&2), Ok(Some(20))); + } + + #[test] + fn sharded_lru_ttl_inherent_set_ttl_zero_disables_expiry() { + let cache: ShardedLruTtlCache = ShardedLruTtlCache::builder() + .max_size(8) + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedLruTtlCache"); + + let prev = cache.set_ttl(Duration::ZERO); + assert_eq!(prev, Some(Duration::from_secs(60))); + assert_eq!(cache.ttl(), None); + + cache.cache_set(1, 10).unwrap(); + assert_eq!(cache.cache_get(&1), Ok(Some(10))); + } + + #[test] + fn sharded_lru_ttl_trait_set_ttl_zero_disables_expiry() { + let cache: ShardedLruTtlCache = ShardedLruTtlCache::builder() + .max_size(8) + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedLruTtlCache"); + + let prev = ConcurrentCacheTtl::set_ttl(&cache, Duration::ZERO); + assert_eq!(prev, Some(Duration::from_secs(60))); + + cache.cache_set(2, 20).unwrap(); + assert_eq!(cache.cache_get(&2), Ok(Some(20))); + } + + #[test] + fn sharded_ttl_set_zero_is_equivalent_to_unset() { + // set_ttl(ZERO) and unset_ttl() are observably identical: both disable expiry + // for FUTURE inserts. Entries already in the cache keep their per-entry expires_at. + let via_zero: ShardedTtlCache = ShardedTtlCache::builder() + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedTtlCache"); + let via_unset: ShardedTtlCache = ShardedTtlCache::builder() + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedTtlCache"); + + let _ = via_zero.set_ttl(Duration::ZERO); + let _ = via_unset.unset_ttl(); + assert_eq!(via_zero.ttl(), via_unset.ttl()); + assert_eq!(via_zero.ttl(), None); + + // Insert AFTER disabling: these entries get expires_at = None (never expire). + via_zero.cache_set(3, 30).unwrap(); + via_unset.cache_set(3, 30).unwrap(); + assert_eq!(via_zero.cache_get(&3), Ok(Some(30))); + assert_eq!(via_unset.cache_get(&3), Ok(Some(30))); + + // Re-arming only affects FUTURE inserts; existing entries (expires_at=None) live on. + via_zero.set_ttl(Duration::from_millis(20)); + // New insert under the re-armed TTL: this one should expire. + via_zero.cache_set(4, 40).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(60)); + // Entry 3 (inserted while disabled, expires_at=None) must still be live. + assert_eq!( + via_zero.cache_get(&3), + Ok(Some(30)), + "entry inserted while disabled keeps expires_at=None; must survive re-arming" + ); + // Entry 4 (inserted after re-arming) must have expired. + assert_eq!( + via_zero.cache_get(&4), + Ok(None), + "entry inserted after set_ttl(nonzero) must expire at the new deadline" + ); + } +} + +// ── Builder missing-required errors are server-free (C1) ────────────────────── +// +// The redis/redb builders are now no-arg; the former positional args +// (`prefix`/`ttl` for redis, `name` for redb) are required setters. A `build()` +// with a required field unset must return `BuildError::MissingRequired(...)` +// WITHOUT attempting any IO/connection, so these tests need no live server. +#[cfg(feature = "redb_store")] +#[test] +fn redb_builder_missing_name_is_server_free_error() { + use cached::{BuildError, RedbCache, RedbCacheBuildError}; + let result = RedbCache::::builder().build(); + assert!( + matches!( + result, + Err(RedbCacheBuildError::Build(BuildError::MissingRequired( + "name" + ))) + ), + "expected Build(MissingRequired(\"name\"))" + ); +} + +#[cfg(feature = "redis_store")] +#[test] +fn redis_builder_missing_required_is_server_free_error() { + use cached::{BuildError, RedisCache, RedisCacheBuildError}; + + // No prefix and no ttl -> prefix is reported first. + let result = RedisCache::::builder().build(); + assert!( + matches!( + result, + Err(RedisCacheBuildError::Build(BuildError::MissingRequired( + "prefix" + ))) + ), + "expected Build(MissingRequired(\"prefix\"))" + ); + + // prefix set, ttl unset -> ttl is reported, still before any connection attempt. + let result = RedisCache::::builder().prefix("x").build(); + assert!( + matches!( + result, + Err(RedisCacheBuildError::Build(BuildError::MissingRequired( + "ttl" + ))) + ), + "expected Build(MissingRequired(\"ttl\"))" + ); +} + +// ── Regression: ConcurrentCached / ConcurrentCachedAsync method-name collision ─ +// +// Before the trait split, both `ConcurrentCached` and `ConcurrentCachedAsync` +// declared identical synchronous helpers (`cache_size`, `len`, `is_empty`, `ttl`, +// `set_ttl`, `unset_ttl`, `refresh_on_hit`, `set_refresh_on_hit`). On a store that +// implements BOTH traits (`RedbCache`, every `Sharded*` store), calling one of +// those helpers through method syntax with both traits in scope (as the prelude +// glob brings them) produced `error[E0034]: multiple applicable items in scope`. +// +// After hoisting introspection onto `ConcurrentCacheBase` and the global-TTL +// controls onto `ConcurrentCacheTtl`, each helper lives on exactly one trait, so +// these calls resolve unambiguously without fully-qualified syntax. This module +// glob-imports the prelude (both concurrent traits + the two new bases) and calls +// the previously-colliding methods on `RedbCache` and a sharded TTL store. +#[cfg(all(feature = "redb_store", feature = "time_stores"))] +mod concurrent_trait_split_no_collision { + // Glob-import brings ConcurrentCached, ConcurrentCachedAsync, ConcurrentCacheBase, + // and ConcurrentCacheTtl all into scope simultaneously -- the exact condition + // that used to trigger E0034 on the shared helpers. + use cached::prelude::*; + use cached::time::Duration; + use cached::{ + RedbCache, SetTtlError, ShardedLruTtlCache, ShardedTtlCache, ShardedUnboundCache, + }; + + // RedbCache implements BOTH ConcurrentCached and ConcurrentCachedAsync. + #[test] + fn redb_shared_helpers_resolve_without_fully_qualified_syntax() { + let dir = tempfile::TempDir::new().expect("temp dir"); + let cache: RedbCache = RedbCache::builder() + .name("collision-probe") + .disk_directory(dir.path()) + .ttl(Duration::from_secs(60)) + .build() + .expect("build RedbCache"); + + // cache_size lives on ConcurrentCacheBase (single impl) -- no E0034. + assert_eq!(cache.cache_size().expect("cache_size"), None); + assert_eq!(cache.len().expect("len"), None); + assert_eq!(cache.is_empty().expect("is_empty"), None); + + // set_ttl / ttl / unset_ttl live on ConcurrentCacheTtl -- no E0034 even with + // both ConcurrentCached and ConcurrentCachedAsync in scope. + assert_eq!(cache.ttl(), Some(Duration::from_secs(60))); + let prev = cache.set_ttl(Duration::from_secs(30)); + assert_eq!(prev, Some(Duration::from_secs(60))); + let prev2 = cache.unset_ttl(); + assert_eq!(prev2, Some(Duration::from_secs(30))); + assert_eq!(cache.ttl(), None); + + // The IO ops still work (cache_set/cache_get on ConcurrentCached). + assert_eq!(cache.cache_set("k".to_string(), 7).expect("set"), None); + assert_eq!(cache.cache_get(&"k".to_string()).expect("get"), Some(7)); + } + + // A sharded TTL store also implements both concurrent traits. + #[test] + fn sharded_ttl_shared_helpers_resolve_without_fully_qualified_syntax() { + let cache: ShardedTtlCache = ShardedTtlCache::builder() + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedTtlCache"); + + // cache_size on ConcurrentCacheBase is unambiguous through the trait even + // though the sharded store also has an inherent `len`/`is_empty`. + assert_eq!(ConcurrentCacheBase::cache_size(&cache), Ok(Some(0))); + + cache.cache_set(1, 10).expect("infallible"); + assert_eq!(ConcurrentCacheBase::cache_size(&cache), Ok(Some(1))); + + // set_ttl / unset_ttl on ConcurrentCacheTtl, called via plain method syntax. + let prev = cache.set_ttl(Duration::from_secs(30)); + assert_eq!(prev, Some(Duration::from_secs(60))); + assert_eq!(cache.unset_ttl(), Some(Duration::from_secs(30))); + } + + // ConcurrentCacheTtl::try_set_ttl rejects a zero Duration with SetTtlError::ZeroTtl + // on a concurrent TTL store (mirrors the single-owner CacheTtl::try_set_ttl). + #[test] + fn concurrent_try_set_ttl_zero_is_rejected() { + let redb_dir = tempfile::TempDir::new().expect("temp dir"); + let redb: RedbCache = RedbCache::builder() + .name("try-set-ttl-zero") + .disk_directory(redb_dir.path()) + .ttl(Duration::from_secs(60)) + .build() + .expect("build RedbCache"); + assert_eq!( + redb.try_set_ttl(Duration::ZERO), + Err(SetTtlError::ZeroTtl), + "try_set_ttl(ZERO) must reject without disabling expiry" + ); + // The ttl is untouched after a rejected try_set_ttl. + assert_eq!(redb.ttl(), Some(Duration::from_secs(60))); + // A non-zero try_set_ttl succeeds and returns the previous value. + assert_eq!( + redb.try_set_ttl(Duration::from_secs(10)), + Ok(Some(Duration::from_secs(60))) + ); + + let sharded: ShardedTtlCache = ShardedTtlCache::builder() + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedTtlCache"); + assert_eq!( + sharded.try_set_ttl(Duration::ZERO), + Err(SetTtlError::ZeroTtl) + ); + + let lru_ttl: ShardedLruTtlCache = ShardedLruTtlCache::builder() + .max_size(8) + .ttl(Duration::from_secs(60)) + .build() + .expect("build ShardedLruTtlCache"); + assert_eq!( + lru_ttl.try_set_ttl(Duration::ZERO), + Err(SetTtlError::ZeroTtl) + ); + } + + // Non-TTL sharded stores intentionally do NOT implement ConcurrentCacheTtl, but + // their ConcurrentCacheBase introspection is still reachable through the prelude + // glob without collision. + #[test] + fn non_ttl_sharded_store_base_helpers_resolve() { + let cache: ShardedUnboundCache = + ShardedUnboundCache::builder().build().expect("build"); + assert_eq!(ConcurrentCacheBase::is_empty(&cache), Ok(Some(true))); + cache.cache_set(1, 10).expect("infallible"); + assert_eq!(ConcurrentCacheBase::cache_size(&cache), Ok(Some(1))); + } + + // The author's collision regression coverage is all `#[test]` (sync context). + // The original E0034 was a name-resolution failure, which is identical in an + // async fn body, but the failure mode that matters in async code is calling the + // `ConcurrentCacheTtl`/`ConcurrentCacheBase` helpers via plain method syntax + // *alongside* the `async_cache_*` IO ops with both concurrent traits in scope. + // This `#[tokio::test]` exercises exactly that on `RedbCache` (implements BOTH + // ConcurrentCached and ConcurrentCachedAsync): `set_ttl`/`cache_size`/`unset_ttl` + // resolve unqualified inside an async fn and interleave with `.await`ed IO with no + // ambiguity. If a future refactor reintroduced the duplicated helpers on both + // concurrent traits, this would fail to compile under the prelude glob. + #[cfg(feature = "async")] + #[tokio::test] + async fn redb_shared_helpers_resolve_unqualified_in_async_context() { + let dir = tempfile::TempDir::new().expect("temp dir"); + let cache: RedbCache = RedbCache::builder() + .name("collision-probe-async") + .disk_directory(dir.path()) + .ttl(Duration::from_secs(60)) + .build() + .expect("build RedbCache"); + + // ConcurrentCacheBase::cache_size via plain method syntax in an async fn. + // RedbCache reports an unknown size (Ok(None)). + assert_eq!(cache.cache_size().expect("cache_size"), None); + + // ConcurrentCacheTtl::set_ttl via plain method syntax, interleaved with + // `async_cache_*` IO ops from ConcurrentCachedAsync. + assert_eq!(cache.ttl(), Some(Duration::from_secs(60))); + let prev = cache.set_ttl(Duration::from_secs(30)); + assert_eq!(prev, Some(Duration::from_secs(60))); + + // Async IO op resolves unambiguously alongside the sync helpers. + let set_prev = cache + .async_cache_set("k".to_string(), 7) + .await + .expect("async_cache_set"); + assert_eq!(set_prev, None); + assert_eq!( + cache.async_cache_get(&"k".to_string()).await.expect("get"), + Some(7) + ); + + // unset_ttl (ConcurrentCacheTtl) resolves unqualified after the await. + let prev2 = cache.unset_ttl(); + assert_eq!(prev2, Some(Duration::from_secs(30))); + assert_eq!(cache.ttl(), None); + + // try_set_ttl default (ConcurrentCacheTtl) still rejects zero in async code. + assert_eq!(cache.try_set_ttl(Duration::ZERO), Err(SetTtlError::ZeroTtl)); + } +} + +// ── cache_size/len/is_empty defaults on Ok(None) stores (ConcurrentCacheBase) ── +// +// The author asserted `cache_size() == Ok(None)` only on `RedbCache`, and the +// `len`/`is_empty` checks only on stores that report a real size (sharded). This +// module pins the *default delegation* on a store whose `cache_size` is `Ok(None)`: +// `len` must forward to `cache_size` (so also `Ok(None)`) and `is_empty` must map +// `None` through to `Ok(None)` rather than fabricating a bool. A regression that +// made `is_empty` return `Ok(Some(true))` for an unknown size would be caught here. +#[cfg(feature = "redb_store")] +mod concurrent_base_unknown_size_defaults { + use cached::time::Duration; + use cached::{ConcurrentCacheBase, ConcurrentCached, RedbCache}; + + #[test] + fn redb_len_and_is_empty_default_to_unknown() { + let dir = tempfile::TempDir::new().expect("temp dir"); + let cache: RedbCache = RedbCache::builder() + .name("unknown-size-defaults") + .disk_directory(dir.path()) + .ttl(Duration::from_secs(60)) + .build() + .expect("build RedbCache"); + + // cache_size is unknown for redb (O(n) scan avoided). RedbCacheError does not + // implement PartialEq, so unwrap the Ok and compare the Option payload. + assert_eq!( + ConcurrentCacheBase::cache_size(&cache).expect("cache_size"), + None + ); + + // len delegates to cache_size -> also None. + assert_eq!(ConcurrentCacheBase::len(&cache).expect("len"), None); + + // is_empty maps an unknown size through to None (NOT Some(true)). + assert_eq!( + ConcurrentCacheBase::is_empty(&cache).expect("is_empty"), + None + ); + + // The defaults stay None even after a real write: redb still won't scan. + ConcurrentCached::cache_set(&cache, "k".to_string(), 1).expect("infallible set"); + assert_eq!( + ConcurrentCacheBase::cache_size(&cache).expect("cache_size"), + None + ); + assert_eq!(ConcurrentCacheBase::len(&cache).expect("len"), None); + assert_eq!( + ConcurrentCacheBase::is_empty(&cache).expect("is_empty"), + None + ); + } +} + +// ── Spec 0012: concurrent metric accessors via ConcurrentCacheBase bound ────── +// +// Verifies that cache_hits, cache_misses, cache_capacity, cache_evictions, and +// metrics() are accessible on sharded stores via a generic ConcurrentCacheBase +// bound and that values are correctly aggregated across shards. +mod concurrent_metrics_via_base_trait { + use cached::{CacheMetrics, ConcurrentCacheBase, ConcurrentCached}; + + fn assert_metrics_available(store: &S) + where + S: ConcurrentCacheBase, + { + let _ = store.cache_hits(); + let _ = store.cache_misses(); + let _ = store.cache_capacity(); + let _ = store.cache_evictions(); + let _m: CacheMetrics = store.metrics(); + } + + #[test] + fn sharded_unbound_cache_metrics_via_base_bound() { + use cached::ShardedUnboundCache; + let cache: ShardedUnboundCache = ShardedUnboundCache::builder() + .shards(4) + .build() + .expect("build"); + + // Reachable via the generic bound before any operations. + assert_metrics_available(&cache); + assert_eq!(cache.cache_hits(), Some(0)); + assert_eq!(cache.cache_misses(), Some(0)); + + // Populate the cache and verify hit/miss counts aggregate across shards. + ConcurrentCached::cache_set(&cache, 1, 10).expect("infallible"); + let _ = ConcurrentCached::cache_get(&cache, &1).expect("infallible"); // hit + let _ = ConcurrentCached::cache_get(&cache, &2).expect("infallible"); // miss + assert_eq!(cache.cache_hits(), Some(1)); + assert_eq!(cache.cache_misses(), Some(1)); + // Unbounded cache has no capacity or evictions. + assert_eq!(cache.cache_capacity(), None); + assert_eq!(cache.cache_evictions(), None); + + let m = cache.metrics(); + assert_eq!(m.hits, Some(1)); + assert_eq!(m.misses, Some(1)); + assert_eq!(m.evictions, None); + assert_eq!(m.entry_count, 1); + assert_eq!(m.capacity, None); + } + + #[test] + fn sharded_lru_cache_metrics_via_base_bound() { + use cached::ShardedLruCache; + // Use per_shard_max_size to get a predictable total_capacity. + let cache: ShardedLruCache = ShardedLruCache::builder() + .shards(2) + .per_shard_max_size(8) + .build() + .expect("build"); + + assert_metrics_available(&cache); + // total_capacity = shards * per_shard_max_size = 2 * 8 = 16. + assert_eq!(cache.cache_capacity(), Some(16)); + + ConcurrentCached::cache_set(&cache, 1, 10).expect("infallible"); + let _ = ConcurrentCached::cache_get(&cache, &1); // hit + let _ = ConcurrentCached::cache_get(&cache, &9); // miss + assert_eq!(cache.cache_hits(), Some(1)); + assert_eq!(cache.cache_misses(), Some(1)); + + let m = cache.metrics(); + assert_eq!(m.hits, Some(1)); + assert_eq!(m.misses, Some(1)); + assert_eq!(m.capacity, Some(16)); + } + + #[cfg(feature = "time_stores")] + #[test] + fn sharded_ttl_cache_metrics_via_base_bound() { + use cached::ShardedTtlCache; + use cached::time::Duration; + let cache: ShardedTtlCache = ShardedTtlCache::builder() + .shards(2) + .ttl(Duration::from_secs(60)) + .build() + .expect("build"); + + assert_metrics_available(&cache); + ConcurrentCached::cache_set(&cache, 1, 10).expect("infallible"); + let _ = ConcurrentCached::cache_get(&cache, &1); // hit + let _ = ConcurrentCached::cache_get(&cache, &9); // miss + assert_eq!(cache.cache_hits(), Some(1)); + assert_eq!(cache.cache_misses(), Some(1)); + } +} + +// ── Spec 0008: CachedExt and ConcurrentCachedExt blanket extension traits ──── +// +// Verifies that: +// - CachedExt short aliases work when only CachedExt is in scope (not Cached). +// - ConcurrentCachedExt short aliases work when only ConcurrentCachedExt is in scope. +// - Generic code using CachedExt bounds works with any Cached store. +// - Generic code using ConcurrentCachedExt bounds works with any ConcurrentCached store. +mod extension_trait_blanket_impls { + // Only CachedExt in scope, not Cached -- short names must resolve unambiguously. + #[test] + fn cached_ext_short_aliases_without_cached_in_scope() { + use cached::{CachedExt, UnboundCache}; + + let mut cache: UnboundCache = UnboundCache::builder().build().unwrap(); + + // set / get / remove / delete / clear / len / is_empty work via CachedExt alone. + assert_eq!(cache.set(1, 10), None); + assert_eq!(cache.get(&1), Some(&10)); + assert_eq!(cache.len(), 1); + assert!(!cache.is_empty()); + assert_eq!(cache.remove(&1), Some(10)); + assert!(cache.is_empty()); + + cache.set(2, 20); + assert!(cache.delete(&2)); + assert!(!cache.delete(&2)); + + cache.set(3, 30); + assert!(cache.contains(&3)); + cache.clear(); + assert!(cache.is_empty()); + } + + // Generic function with a CachedExt bound -- must compile and work at runtime. + fn fill_and_drain(cache: &mut C, key: K, val: V) -> Option + where + K: std::hash::Hash + Eq + Clone, + V: Clone + PartialEq + std::fmt::Debug, + C: cached::CachedExt, + { + // Use fully-qualified syntax to avoid ambiguity: both Cached and CachedExt + // provide set/get/remove as defaults or blanket impls. + cached::CachedExt::set(cache, key.clone(), val.clone()); + assert_eq!(cached::CachedExt::get(cache, &key), Some(&val)); + cached::CachedExt::remove(cache, &key) + } + + #[test] + fn cached_ext_generic_bound_works() { + use cached::UnboundCache; + let mut c: UnboundCache = UnboundCache::builder().build().unwrap(); + let removed = fill_and_drain(&mut c, 42u32, 100u32); + assert_eq!(removed, Some(100)); + } + + // hits/misses/metrics accessible via CachedExt alone. + #[test] + fn cached_ext_metrics_via_ext_trait_only() { + use cached::{CachedExt, UnboundCache}; + + let mut cache: UnboundCache = UnboundCache::builder().build().unwrap(); + cache.set(1, 10); + let _ = cache.get(&1); // hit + let _ = cache.get(&2); // miss + assert_eq!(cache.hits(), Some(1)); + assert_eq!(cache.misses(), Some(1)); + let m = cache.metrics(); + assert_eq!(m.hits, Some(1)); + assert_eq!(m.misses, Some(1)); + assert_eq!(m.entry_count, 1); + } + + // Only ConcurrentCachedExt in scope, not ConcurrentCached. + #[test] + fn concurrent_cached_ext_short_aliases_without_concurrent_cached_in_scope() { + use cached::{ConcurrentCachedExt, ShardedUnboundCache}; + + let cache: ShardedUnboundCache = + ShardedUnboundCache::builder().build().expect("build"); + + // Use fully-qualified syntax for trait version (sharded stores have inherent + // get/set that shadow the trait method in method-call syntax). + assert_eq!( + ConcurrentCachedExt::set(&cache, 1u32, 10u32).expect("infallible"), + None + ); + assert_eq!( + ConcurrentCachedExt::get(&cache, &1u32).expect("infallible"), + Some(10) + ); + assert_eq!( + ConcurrentCachedExt::remove(&cache, &1u32).expect("infallible"), + Some(10) + ); + assert_eq!( + ConcurrentCachedExt::get(&cache, &1u32).expect("infallible"), + None + ); + } + + // Generic function with a ConcurrentCachedExt bound. + fn concurrent_fill(cache: &C, key: K, val: V) -> Option + where + K: std::hash::Hash + Eq + Clone, + V: Clone, + C: cached::ConcurrentCachedExt, + C::Error: std::fmt::Debug, + { + cached::ConcurrentCachedExt::set(cache, key.clone(), val).expect("infallible"); + cached::ConcurrentCachedExt::remove(cache, &key).expect("infallible") + } + + #[test] + fn concurrent_cached_ext_generic_bound_works() { + use cached::ShardedUnboundCache; + let cache: ShardedUnboundCache = + ShardedUnboundCache::builder().build().expect("build"); + let removed = concurrent_fill(&cache, 7u32, 99u32); + assert_eq!(removed, Some(99)); + } + + // get_or_set_with is available via ConcurrentCachedExt. + #[test] + fn concurrent_cached_ext_get_or_set_with_works() { + use cached::{ConcurrentCachedExt, ShardedUnboundCache}; + + let cache: ShardedUnboundCache = + ShardedUnboundCache::builder().build().expect("build"); + let v = cache.get_or_set_with(10, || 99).expect("infallible"); + assert_eq!(v, 99); + // Second call must hit and not invoke the factory. + let v2 = cache + .get_or_set_with(10, || panic!("factory must not run on hit")) + .expect("infallible"); + assert_eq!(v2, 99); + } + + // Short aliases reachable via `use cached::prelude::*` without a separate `use cached::CachedExt`. + // Both `Cached` and `CachedExt` are exported through the prelude; their short-alias methods + // must be unambiguous (no E0034) when both are in scope. + #[test] + fn short_aliases_reachable_via_prelude() { + use cached::prelude::*; + // Store types are NOT in the prelude; import them explicitly. + use cached::{ShardedUnboundCache, UnboundCache}; + + // Sync store: `prelude::*` brings in both `Cached` and `CachedExt`; the short aliases + // must resolve without E0034 ambiguity. + let mut cache: UnboundCache = UnboundCache::builder().build().unwrap(); + assert_eq!(cache.set(1, 10), None); + assert_eq!(cache.get(&1), Some(&10)); + assert_eq!(cache.len(), 1); + assert_eq!(cache.hits(), Some(1)); + assert_eq!(cache.misses(), Some(0)); + + // Concurrent store: ConcurrentCachedExt aliases must also be reachable via prelude. + let cc: ShardedUnboundCache = + ShardedUnboundCache::builder().build().expect("build"); + ConcurrentCachedExt::set(&cc, 42u32, 99u32).expect("infallible"); + assert_eq!( + ConcurrentCachedExt::get(&cc, &42u32).expect("infallible"), + Some(99) + ); + } +}