From f0bc44c824561ddacd0c4e850c483a9a623c0e83 Mon Sep 17 00:00:00 2001 From: James Kominick Date: Thu, 4 Jun 2026 00:22:55 -0400 Subject: [PATCH] feat: sharded stores, unified Option/Result handling, v2.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Breaking macro changes: - `result = true` / `option = true` removed from `#[cached]` and `#[once]`; interceptors added with clear removal errors pointing to `cache_err`/`cache_none` - `option = true` never valid on `#[concurrent_cached]`; error updated accordingly - `result_fallback` no longer requires `result = true`; now requires `ttl` (compile guard) - `map_error` on default sharded path is a compile error (was silently ignored) - `cache_err + expires`, `cache_none + expires`, `result_fallback + expires`, `result_fallback + with_cached_flag`, `cache_none + with_cached_flag` all rejected at compile time with actionable diagnostics New macro attributes: - `cache_err = true` — opt-in to cache Err values (`#[cached]`, `#[once]`, `#[concurrent_cached]`) - `cache_none = true` — opt-in to cache None values (same three macros) - `result_fallback = true` on `#[concurrent_cached]` — returns last cached Ok on Err; requires `ttl`; stale TTL refreshed on every Err call; first-ever-failure returns raw Err - `shards = N` on `#[concurrent_cached]` quick-reference table New sharded concurrent stores: - ShardedCache, ShardedLruCache, ShardedTtlCache, ShardedLruTtlCache, ShardedExpiringCache, ShardedExpiringLruCache — Arc-backed, per-shard parking_lot RwLock, builder APIs, on_evict callbacks, metrics, 128-byte cache-line padding - `#[concurrent_cached]` defaults to in-memory sharded stores; `ty`/`create`/`map_error` optional on the in-memory path New traits and methods: - `ConcurrentCloneCached` — returns stale cache entries with expiry status without evicting; implemented on four expiry-capable sharded stores - `cache_remove_entry` added as required method to `Cached` and `ConcurrentCached`; returns Some for expired-but-present entries (unlike `cache_remove`) - `cache_clear_with_on_evict()` on all 13 stores; fires on_evict per entry, increments eviction counter (plain `cache_clear()` remains side-effect-free) - `cache_delete` added as default method on `Cached` (was `ConcurrentCached`-only) - `cache_delete` on `ConcurrentCached` now returns true for expired entries (BREAKING) Store behavior changes: - `cache_remove` on expiring stores returns None for expired-but-present entries - `is_result_return_type` / `is_option_return_type` use exact ident match; aliases like `MyResult` are treated as plain values and Err is cached - `LruCache::retain` fires on_evict and increments evictions counter - All non-unbound stores fire on_evict on explicit `cache_remove` - Rename `clear_with_on_evict` → `cache_clear_with_on_evict` (cache_ prefix convention) - Rename `LruTtlInner::ttl_evictions` → `non_capacity_evictions` - `DiskCache`/`RedisCache`/`AsyncRedisCache` `ConcurrentCached` impls now require `K: Clone` Fixes and hardening: - Fix use-after-move in `concurrent_cached` result_fallback for non-Copy key types - Async result_fallback uses `ConcurrentCachedAsync` (.await) consistently - Fix dead `is_smart_option` branch; Option + non-default backend errors correctly - result_fallback static visibility matches annotated function visibility - Safe two-lookup pattern in TtlSortedCache (revert unsafe raw-pointer optimization) - CACHE_LINE padding: `assert!()` replaces unused dead const - `#[concurrent_cached]` store-conflict diagnostics (create/redis/disk) echo the user-written attribute name (`max_size` vs reconciled `size`) - Document that explicit `shards = N` is rounded up to a power of two but not clamped to the 8–1024 default range (`size`/`max_size` attribute unaffected) - Document that `CachedIter::iter()` (non-sharded `ExpiringCache`/`ExpiringLruCache` only) filters expired entries without removing them from the map - Document that sharded reads clone values from under the shard lock, so `V: Clone` - Remove stale 1.1-to-1.2 migration doc (version never released, content contradicted code) - Stabilize flaky async CI test with timing-independent assertion Tests: - Integration tests for `#[concurrent_cached]` with `cache_err`, `cache_none`, `result_fallback` (sync + async, TTL expiry, first-ever-failure, non-Copy keys) - Eviction counter tests for all 11 stores with eviction tracking - cache_remove_entry coverage: counter increment, absent-key, stored-key identity - Compile-fail tests for all new mutual-exclusion guards, including the `max_size` store-conflict diagnostic wording - All sharded TTL tests moved into `time_store_tests` module (project convention) Macro attribute deprecation: - `size = N` on `#[cached]` / `#[concurrent_cached]` is now a deprecated alias for `max_size = N`; it still compiles but emits a deprecation warning (a hard error under `-D warnings` / `#![deny(deprecated)]`) steering users to `max_size`. Setting both on one annotation remains a compile error. - Implemented via a `#[deprecated]` marker const (`__DEPRECATED_SIZE_ATTR`) referenced through `quote_spanned!` anchored at the user's `size` token, so the warning points at the attribute. All internal uses (tests, examples, doctests, README) migrated to `max_size`; dedicated alias-regression tests keep `size` covered under `#[allow(deprecated)]`, and trybuild UI tests pin the deprecation diagnostic. Builder rename: - Sharded builder `per_shard_size` -> `per_shard_max_size`, matching the `max_size` vocabulary used across the rest of the size-bound API. Dev tooling (skills/agents, not shipped in the crate): - New `pr-review` skill: standalone read-only review of a PR or checked-out branch (acquire diff, spawn code-review + consumer sub-agents in parallel, report findings with severity and a valid/already-fixed/invalid verdict). Review-agent model defaults to Sonnet, overridable per run (e.g. opus). - `pr-cycle` refactored into the orchestrator: its review step delegates to `pr-review` instead of duplicating it; retains `full`/`local`/`remote` modes and the review-agent model override. - `release` skill gains a pre-release `review` mode: runs a whole-crate consistency review plus an API-examination pass (via the `consumer-experience-review` skill) and reports lingering inconsistencies and a breaking/non-breaking improvement list; advisory only. - Add `pr-code-reviewer`, `pr-consumer-reviewer`, `pr-fix-implementer` agent definitions the skills reference. Version bump: 1.1 → 2.0.0 Migration guide: docs/migrations/1.1-to-2.0-human.md --- .../consumer-experience-review/SKILL.md | 0 .agents/skills/pr-cycle/SKILL.md | 308 ++ .agents/skills/pr-cycle/pr.py | 515 ++++ .agents/skills/pr-review/SKILL.md | 130 + .agents/skills/release/SKILL.md | 191 ++ .agents/skills/release/detect-crates.sh | 23 + .claude/agents/pr-code-reviewer.md | 62 + .claude/agents/pr-consumer-reviewer.md | 62 + .claude/agents/pr-fix-implementer.md | 44 + .claude/skills/consumer-experience-review | 1 + .claude/skills/pr-cycle | 1 + .claude/skills/pr-cycle/SKILL.md | 126 - .claude/skills/pr-review | 1 + .claude/skills/release | 1 + .claude/skills/release/SKILL.md | 83 - .github/workflows/codspeed.yml | 37 - .gitignore | 1 + AGENTS.md | 46 +- CHANGELOG.md | 86 +- CONTRIBUTING.md | 12 + Cargo.toml | 14 +- Makefile | 2 + README.md | 239 +- benches/cache_benches.rs | 332 ++- cached_proc_macro/Cargo.toml | 2 +- cached_proc_macro/src/cached.rs | 134 +- cached_proc_macro/src/concurrent_cached.rs | 1059 ++++++- cached_proc_macro/src/helpers.rs | 54 +- cached_proc_macro/src/lib.rs | 151 +- cached_proc_macro/src/once.rs | 81 +- .../0.x-to-1.0-human.md} | 28 +- .../0.x-to-1.0.md} | 2 +- docs/migrations/1.0-to-1.1.md | 72 + docs/migrations/1.1-to-2.0-human.md | 250 ++ docs/migrations/1.1-to-2.0.md | 200 ++ examples/async_std.rs | 10 +- examples/basic.rs | 4 +- examples/expires_per_key.rs | 2 +- examples/kitchen_sink.rs | 17 +- examples/sharded.rs | 171 ++ examples/sharded_expiring.rs | 143 + examples/tokio.rs | 4 +- examples/wasm/src/main.rs | 3 +- src/lib.rs | 608 +++- src/lru_list.rs | 4 +- src/macros.rs | 14 +- src/stores/disk.rs | 62 +- src/stores/expiring.rs | 185 +- src/stores/expiring_lru.rs | 317 +- src/stores/lru.rs | 373 ++- src/stores/lru_ttl.rs | 334 ++- src/stores/mod.rs | 117 +- src/stores/redis.rs | 53 +- src/stores/sharded/expiring.rs | 1096 +++++++ src/stores/sharded/expiring_lru.rs | 1296 ++++++++ src/stores/sharded/lru.rs | 979 ++++++ src/stores/sharded/lru_ttl.rs | 1668 +++++++++++ src/stores/sharded/mod.rs | 200 ++ src/stores/sharded/ttl.rs | 1222 ++++++++ src/stores/sharded/unbound.rs | 793 +++++ src/stores/ttl.rs | 241 +- src/stores/ttl_sorted.rs | 523 +++- src/stores/unbound.rs | 232 +- tests/cached.rs | 2644 +++++++++++++++-- ...ached_cache_err_requires_result_return.rs} | 2 +- ...ed_cache_err_requires_result_return.stderr | 5 + ...ed_cache_err_result_fallback_exclusive.rs} | 2 +- ...cache_err_result_fallback_exclusive.stderr | 5 + ...ached_cache_none_requires_option_return.rs | 8 + ...d_cache_none_requires_option_return.stderr | 5 + ...d_cache_none_with_cached_flag_exclusive.rs | 8 + ...che_none_with_cached_flag_exclusive.stderr | 5 + .../ui/cached_expires_cache_none_exclusive.rs | 14 + ...cached_expires_cache_none_exclusive.stderr | 5 + tests/ui/cached_option_attr_removed.rs | 8 + tests/ui/cached_option_attr_removed.stderr | 5 + ...eturn.rs => cached_result_attr_removed.rs} | 4 +- tests/ui/cached_result_attr_removed.stderr | 5 + tests/ui/cached_result_complex_return.stderr | 5 - .../ui/cached_result_fallback_sync_writes.rs | 2 +- tests/ui/cached_result_no_inner_type.stderr | 5 - tests/ui/cached_result_no_return.rs | 8 - tests/ui/cached_result_no_return.stderr | 7 - .../ui/cached_result_option_exclusive.stderr | 5 - tests/ui/cached_size_attr_deprecated.rs | 12 + tests/ui/cached_size_attr_deprecated.stderr | 11 + tests/ui/cached_size_max_size_exclusive.rs | 8 + .../ui/cached_size_max_size_exclusive.stderr | 7 + ...hed_cache_err_result_fallback_exclusive.rs | 8 + ...cache_err_result_fallback_exclusive.stderr | 5 + ...concurrent_cached_cache_none_with_redis.rs | 9 + ...urrent_cached_cache_none_with_redis.stderr | 5 + .../concurrent_cached_complex_return.stderr | 2 +- ...oncurrent_cached_custom_ty_required.stderr | 5 - ...rent_cached_expires_cache_err_exclusive.rs | 14 + ..._cached_expires_cache_err_exclusive.stderr | 5 + ...ent_cached_expires_cache_none_exclusive.rs | 8 + ...cached_expires_cache_none_exclusive.stderr | 5 + ...current_cached_expires_create_exclusive.rs | 8 + ...ent_cached_expires_create_exclusive.stderr | 5 + ...oncurrent_cached_expires_disk_exclusive.rs | 8 + ...rrent_cached_expires_disk_exclusive.stderr | 5 + ...ncurrent_cached_expires_redis_exclusive.rs | 8 + ...rent_cached_expires_redis_exclusive.stderr | 5 + ...urrent_cached_expires_refresh_exclusive.rs | 8 + ...nt_cached_expires_refresh_exclusive.stderr | 5 + ...concurrent_cached_expires_ttl_exclusive.rs | 8 + ...urrent_cached_expires_ttl_exclusive.stderr | 5 + .../concurrent_cached_expires_ty_exclusive.rs | 8 + ...current_cached_expires_ty_exclusive.stderr | 5 + ...ncurrent_cached_map_error_on_infallible.rs | 9 + ...rent_cached_map_error_on_infallible.stderr | 5 + ...current_cached_max_size_create_conflict.rs | 10 + ...ent_cached_max_size_create_conflict.stderr | 5 + tests/ui/concurrent_cached_no_return.stderr | 2 +- ...concurrent_cached_non_result_return.stderr | 2 +- .../concurrent_cached_option_attr_removed.rs | 9 + ...ncurrent_cached_option_attr_removed.stderr | 5 + ...ncurrent_cached_option_attr_unsupported.rs | 9 + ...rent_cached_option_attr_unsupported.stderr | 5 + tests/ui/concurrent_cached_option_return.rs | 5 +- .../ui/concurrent_cached_option_return.stderr | 8 +- .../ui/concurrent_cached_option_with_redis.rs | 9 + ...concurrent_cached_option_with_redis.stderr | 5 + .../concurrent_cached_redis_disk_exclusive.rs | 8 + ...current_cached_redis_disk_exclusive.stderr | 5 + .../concurrent_cached_refresh_without_ttl.rs | 8 + ...ncurrent_cached_refresh_without_ttl.stderr | 5 + ...ncurrent_cached_result_attr_unsupported.rs | 8 + ...rent_cached_result_attr_unsupported.stderr | 5 + ...ached_result_fallback_expires_exclusive.rs | 14 + ...d_result_fallback_expires_exclusive.stderr | 5 + ...ent_cached_result_fallback_requires_ttl.rs | 11 + ...cached_result_fallback_requires_ttl.stderr | 5 + ...ult_fallback_with_cached_flag_exclusive.rs | 8 + ...fallback_with_cached_flag_exclusive.stderr | 5 + .../ui/concurrent_cached_shards_with_disk.rs | 14 + .../concurrent_cached_shards_with_disk.stderr | 5 + .../ui/concurrent_cached_shards_with_redis.rs | 13 + ...concurrent_cached_shards_with_redis.stderr | 5 + tests/ui/concurrent_cached_shards_zero.rs | 8 + tests/ui/concurrent_cached_shards_zero.stderr | 5 + .../concurrent_cached_size_attr_deprecated.rs | 12 + ...current_cached_size_attr_deprecated.stderr | 11 + ...ncurrent_cached_size_max_size_exclusive.rs | 8 + ...rent_cached_size_max_size_exclusive.stderr | 7 + tests/ui/concurrent_cached_size_with_disk.rs | 13 + .../concurrent_cached_size_with_disk.stderr | 5 + .../ui/concurrent_cached_size_with_disk_ty.rs | 14 + ...concurrent_cached_size_with_disk_ty.stderr | 5 + tests/ui/concurrent_cached_size_with_redis.rs | 13 + .../concurrent_cached_size_with_redis.stderr | 5 + .../concurrent_cached_size_with_redis_ty.rs | 14 + ...oncurrent_cached_size_with_redis_ty.stderr | 5 + tests/ui/concurrent_cached_size_zero.rs | 8 + tests/ui/concurrent_cached_size_zero.stderr | 5 + ...ent_cached_sync_writes_attr_unsupported.rs | 8 + ...cached_sync_writes_attr_unsupported.stderr | 5 + ...uired.rs => concurrent_cached_ttl_zero.rs} | 2 +- tests/ui/concurrent_cached_ttl_zero.stderr | 5 + ...ent_cached_with_cached_flag_foreign.stderr | 2 + ...ncurrent_cached_with_cached_flag_option.rs | 9 + ...rent_cached_with_cached_flag_option.stderr | 5 + .../once_cache_err_requires_result_return.rs | 8 + ...ce_cache_err_requires_result_return.stderr | 5 + .../once_cache_none_requires_option_return.rs | 8 + ...e_cache_none_requires_option_return.stderr | 5 + ...e_cache_none_with_cached_flag_exclusive.rs | 8 + ...che_none_with_cached_flag_exclusive.stderr | 5 + tests/ui/once_option_attr_removed.rs | 8 + tests/ui/once_option_attr_removed.stderr | 5 + ..._return.rs => once_result_attr_removed.rs} | 4 +- tests/ui/once_result_attr_removed.stderr | 5 + tests/ui/once_result_no_return.stderr | 7 - tests/ui/once_result_option_exclusive.rs | 8 - tests/ui/once_result_option_exclusive.stderr | 5 - tests/ui/result_fallback_unbound_cache.rs | 2 +- .../ui/result_fallback_without_result.stderr | 2 +- 178 files changed, 17175 insertions(+), 1256 deletions(-) rename {.claude => .agents}/skills/consumer-experience-review/SKILL.md (100%) create mode 100644 .agents/skills/pr-cycle/SKILL.md create mode 100755 .agents/skills/pr-cycle/pr.py create mode 100644 .agents/skills/pr-review/SKILL.md create mode 100644 .agents/skills/release/SKILL.md create mode 100755 .agents/skills/release/detect-crates.sh create mode 100644 .claude/agents/pr-code-reviewer.md create mode 100644 .claude/agents/pr-consumer-reviewer.md create mode 100644 .claude/agents/pr-fix-implementer.md create mode 120000 .claude/skills/consumer-experience-review create mode 120000 .claude/skills/pr-cycle delete mode 100644 .claude/skills/pr-cycle/SKILL.md create mode 120000 .claude/skills/pr-review create mode 120000 .claude/skills/release delete mode 100644 .claude/skills/release/SKILL.md delete mode 100644 .github/workflows/codspeed.yml rename docs/{MIGRATION-1.0.md => migrations/0.x-to-1.0-human.md} (95%) rename docs/{MIGRATION-1.0-AGENT.md => migrations/0.x-to-1.0.md} (99%) create mode 100644 docs/migrations/1.0-to-1.1.md create mode 100644 docs/migrations/1.1-to-2.0-human.md create mode 100644 docs/migrations/1.1-to-2.0.md create mode 100644 examples/sharded.rs create mode 100644 examples/sharded_expiring.rs create mode 100644 src/stores/sharded/expiring.rs create mode 100644 src/stores/sharded/expiring_lru.rs create mode 100644 src/stores/sharded/lru.rs create mode 100644 src/stores/sharded/lru_ttl.rs create mode 100644 src/stores/sharded/mod.rs create mode 100644 src/stores/sharded/ttl.rs create mode 100644 src/stores/sharded/unbound.rs rename tests/ui/{cached_result_no_inner_type.rs => cached_cache_err_requires_result_return.rs} (73%) create mode 100644 tests/ui/cached_cache_err_requires_result_return.stderr rename tests/ui/{cached_result_option_exclusive.rs => cached_cache_err_result_fallback_exclusive.rs} (60%) create mode 100644 tests/ui/cached_cache_err_result_fallback_exclusive.stderr create mode 100644 tests/ui/cached_cache_none_requires_option_return.rs create mode 100644 tests/ui/cached_cache_none_requires_option_return.stderr create mode 100644 tests/ui/cached_cache_none_with_cached_flag_exclusive.rs create mode 100644 tests/ui/cached_cache_none_with_cached_flag_exclusive.stderr create mode 100644 tests/ui/cached_expires_cache_none_exclusive.rs create mode 100644 tests/ui/cached_expires_cache_none_exclusive.stderr create mode 100644 tests/ui/cached_option_attr_removed.rs create mode 100644 tests/ui/cached_option_attr_removed.stderr rename tests/ui/{cached_result_complex_return.rs => cached_result_attr_removed.rs} (56%) create mode 100644 tests/ui/cached_result_attr_removed.stderr delete mode 100644 tests/ui/cached_result_complex_return.stderr delete mode 100644 tests/ui/cached_result_no_inner_type.stderr delete mode 100644 tests/ui/cached_result_no_return.rs delete mode 100644 tests/ui/cached_result_no_return.stderr delete mode 100644 tests/ui/cached_result_option_exclusive.stderr create mode 100644 tests/ui/cached_size_attr_deprecated.rs create mode 100644 tests/ui/cached_size_attr_deprecated.stderr create mode 100644 tests/ui/cached_size_max_size_exclusive.rs create mode 100644 tests/ui/cached_size_max_size_exclusive.stderr create mode 100644 tests/ui/concurrent_cached_cache_err_result_fallback_exclusive.rs create mode 100644 tests/ui/concurrent_cached_cache_err_result_fallback_exclusive.stderr create mode 100644 tests/ui/concurrent_cached_cache_none_with_redis.rs create mode 100644 tests/ui/concurrent_cached_cache_none_with_redis.stderr delete mode 100644 tests/ui/concurrent_cached_custom_ty_required.stderr create mode 100644 tests/ui/concurrent_cached_expires_cache_err_exclusive.rs create mode 100644 tests/ui/concurrent_cached_expires_cache_err_exclusive.stderr create mode 100644 tests/ui/concurrent_cached_expires_cache_none_exclusive.rs create mode 100644 tests/ui/concurrent_cached_expires_cache_none_exclusive.stderr create mode 100644 tests/ui/concurrent_cached_expires_create_exclusive.rs create mode 100644 tests/ui/concurrent_cached_expires_create_exclusive.stderr create mode 100644 tests/ui/concurrent_cached_expires_disk_exclusive.rs create mode 100644 tests/ui/concurrent_cached_expires_disk_exclusive.stderr create mode 100644 tests/ui/concurrent_cached_expires_redis_exclusive.rs create mode 100644 tests/ui/concurrent_cached_expires_redis_exclusive.stderr create mode 100644 tests/ui/concurrent_cached_expires_refresh_exclusive.rs create mode 100644 tests/ui/concurrent_cached_expires_refresh_exclusive.stderr create mode 100644 tests/ui/concurrent_cached_expires_ttl_exclusive.rs create mode 100644 tests/ui/concurrent_cached_expires_ttl_exclusive.stderr create mode 100644 tests/ui/concurrent_cached_expires_ty_exclusive.rs create mode 100644 tests/ui/concurrent_cached_expires_ty_exclusive.stderr create mode 100644 tests/ui/concurrent_cached_map_error_on_infallible.rs create mode 100644 tests/ui/concurrent_cached_map_error_on_infallible.stderr create mode 100644 tests/ui/concurrent_cached_max_size_create_conflict.rs create mode 100644 tests/ui/concurrent_cached_max_size_create_conflict.stderr create mode 100644 tests/ui/concurrent_cached_option_attr_removed.rs create mode 100644 tests/ui/concurrent_cached_option_attr_removed.stderr create mode 100644 tests/ui/concurrent_cached_option_attr_unsupported.rs create mode 100644 tests/ui/concurrent_cached_option_attr_unsupported.stderr create mode 100644 tests/ui/concurrent_cached_option_with_redis.rs create mode 100644 tests/ui/concurrent_cached_option_with_redis.stderr create mode 100644 tests/ui/concurrent_cached_redis_disk_exclusive.rs create mode 100644 tests/ui/concurrent_cached_redis_disk_exclusive.stderr create mode 100644 tests/ui/concurrent_cached_refresh_without_ttl.rs create mode 100644 tests/ui/concurrent_cached_refresh_without_ttl.stderr create mode 100644 tests/ui/concurrent_cached_result_attr_unsupported.rs create mode 100644 tests/ui/concurrent_cached_result_attr_unsupported.stderr create mode 100644 tests/ui/concurrent_cached_result_fallback_expires_exclusive.rs create mode 100644 tests/ui/concurrent_cached_result_fallback_expires_exclusive.stderr create mode 100644 tests/ui/concurrent_cached_result_fallback_requires_ttl.rs create mode 100644 tests/ui/concurrent_cached_result_fallback_requires_ttl.stderr create mode 100644 tests/ui/concurrent_cached_result_fallback_with_cached_flag_exclusive.rs create mode 100644 tests/ui/concurrent_cached_result_fallback_with_cached_flag_exclusive.stderr create mode 100644 tests/ui/concurrent_cached_shards_with_disk.rs create mode 100644 tests/ui/concurrent_cached_shards_with_disk.stderr create mode 100644 tests/ui/concurrent_cached_shards_with_redis.rs create mode 100644 tests/ui/concurrent_cached_shards_with_redis.stderr create mode 100644 tests/ui/concurrent_cached_shards_zero.rs create mode 100644 tests/ui/concurrent_cached_shards_zero.stderr create mode 100644 tests/ui/concurrent_cached_size_attr_deprecated.rs create mode 100644 tests/ui/concurrent_cached_size_attr_deprecated.stderr create mode 100644 tests/ui/concurrent_cached_size_max_size_exclusive.rs create mode 100644 tests/ui/concurrent_cached_size_max_size_exclusive.stderr create mode 100644 tests/ui/concurrent_cached_size_with_disk.rs create mode 100644 tests/ui/concurrent_cached_size_with_disk.stderr create mode 100644 tests/ui/concurrent_cached_size_with_disk_ty.rs create mode 100644 tests/ui/concurrent_cached_size_with_disk_ty.stderr create mode 100644 tests/ui/concurrent_cached_size_with_redis.rs create mode 100644 tests/ui/concurrent_cached_size_with_redis.stderr create mode 100644 tests/ui/concurrent_cached_size_with_redis_ty.rs create mode 100644 tests/ui/concurrent_cached_size_with_redis_ty.stderr create mode 100644 tests/ui/concurrent_cached_size_zero.rs create mode 100644 tests/ui/concurrent_cached_size_zero.stderr create mode 100644 tests/ui/concurrent_cached_sync_writes_attr_unsupported.rs create mode 100644 tests/ui/concurrent_cached_sync_writes_attr_unsupported.stderr rename tests/ui/{concurrent_cached_custom_ty_required.rs => concurrent_cached_ttl_zero.rs} (72%) create mode 100644 tests/ui/concurrent_cached_ttl_zero.stderr create mode 100644 tests/ui/concurrent_cached_with_cached_flag_option.rs create mode 100644 tests/ui/concurrent_cached_with_cached_flag_option.stderr create mode 100644 tests/ui/once_cache_err_requires_result_return.rs create mode 100644 tests/ui/once_cache_err_requires_result_return.stderr create mode 100644 tests/ui/once_cache_none_requires_option_return.rs create mode 100644 tests/ui/once_cache_none_requires_option_return.stderr create mode 100644 tests/ui/once_cache_none_with_cached_flag_exclusive.rs create mode 100644 tests/ui/once_cache_none_with_cached_flag_exclusive.stderr create mode 100644 tests/ui/once_option_attr_removed.rs create mode 100644 tests/ui/once_option_attr_removed.stderr rename tests/ui/{once_result_no_return.rs => once_result_attr_removed.rs} (59%) create mode 100644 tests/ui/once_result_attr_removed.stderr delete mode 100644 tests/ui/once_result_no_return.stderr delete mode 100644 tests/ui/once_result_option_exclusive.rs delete mode 100644 tests/ui/once_result_option_exclusive.stderr diff --git a/.claude/skills/consumer-experience-review/SKILL.md b/.agents/skills/consumer-experience-review/SKILL.md similarity index 100% rename from .claude/skills/consumer-experience-review/SKILL.md rename to .agents/skills/consumer-experience-review/SKILL.md diff --git a/.agents/skills/pr-cycle/SKILL.md b/.agents/skills/pr-cycle/SKILL.md new file mode 100644 index 00000000..7f8ae718 --- /dev/null +++ b/.agents/skills/pr-cycle/SKILL.md @@ -0,0 +1,308 @@ +--- +name: pr-cycle +description: PR review-and-update cycle — the orchestrator that takes a PR from review to resolved. It runs a local review (by delegating to the `pr-review` skill), fetches open GitHub review comments, evaluates all findings, applies valid fixes, runs CI, commits, pushes, resolves threads, and re-requests Copilot review. Supports three modes — `full` (default, everything), `local` (only the local-review-and-fix loop; no GitHub PR-conversation reads or mutations), and `remote` (only the GitHub PR feedback loop; no local review). The review sub-agents default to Sonnet but can be overridden per run (e.g. to opus). Use when asked to "run the pr cycle", "address pr comments", "resolve comments and re-request review", "review and fix the branch", "run the remote pr cycle", or after pushing a new round of changes. For a read-only review with no fixes/push, use `pr-review` instead. +allowed-tools: Bash, Read, Edit, Write, Agent +--- + +# PR Cycle + +Run one full iteration of the review → fix → push → re-request loop. This is the +**orchestrator**: it produces findings, addresses them with fixes, and updates the PR +(commit, push, resolve threads, re-request review). + +The **review** half is owned by the separate `pr-review` skill, which spawns the two +read-only review sub-agents and reports their findings. `pr-cycle` does not duplicate +that logic — step 2 below delegates to `pr-review`. Reach for `pr-review` directly when +you only want a read-only review (no fixes, no push, no GitHub-conversation changes); +reach for `pr-cycle` when you want those findings actually addressed and the PR updated. + +The helper script is `.agents/skills/pr-cycle/pr.py`. Run it outside the sandbox (GitHub API requires network). Multiple commands can be passed in one call so only one permission prompt is needed: + +```bash +.agents/skills/pr-cycle/pr.py PR_NUMBER COMMAND [COMMAND ...] +``` + +Available commands: `comments`, `threads`, `resolve`, `rerequest`, `minimize`, +`codspeed`, `ci`, `readme`, `pushpreview`, `diff`. + +## Modes + +This skill runs in one of three modes. The mode is taken from the input (see [Input](#input)): + +- **`full`** (default) — the complete review → fix → push → resolve → re-request loop. Runs every step below. +- **`local`** — only the **local review and fix loop**. Runs the `pr-review` skill (the local code-review and consumer sub-agents), evaluates their findings, applies fixes, runs CI, regenerates the README, and commits/pushes. Does **not** interact with the PR conversation on GitHub: it does not read PR comments or threads, does not edit the PR body, and does not resolve, minimize/hide, or re-request Copilot review. +- **`remote`** — only the **GitHub PR feedback loop**. Fetches and evaluates open PR comments/threads, applies fixes, runs CI, regenerates the README, commits/pushes, then resolves threads, minimizes prior comments, re-requests Copilot review, and audits the PR body. Does **not** run the local review (`pr-review`). + +Both `local` and `remote` still commit and push the fixes they make (a code change has to land to be useful), and both follow the push protocol (show the push preamble before pushing). "Does not interact with GitHub" for `local` means the PR-conversation operations — comment/thread reads and mutations, Copilot re-request, PR-body edits — not the `git push` itself. + +Step-by-step applicability (✓ = runs in that mode): + +| Step | What | `full` | `local` | `remote` | +|------|------|:------:|:-------:|:--------:| +| 1 | Fetch open comments + threads | ✓ | | ✓ | +| 2 | Run local review (`pr-review`) | ✓ | ✓ | | +| 3 | Evaluate findings | ✓ | ✓ (agents only) | ✓ (PR comments only) | +| 4 | Apply fixes | ✓ | ✓ | ✓ | +| 5 | Run CI | ✓ | ✓ | ✓ | +| 6 | Regenerate README | ✓ | ✓ | ✓ | +| 7 | Sync audit (CHANGELOG / commit msg) | ✓ | ✓ | ✓ | +| 7c | Sync audit — PR body edit | ✓ | | ✓ | +| 8 | Commit + push | ✓ | ✓ | ✓ | +| 9 | Resolve threads + re-request Copilot | ✓ | | ✓ | +| 10 | Minimize prior comments | ✓ | | ✓ | +| 11 | Report | ✓ | ✓ | ✓ | + +When a step is not applicable to the active mode, skip it entirely — do not run its `pr.py` command or `gh` call. In `local` mode you must not invoke any of `comments`, `threads`, `resolve`, `rerequest`, `minimize`, or `gh pr edit`/`gh pr view --json body`. + +## Model tiers + +This skill is designed to keep expensive Opus reasoning concentrated in the +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) | + +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 +core is done. + +## Input + +Optional PR number, an optional mode keyword (`full`, `local`, or `remote`), and an optional review-agent model override, in any order. + +- **Mode**: if one of `local` / `remote` is present in the input, use it; otherwise default to `full`. Phrasings map as: "local review" / "just the local reviewers" / "local pr cycle" → `local`; "remote" / "address the pr comments" / "resolve and re-request" / "remote pr cycle" → `remote`; anything else (or "run the pr cycle") → `full`. +- **Review-agent model**: the model used by the local review sub-agents (`pr-code-reviewer`, `pr-consumer-reviewer`) **defaults to `sonnet`**, but can be overridden. If the input names a model (e.g. "use opus for the reviewers", "review with opus", "opus reviewers", "model=opus"), pass that model through to the `pr-review` delegation in step 2 (which forwards it to the Agent tool's `model` parameter on both sub-agent spawns). Only the review sub-agents are affected — this does not change the session model or the model used by `pr-fix-implementer`. This override is only meaningful in modes that run the local review (`full`, `local`); ignore it in `remote` mode. +- **PR number**: if omitted, infer it from the current branch using `gh pr view --json number`. In `local` mode the PR number is only needed for the `pr.py` wrapper commands (`ci`, `readme`, `pushpreview`) and for the push preamble — none of which read or mutate the PR conversation. + +Announce the resolved mode (and, when the reviewers will run, the review-agent model) at the start — e.g. "Running pr-cycle in **local** mode with **opus** reviewers" — before executing any step. + +## Steps + +### 1. Fetch ALL open comments and unresolved threads + +**Modes: `full`, `remote`.** Skip entirely in `local` mode. + +Run outside the sandbox: + +```bash +.agents/skills/pr-cycle/pr.py PR_NUMBER comments threads +``` + +`comments` fetches all inline review comments, separates them into new (post-last-push) vs pre-existing unresolved, and prints each with its ID, `created_at`, file:line, author, and body. Record every comment for evaluation in step 3 — do not filter by timestamp. + +`threads` lists all review thread node IDs with their resolution status. Unresolved threads will all be resolved in step 10. + +### 2. Run the local review (delegate to `pr-review`) + +**Modes: `full`, `local`.** Skip entirely in `remote` mode (no local review runs). + +The review itself lives in the `pr-review` skill — do not re-implement it here. Run that +skill against this PR/branch to obtain the local findings, passing through the +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** + spawns), and +- return a consolidated findings report with severity and a per-finding verdict. + +Carry `pr-review`'s findings forward into step 3, where they are evaluated alongside any +GitHub PR comments (in `full` mode). Do not act on fixes inside `pr-review` — it is +read-only; addressing findings is step 4 here. + +### 3. Evaluate all findings + +**Modes: all.** The set of findings depends on the mode: +- `full` — all open inline PR comments (from step 1) **plus** both sub-agent reports (from step 2). +- `local` — **only** the two sub-agent reports. Do not reference PR comments. +- `remote` — **only** the open inline PR comments. There are no sub-agent reports. + +Present all in-scope findings together. For each finding: +- **Valid**: the concern is real and the code should change +- **Already fixed**: the concern was valid but the code has already been corrected (the comment is stale) — mark for resolution only +- **Invalid**: the finding is incorrect or environment-specific (e.g. rustc version mismatch on trybuild golden files) + +Explain your reasoning for each verdict. Do not apply any fix silently — call out what you are doing and why. + +### 4. Apply fixes for valid findings + +**Modes: all.** + +#### 4a. Write a fix spec for each valid finding (Opus / judgment core) + +For each valid finding, produce an explicit fix spec before touching any file: + +``` +Finding: +Target: +Location: +Change: +Test: + OR "no test needed (doc-only)" +``` + +The spec must be precise enough for Sonnet to apply without judgment calls. +Test design always stays here — this repo requires tests that fail on the unfixed +code and pass on the fixed code; a trivially-passing test defeats the rule. + +Common fix types: +- Documentation/comment updates: `src/stores/`, `src/lib.rs`, `cached_proc_macro/src/lib.rs` +- Test additions/corrections: `tests/cached.rs` +- 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: + +```bash +.agents/skills/pr-cycle/pr.py PR_NUMBER ci +``` + +Then spot-check: confirm that at least one of the newly added tests would fail if +its corresponding fix were reverted. (Read the test and reason through it; you do +not have to literally revert the fix.) + +### 5. Run CI + +**Modes: all.** Run outside the sandbox: + +```bash +.agents/skills/pr-cycle/pr.py PR_NUMBER ci +``` + +This runs `make ci`, filters Redis/Docker noise, and exits non-zero only on real +failures. If it exits non-zero, fix the reported failures and re-run. + +If trybuild golden files drift, regenerate them: +```bash +TRYBUILD=overwrite cargo test --no-default-features --features "proc_macro,time_stores" compile_fail_macro_arg_validation +``` + +### 6. Regenerate README if `src/lib.rs` changed + +**Modes: all.** Run outside the sandbox: + +```bash +.agents/skills/pr-cycle/pr.py PR_NUMBER readme +``` + +No-ops automatically when `src/lib.rs` is unchanged vs `origin/master`. + +### 7. Sync audit — docs, commit message, and PR summary + +**Modes: all** for parts (a), (b), (d). **Part (c) — PR-body edit — runs only in `full` and `remote`; skip it in `local`** (it reads and mutates the PR on GitHub). + +Before staging anything, do a consistency check. The goal: every artifact that describes "what this PR does" must match the actual diff. + +**a. Diff summary** — produce a concise internal summary of what the branch actually changes: + +```bash +git diff origin/master --stat +git diff origin/master -- CHANGELOG.md +``` + +**b. CHANGELOG.md** — read the `[Unreleased]` section. For each bullet: +- Does it describe something that is actually in `git diff origin/master`? If a bullet refers to a feature or behavior that was removed, reverted, or renamed, update or remove it. +- Is anything significant in the diff that is NOT mentioned? Add it. +- Check accuracy of any named types, method signatures, attribute names, or feature gates — they must exactly match the code. + +**c. PR description** (`full` / `remote` only — skip in `local`) — read the current PR body: + +```bash +gh pr view PR_NUMBER --json body +``` + +Apply the same audit: every claim must match the diff. Pay special attention to: +- Named types or methods that were renamed or removed +- Feature/behavior claims that no longer apply (e.g. "replaces AtomicU64 across all stores" when only two stores were changed) +- Test counts that are now stale + +Update the PR body if anything is inaccurate: + +```bash +gh pr edit PR_NUMBER --body "..." +``` + +**d. Commit message** — draft a concise new commit message for the fixes made in this cycle. The message should describe only the newly applied changes, not the entire PR. + +Do not make the sync audit a "wall of changes" — only fix what is actually wrong. + +### 8. Create a new commit and push + +**Modes: all** (only if fixes were applied this pass). Applies to `local` and `remote` too — fixes have to land to be useful. + +```bash +git add -p # stage only changed files explicitly +git commit -m "fix: address PR review feedback" +``` + +Before pushing, run outside the sandbox to show the push preamble: + +```bash +.agents/skills/pr-cycle/pr.py PR_NUMBER pushpreview +``` + +Then add a one-sentence summary of what the push contains (e.g. "Pushing 1 commit: +doc fix for option+expires constraint and CHANGELOG update"). Then push: + +```bash +git push origin BRANCH +``` + +Create a new commit for every PR-cycle pass that changes files. Do not amend previous commits and do not force push unless the user explicitly requests history rewriting. + +Do not add a `Co-Authored-By` line. + +### 9. Resolve all open threads and re-request Copilot review + +**Modes: `full`, `remote`.** Skip entirely in `local` mode (no PR-conversation mutations, no Copilot re-request). + +After the push, run outside the sandbox — combining both steps in one call: + +```bash +.agents/skills/pr-cycle/pr.py PR_NUMBER resolve rerequest +``` + +`resolve` re-fetches all unresolved threads and resolves each one via GraphQL mutation. Goal: zero open threads after this step. + +`rerequest` triggers a fresh Copilot review on the PR. + +### 10. Minimize all comments from before this cycle + +**Modes: `full`, `remote`.** Skip entirely in `local` mode. + +After threads are resolved, hide all inline review comments and top-level PR comments so the PR conversation is clean. Run outside the sandbox: + +```bash +.agents/skills/pr-cycle/pr.py PR_NUMBER minimize +``` + +This fetches all inline review comments and top-level PR comments from all authors and calls the GitHub `minimizeComment` GraphQL mutation with classifier `RESOLVED` on each one. Only comments created before the last push timestamp are minimized — comments posted after the push (i.e., responses to the new round of changes) are left visible. Use `--dry-run` first to preview which comments would be minimized. + +### 11. Report + +**Modes: all.** State the mode that ran, and report only the lines relevant to it. + +- The mode that ran (`full` / `local` / `remote`). +- (`full` / `remote`) How many inline PR comments were found total; how many were new (post-last-push) vs. pre-existing unresolved; how many were valid/already-fixed/invalid; how many were fixed this cycle. +- (`full` / `local`) How many code-reviewer findings were found, how many were valid, how many were fixed. +- (`full` / `local`) How many consumer-reviewer findings were found, how many were valid, how many were fixed. +- Which findings were ruled invalid and why. +- Sync audit result: what was corrected in CHANGELOG, the new commit message, and — in `full` / `remote` — the PR description (or "all in sync"). +- (`full` / `remote`) Confirm threads resolved (state total resolved count) and Copilot re-requested. +- The resulting new commit SHA and push status (or "no changes to commit this pass"). diff --git a/.agents/skills/pr-cycle/pr.py b/.agents/skills/pr-cycle/pr.py new file mode 100755 index 00000000..181be760 --- /dev/null +++ b/.agents/skills/pr-cycle/pr.py @@ -0,0 +1,515 @@ +#!/usr/bin/env python3 +""" +pr.py — consolidated PR cycle helper. + +Usage: + pr.py PR_NUMBER COMMAND [COMMAND ...] [options] + +Commands (executed in the order given): + comments Fetch and display all inline PR review comments (new vs pre-existing) + threads List all review threads with resolution status + resolve Resolve all open review threads + rerequest Re-request Copilot review + minimize Minimize (hide) all eligible pre-push comments as "resolved" + codspeed Check CodSpeed benchmark results + ci Run `make ci`, filter Redis/Docker noise, exit non-zero on real failures + readme Regenerate README from src/lib.rs if that file changed vs origin/master + pushpreview Print the git log + diff --stat push preamble for the current branch + diff Print git diff origin/master (for handing to reviewer agents) + +Options: + --owner OWNER GitHub owner (auto-detected from `gh repo view`) + --repo REPO GitHub repo (auto-detected from `gh repo view`) + --branch BRANCH Branch name for codspeed / pushpreview (auto-detected if omitted) + --since TS ISO timestamp; comments after this are 'new' (default: last push) + --limit N Max workflow runs to fetch for codspeed (default: 5) + --dry-run For 'resolve': list threads that would be resolved without mutating + +Examples: + pr.py 264 comments + pr.py 264 threads + pr.py 264 resolve rerequest minimize + pr.py 264 comments threads resolve rerequest minimize codspeed + pr.py 264 ci + pr.py 264 readme + pr.py 264 pushpreview + pr.py 264 diff +""" + +import argparse +import json +import subprocess +import sys +from datetime import datetime, timezone + +COMMANDS = ("comments", "threads", "resolve", "rerequest", "minimize", "codspeed", + "ci", "readme", "pushpreview", "diff") + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + +def gh(*args, check=True, **kwargs): + return subprocess.run(["gh", *args], capture_output=True, text=True, check=check, **kwargs) + + +def repo_info(): + r = gh("repo", "view", "--json", "owner,name", check=False) + if r.returncode != 0: + sys.exit("Cannot auto-detect owner/repo. Pass --owner and --repo explicitly.") + d = json.loads(r.stdout) + return d["owner"]["login"], d["name"] + + +def graphql(query): + return gh("api", "graphql", "-f", f"query={query}") + + +def parse_utc(ts): + return datetime.fromisoformat(ts.rstrip("Z")).replace(tzinfo=timezone.utc) + + +def section(title): + print(f"\n{'=' * 3} {title} {'=' * (max(0, 60 - len(title)))}") + + +# --------------------------------------------------------------------------- +# comments +# --------------------------------------------------------------------------- + +def cmd_comments(pr, owner, repo, since=None): + section(f"PR #{pr} inline comments") + + r = gh("api", f"repos/{owner}/{repo}/pulls/{pr}/comments", "--paginate") + comments = json.loads(r.stdout) + + if since is None: + r2 = gh("api", f"repos/{owner}/{repo}/pulls/{pr}/commits", + "--paginate", check=False) + if r2.returncode == 0 and r2.stdout.strip(): + commits = json.loads(r2.stdout) + if commits: + since = commits[-1]["commit"]["committer"]["date"] + + since_dt = parse_utc(since) if since else None + + new_comments, old_comments = [], [] + for c in comments: + if since_dt and parse_utc(c["created_at"]) > since_dt: + new_comments.append(c) + else: + old_comments.append(c) + + print(f"Total inline comments: {len(comments)}") + if since_dt: + print(f" New (after {since}): {len(new_comments)}") + print(f" Pre-existing: {len(old_comments)}") + print() + + def show(c, label): + line = c.get("line") or c.get("original_line") or "?" + print(f"[{label}] id={c['id']} {c['created_at']} {c['path']}:{line} @{c['user']['login']}") + body = c["body"].replace("\n", " ").strip() + if len(body) > 320: + body = body[:317] + "..." + print(f" {body}") + print() + + if new_comments: + print("=== NEW COMMENTS ===") + for c in new_comments: + show(c, "NEW") + + if old_comments: + print("=== PRE-EXISTING COMMENTS ===") + for c in old_comments: + show(c, "OLD") + + +# --------------------------------------------------------------------------- +# threads +# --------------------------------------------------------------------------- + +_THREADS_QUERY = """\ +{{ + repository(owner: "{owner}", name: "{repo}") {{ + pullRequest(number: {pr}) {{ + reviewThreads(first: 100) {{ + nodes {{ + id + isResolved + comments(first: 1) {{ + nodes {{ + databaseId + createdAt + body + }} + }} + }} + }} + }} + }} +}}""" + + +def fetch_threads(owner, repo, pr): + r = graphql(_THREADS_QUERY.format(owner=owner, repo=repo, pr=pr)) + nodes = json.loads(r.stdout)["data"]["repository"]["pullRequest"]["reviewThreads"]["nodes"] + threads = [] + for n in nodes: + first = n["comments"]["nodes"][0] if n["comments"]["nodes"] else {} + threads.append({ + "id": n["id"], + "isResolved": n["isResolved"], + "created_at": first.get("createdAt", ""), + "database_id": first.get("databaseId"), + "body_preview": first.get("body", "")[:200], + }) + return threads + + +def cmd_threads(pr, owner, repo): + section(f"PR #{pr} review threads") + threads = fetch_threads(owner, repo, pr) + unresolved = sum(1 for t in threads if not t["isResolved"]) + print(f"Total: {len(threads)} Unresolved: {unresolved}") + print(json.dumps(threads, indent=2)) + + +# --------------------------------------------------------------------------- +# resolve +# --------------------------------------------------------------------------- + +_RESOLVE_MUTATION = """\ +mutation {{ + resolveReviewThread(input: {{threadId: "{thread_id}"}}) {{ + thread {{ id isResolved }} + }} +}}""" + + +def cmd_resolve(pr, owner, repo, dry_run=False): + section(f"PR #{pr} resolve threads") + threads = fetch_threads(owner, repo, pr) + unresolved = [t["id"] for t in threads if not t["isResolved"]] + print(f"Found {len(unresolved)} unresolved thread(s).") + + if not unresolved: + print("Nothing to do.") + return + + if dry_run: + print("Dry run — would resolve:") + for tid in unresolved: + print(f" {tid}") + return + + resolved = 0 + for tid in unresolved: + print(f" Resolving {tid} ...", end=" ", flush=True) + r = graphql(_RESOLVE_MUTATION.format(thread_id=tid)) + if r.returncode == 0: + print("ok") + resolved += 1 + else: + print(f"FAILED: {r.stderr.strip()}") + + print(f"\nResolved {resolved}/{len(unresolved)} thread(s).") + if resolved < len(unresolved): + sys.exit(1) + + +# --------------------------------------------------------------------------- +# minimize +# --------------------------------------------------------------------------- + +_MINIMIZE_MUTATION = """\ +mutation {{ + minimizeComment(input: {{subjectId: "{node_id}", classifier: RESOLVED}}) {{ + minimizedComment {{ + isMinimized + minimizedReason + }} + }} +}}""" + + +def cmd_minimize(pr, owner, repo, since=None, dry_run=False): + section(f"PR #{pr} minimize comments") + + # Determine cutoff: only minimize comments created at or before this + # timestamp, which defaults to the last push (same boundary cmd_comments + # uses for "pre-existing"). This prevents accidentally hiding comment + # threads that were opened after the cycle started. + if since is None: + r0 = gh("api", f"repos/{owner}/{repo}/pulls/{pr}/commits", + "--paginate", "--jq", "[-1].commit.committer.date", check=False) + if r0.returncode == 0 and r0.stdout.strip(): + since = r0.stdout.strip().strip('"') + + since_dt = parse_utc(since) if since else None + if since_dt: + print(f"Cutoff: {since} (comments after this timestamp are skipped)") + + r = gh("api", f"repos/{owner}/{repo}/pulls/{pr}/comments", "--paginate") + review_comments = json.loads(r.stdout) + + r2 = gh("api", f"repos/{owner}/{repo}/issues/{pr}/comments", "--paginate") + issue_comments = json.loads(r2.stdout) + + eligible = [ + c for c in review_comments + issue_comments + if since_dt is None or parse_utc(c["created_at"]) <= since_dt + ] + print(f"Found {len(eligible)} eligible comment(s) to minimize.") + + if dry_run: + for c in eligible: + print(f" Would minimize: {c['node_id']} @{c['user']['login']} {c.get('path', '')} {c['created_at']}") + return + + minimized = 0 + for c in eligible: + print(f" Minimizing {c['node_id']} (@{c['user']['login']}) ...", end=" ", flush=True) + r = graphql(_MINIMIZE_MUTATION.format(node_id=c["node_id"])) + if r.returncode == 0: + print("ok") + minimized += 1 + else: + print(f"FAILED: {r.stderr.strip()}") + + print(f"\nMinimized {minimized}/{len(eligible)} comment(s).") + if minimized < len(eligible): + sys.exit(1) + + +# --------------------------------------------------------------------------- +# rerequest +# --------------------------------------------------------------------------- + +def cmd_rerequest(pr, owner, repo): + section(f"PR #{pr} re-request Copilot review") + gh("api", f"repos/{owner}/{repo}/pulls/{pr}/requested_reviewers", + "-X", "POST", "-f", "reviewers[]=copilot-pull-request-reviewer[bot]") + print(f"Copilot review re-requested for {owner}/{repo}#{pr}.") + + +# --------------------------------------------------------------------------- +# codspeed +# --------------------------------------------------------------------------- + +def cmd_codspeed(pr, owner, repo, branch=None, limit=5): + section(f"PR #{pr} CodSpeed") + print("CodSpeed is not configured for this repository.") + print(".github/workflows/codspeed.yml was removed in PR #264.") + print("To re-enable, restore the workflow and update --workflow= in this command.") + + +# --------------------------------------------------------------------------- +# ci +# --------------------------------------------------------------------------- + +# Patterns in stderr/stdout that indicate a Redis or Docker-related failure that +# is expected / acceptable in local environments. +_REDIS_DOCKER_PATTERNS = ( + "redis", + "docker", + "connection refused", + "cannot connect", + "ECONNREFUSED", + "failed to connect", + "redis_store", + "disk_store", # disk-store tests depend on the Redis feature flag indirectly +) + + +def cmd_ci(): + section("CI") + print("Running `make ci` …") + r = subprocess.run( + ["make", "ci"], + capture_output=True, + text=True, + ) + combined = r.stdout + r.stderr + + if r.returncode == 0: + print("make ci passed.") + return + + # Separate lines into Redis/Docker noise vs real failures. + lines = combined.splitlines() + real_failure_lines = [] + for line in lines: + lower = line.lower() + if any(p in lower for p in _REDIS_DOCKER_PATTERNS): + continue + # Lines that mention FAILED or error (but not from noise) are real. + if "error" in lower or "failed" in lower or "panicked" in lower: + real_failure_lines.append(line) + + if not real_failure_lines: + # All failures appear to be Redis/Docker related. + print("make ci exited non-zero, but all failures appear to be Redis/Docker-related (expected in local env).") + print("Redis/Docker failures are acceptable — treating as pass.") + return + + print(f"make ci FAILED (exit {r.returncode}). Real (non-Redis/Docker) failures:") + print() + for line in real_failure_lines[:80]: + print(f" {line}") + if len(real_failure_lines) > 80: + print(f" … ({len(real_failure_lines) - 80} more lines)") + print() + print("Full output saved to stderr above. Fix these failures before proceeding.") + sys.exit(r.returncode) + + +# --------------------------------------------------------------------------- +# readme +# --------------------------------------------------------------------------- + +def cmd_readme(): + section("README regeneration") + r = subprocess.run( + ["git", "diff", "--name-only", "origin/master", "--", "src/lib.rs"], + capture_output=True, + text=True, + ) + if not r.stdout.strip(): + print("src/lib.rs is unchanged vs origin/master — README regeneration skipped.") + return + + print("src/lib.rs changed — regenerating README.md …") + r2 = subprocess.run( + ["cargo", "readme", "--no-indent-headings"], + capture_output=True, + text=True, + ) + if r2.returncode != 0: + print(f"cargo readme failed (exit {r2.returncode}):") + print(r2.stderr) + sys.exit(r2.returncode) + + with open("README.md", "w") as f: + f.write(r2.stdout) + print("README.md regenerated successfully.") + + +# --------------------------------------------------------------------------- +# pushpreview +# --------------------------------------------------------------------------- + +def _current_branch(): + r = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, text=True, check=True, + ) + return r.stdout.strip() + + +def cmd_pushpreview(branch=None): + section("Push preview") + if branch is None: + branch = _current_branch() + origin_ref = f"origin/{branch}" + + print(f"Branch: {branch} → {origin_ref}\n") + + r1 = subprocess.run( + ["git", "log", f"{origin_ref}..HEAD", "--oneline"], + capture_output=True, text=True, + ) + if r1.stdout.strip(): + print("Commits to push:") + for line in r1.stdout.strip().splitlines(): + print(f" {line}") + else: + print("No commits ahead of remote.") + + print() + r2 = subprocess.run( + ["git", "diff", f"{origin_ref}", "--stat"], + capture_output=True, text=True, + ) + if r2.stdout.strip(): + print("Files changed:") + print(r2.stdout.rstrip()) + else: + print("No file changes vs remote.") + + +# --------------------------------------------------------------------------- +# diff +# --------------------------------------------------------------------------- + +def cmd_diff(): + section("diff origin/master") + r = subprocess.run( + ["git", "diff", "origin/master"], + capture_output=True, text=True, + ) + print(r.stdout) + if r.stderr: + print(r.stderr, file=sys.stderr) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main(): + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + ap.add_argument("pr", type=int, help="PR number") + ap.add_argument("commands", nargs="+", choices=COMMANDS, + metavar="COMMAND", + help=f"One or more of: {', '.join(COMMANDS)}") + ap.add_argument("--owner") + ap.add_argument("--repo") + ap.add_argument("--branch", help="Branch name (for codspeed; auto-detected if omitted)") + ap.add_argument("--since", help="ISO timestamp for 'comments' new/old split") + ap.add_argument("--limit", type=int, default=5, help="Max runs for codspeed (default: 5)") + ap.add_argument("--dry-run", action="store_true", help="For 'resolve': list without mutating") + # ci/readme/pushpreview/diff commands do not require a PR number, but the + # positional 'pr' arg is still required by argparse. For those commands the + # PR number is accepted but unused (pass 0 if you have no PR handy). + args = ap.parse_args() + + # Commands that need the GitHub owner/repo (REST or GraphQL API). + _GH_COMMANDS = {"comments", "threads", "resolve", "rerequest", "minimize", "codspeed"} + # Fetch owner/repo lazily — only if at least one command needs it. + owner = repo = None + if any(cmd in _GH_COMMANDS for cmd in args.commands): + owner, repo = args.owner, args.repo + if not owner or not repo: + owner, repo = repo_info() + + for cmd in args.commands: + if cmd == "comments": + cmd_comments(args.pr, owner, repo, since=args.since) + elif cmd == "threads": + cmd_threads(args.pr, owner, repo) + elif cmd == "resolve": + cmd_resolve(args.pr, owner, repo, dry_run=args.dry_run) + elif cmd == "rerequest": + cmd_rerequest(args.pr, owner, repo) + elif cmd == "minimize": + cmd_minimize(args.pr, owner, repo, since=args.since, dry_run=args.dry_run) + elif cmd == "codspeed": + cmd_codspeed(args.pr, owner, repo, branch=args.branch, limit=args.limit) + elif cmd == "ci": + cmd_ci() + elif cmd == "readme": + cmd_readme() + elif cmd == "pushpreview": + cmd_pushpreview(branch=args.branch) + elif cmd == "diff": + cmd_diff() + + +if __name__ == "__main__": + main() diff --git a/.agents/skills/pr-review/SKILL.md b/.agents/skills/pr-review/SKILL.md new file mode 100644 index 00000000..8e7ce59b --- /dev/null +++ b/.agents/skills/pr-review/SKILL.md @@ -0,0 +1,130 @@ +--- +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). +allowed-tools: Bash, Read, Agent +--- + +# PR Review + +Produce a fresh, read-only review of a PR or a checked-out branch and report the +findings. This is the "review" half of the PR workflow, extracted so it can be run +on its own. The orchestrator skill `pr-cycle` calls this skill to obtain its local +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 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 +PR comments/threads, resolve or minimize them, edit the PR body, or re-request +Copilot review. Those belong to `pr-cycle`. This skill only generates a fresh +agent-based review of the code itself. + +This skill is purely advisory: its output is a findings report for a human (or for +`pr-cycle`) to act on. It applies no changes. + +## Model tiers + +| 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) | + +## Input + +A target and an optional review-agent model override, in any order. + +- **Target**: either a **PR number**, or **nothing** (review the current checked-out + branch). If a PR number is omitted you may infer one from the current branch with + `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`, + `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. + +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. + +## Steps + +### 1. Acquire the diff + +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 +``` + +If you are targeting a specific PR, the `pr-cycle` helper prints the identical diff +and is equivalent: + +```bash +.agents/skills/pr-cycle/pr.py PR_NUMBER diff +``` + +Capture the full diff text — it is fed verbatim to both sub-agents. + +### 2. Spawn two independent sub-agents in parallel + +**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) + +**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) + +Both agents are read-only (no Edit/Write tools) 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"`). +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. + +### 3. Evaluate all findings + +Collect both sub-agent reports. 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 + handles it (the reviewer was working from a partial view). +- **Invalid** — the finding is incorrect or environment-specific (e.g. a rustc version + mismatch on trybuild golden files, or a "missing" feature gate that is actually + present). + +This verdict pass is the judgment core; run it on the session model (use Opus). Do not +soften or pad — an invalid finding called valid sends `pr-cycle` (or a human) chasing a +non-issue. + +### 4. 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). +- **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 + output. +- For each **invalid** or **already-fixed** finding: a one-line note on why it was ruled + so. +- A closing one-line verdict: is the branch/PR clean, or are there valid findings to + address (and how many high/medium)? + +Do not apply any fix. If the caller wants the findings addressed and pushed, that is +`pr-cycle`'s job. diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md new file mode 100644 index 00000000..7f299062 --- /dev/null +++ b/.agents/skills/release/SKILL.md @@ -0,0 +1,191 @@ +--- +name: release +description: Prepare a release (bump versions across all Cargo.toml files, update CHANGELOG.md, refresh the migration guide, regenerate README, commit), or run a pre-release review. The `review` option kicks off a consistency review and an API-examination review to surface lingering inconsistencies and a categorized list of breaking and non-breaking improvements before you cut the release — advisory only, it changes nothing. Use when asked to "cut a release", "bump the version", "prepare a release", "release X.Y.Z", or "do a release review" / "review for release". Takes a version string (e.g. `/release 1.2.0`) or `review` as the argument. +--- + +# Release + +Prepare a new release by bumping versions and updating the changelog — or, with the +`review` option, run a pre-release audit that surfaces inconsistencies and improvement +ideas without changing anything. + +## Input + +The argument selects the mode: + +- **`review`** (or "release review", "review for release", "pre-release review") — run the + **Release review** below. This is advisory only: it bumps nothing, edits no files, and + makes no commit. Run it *before* cutting a release. +- **A version string** (e.g. `1.2.0`) — run the **Version bump** flow (the numbered steps). + If no argument is given and the intent is clearly to cut a release, ask which version. + +If the user asks for a release review and then to proceed, run the review first and the +version bump second. + +## Release review + +A pre-release audit. Run it **before** cutting a release — especially a major version, +since a major bump is the only window in which breaking API changes are acceptable. It +**does not** bump versions, edit the changelog/README, or commit anything; it produces a +report. It kicks off two complementary reviews, then synthesizes them. + +### A. Consistency review + +Audit the public surface for lingering inconsistencies across the **whole crate**, not +just the latest diff. Scope it to everything accumulated since the last released tag: + +```bash +git describe --tags --abbrev=0 # last released tag +git diff "$(git describe --tags --abbrev=0)"..HEAD --stat # what this release will ship +``` + +Check for: +- **Naming parity** — do sibling types/methods share vocabulary? (e.g. `max_size` vs + `size`, `with_*` constructors, `try_with_*` fallible variants, `cache_*` method + prefixes). Flag any odd-one-out. +- **Builder / constructor symmetry** — does every store with a builder expose the same + setters where they make sense? Does each family (plain vs sharded, LRU vs TTL) have + parallel constructors? +- **Trait-method parity** — do the sync / async / sharded variants of a trait expose the + same method set with consistent signatures (`&self` vs `&mut self`)? +- **Feature-gate symmetry** — is each public item gated behind exactly the features it + needs, and are paired items gated consistently? +- **Doc / CHANGELOG / migration-guide alignment** — do the docs, the `[Unreleased]` + CHANGELOG section, and the migration guide accurately describe the shipped API (named + types, signatures, attribute names, feature gates)? + +Delegate the correctness/idiom sweep to a `pr-code-reviewer` sub-agent fed the full +release diff, and reason through the cross-type parity items yourself — they need a +whole-surface view the diff alone does not give. + +### B. API examination review + +Run the `consumer-experience-review` skill. It builds a throwaway external consumer that +depends on the crate the way a real user would and surfaces API gaps, naming +inconsistencies, trait-import friction, and feature-flag dead-ends with +**compiler-verified evidence**. This is the authoritative "what would a new user trip +over" pass. + +### C. Synthesize the report + +Merge both reviews into a single report. **Apply no change** — this mode is advisory. +Produce: + +1. **Lingering inconsistencies** — a flat list of every inconsistency found, each with a + `file:line` (or API path) and a one-line description. +2. **Recommended changes, split by impact:** + - **Breaking** — changes that alter the public API (renames, signature changes, removed + items, new required trait methods). For each: what to change, why it improves the + library, and the migration cost. Mark these "land now or wait for the next major" — a + major-version release is the only non-breaking window to make them. + - **Non-breaking** — additive or internal changes (new constructors, doc fixes, new + re-exports, deprecations that keep the old path working). For each: what to change and + why. +3. **Recommendation** — is the library consistent enough to release as-is, or are there + high-impact items that should land in this version first? Be explicit about which + breaking items, if deferred, are stuck until the next major. + +After presenting the report, ask whether to address findings first or proceed to the +version bump below. + +## Version bump + +These numbered steps are the default mode — run them when the argument is a version +string, or after a release review once the user opts to proceed. + +### 1. Determine which crates to bump + +This repo has three crates: +- `cached` — always bumped +- `cached_proc_macro` — bump if this PR/branch touched `cached_proc_macro/` +- `cached_proc_macro_types` — bump only if this PR/branch touched `cached_proc_macro_types/` + +Run the helper script to detect which crates need bumping: + +```bash +.agents/skills/release/detect-crates.sh +``` + +This outputs one crate name per line based on `git diff origin/master`. When in doubt, bump `cached` and `cached_proc_macro` together (the common case); `cached_proc_macro_types` rarely changes. + +### 2. Update `Cargo.toml` versions + +Files to update (only for crates being bumped): + +**`Cargo.toml`** (the `cached` crate): +- `[package] version` → new version +- `[dependencies.cached_proc_macro] version` → new version (if bumping proc_macro) +- `[dependencies.cached_proc_macro_types] version` → new version (if bumping proc_macro_types) + +**`cached_proc_macro/Cargo.toml`**: +- `[package] version` → new version (if bumping proc_macro) + +**`cached_proc_macro_types/Cargo.toml`**: +- `[package] version` → new version (if bumping proc_macro_types) + +Use precise string replacement — do not change dependency versions for third-party crates. + +### 3. Update `CHANGELOG.md` + +- Replace `## [Unreleased]` with `## [X.Y.Z / cached_proc_macro X.Y.Z]` (include only the crates being bumped in the heading — omit `cached_proc_macro_types` if it is not bumped) +- Add a fresh `## [Unreleased]` section above the new version heading +- The changelog must always have an `[Unreleased]` section at the top + +### 4. Create or update migration guide + +Every release requires a migration guide in `docs/migrations/` named `PREV-to-X.Y.Z.md` +(e.g. `1.1-to-1.2.md`). If there are no breaking changes, the guide still must exist and +must state there are no breaking changes. + +Migration guides are written for **agent consumption**: terse, mechanical, grep-friendly. +Required sections: + +- **Versions** header line +- **Breaking changes** — one subsection per change with Detection (what to grep/search for) + and Action (exact code transformation). If none, write "None. This release is purely additive." +- **New APIs** — additive changes; note the feature gate if any +- **Required Cargo.toml change** — exact before/after snippet +- **VERIFY** — the `cargo build` / `cargo test` commands needed to confirm a successful migration, + plus any expected new compile errors and their fixes + +See existing guides in `docs/migrations/` for the established format. + +If the guide for this version already exists (e.g. was drafted ahead of the release), review it +against the final diff and update any stale type names, method signatures, or behavior descriptions. + +### 5. Regenerate README + +```bash +cargo readme --no-indent-headings > README.md +``` + +### 6. Verify + +```bash +cargo check --no-default-features --features "proc_macro,time_stores" +``` + +Fix any compilation errors before proceeding. + +### 7. Commit or amend + +If there is already a single commit ahead of master on this branch, amend it: +```bash +git add Cargo.toml cached_proc_macro/Cargo.toml cached_proc_macro_types/Cargo.toml CHANGELOG.md README.md +git commit --amend --no-edit +``` + +Otherwise create a new commit: +```bash +git commit -m "release: bump version to X.Y.Z" +``` + +Do not add a `Co-Authored-By` line. + +### 8. Report + +Tell the user: +- Which crates were bumped and to what version +- Whether `cached_proc_macro_types` was left unchanged and why +- That README was regenerated +- The resulting commit SHA diff --git a/.agents/skills/release/detect-crates.sh b/.agents/skills/release/detect-crates.sh new file mode 100755 index 00000000..4550e8ed --- /dev/null +++ b/.agents/skills/release/detect-crates.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Detect which crates need version bumps based on files changed vs base ref. +# +# Usage: detect-crates.sh [BASE_REF] (default: origin/master) +# +# Output: one line per crate to bump — "cached", "cached_proc_macro", "cached_proc_macro_types" +# `cached` is always included. + +set -euo pipefail + +BASE=${1:-origin/master} + +changed=$(git diff "$BASE" --name-only) + +echo "cached" + +if echo "$changed" | grep -q "^cached_proc_macro/"; then + echo "cached_proc_macro" +fi + +if echo "$changed" | grep -q "^cached_proc_macro_types/"; then + echo "cached_proc_macro_types" +fi diff --git a/.claude/agents/pr-code-reviewer.md b/.claude/agents/pr-code-reviewer.md new file mode 100644 index 00000000..8011e06f --- /dev/null +++ b/.claude/agents/pr-code-reviewer.md @@ -0,0 +1,62 @@ +--- +name: pr-code-reviewer +model: sonnet +tools: Read, Grep, Glob, Bash +description: Read-only code reviewer for the cached crate. Reviews a PR diff for correctness, API design, test coverage, documentation accuracy, and Rust idiom adherence. Flags issues with severity; does NOT fix anything. +--- + +You are a read-only code reviewer for the `cached` Rust crate. Your job is to +review the diff supplied in the prompt and report findings. **Do not edit any +files. Do not apply any fix. Report only.** + +## Inputs (supplied in the prompt) + +- PR number and branch name +- The full diff (`git diff origin/master`) + +## Review rubric + +For each issue you find, assign a severity: + +- **high** — correctness bug, unsound unsafe, broken invariant, missing feature gate + that would cause a compile error, or any issue that would ship a regression +- **medium** — API design flaw, missing or misleading doc, test gap that leaves + a real behavior untested, or a footgun that will affect users +- **low** — Rust idiom violation, stylistic inconsistency, minor naming issue, + doc typo, or anything that would not affect users in practice + +## What to check + +1. **Correctness** — does the implementation match what the docs and tests claim? + Are edge cases (empty key, zero TTL, None value, concurrent access) handled? +2. **API design** — are names, method signatures, and trait bounds consistent with + the rest of the crate? Does the feature compose with `result`, `option`, + `result_fallback`, `sync_writes`? +3. **Test coverage** — is every new behavioral path exercised by a test that would + *fail* on the unfixed code and *pass* on the fixed code? Doc-tests count. +4. **Documentation accuracy** — do doc comments, examples, CHANGELOG bullets, and + the PR description accurately describe what the code does? Check named types, + method signatures, attribute names, and feature gates for exact match. +5. **Rust idioms** — prefer `expect` over `unwrap`, use idiomatic error propagation, + no unnecessary `clone`, no `#[allow(dead_code)]` in shipped code. + +## Feature-gate rule + +Each test must be gated behind exactly the features it depends on. The home for +expiring-store tests by convention is `time_store_tests`. + +## Output format + +List findings as a flat numbered list. Each entry: + +``` +N. [SEVERITY] File:line — one-sentence summary + Detail: what is wrong and why it matters. +``` + +After the list, print a summary line: +``` +Total: X high, Y medium, Z low findings. +``` + +If you find nothing, say "No findings." Do not pad the list. diff --git a/.claude/agents/pr-consumer-reviewer.md b/.claude/agents/pr-consumer-reviewer.md new file mode 100644 index 00000000..ca6e279b --- /dev/null +++ b/.claude/agents/pr-consumer-reviewer.md @@ -0,0 +1,62 @@ +--- +name: pr-consumer-reviewer +model: sonnet +tools: Read, Grep, Glob, Bash +description: Read-only library-consumer reviewer for the cached crate. Evaluates a PR diff from the perspective of a downstream crate author adding or upgrading `cached` as a dependency. Flags usability, doc, and footgun issues; does NOT fix anything. +--- + +You are evaluating changes to the `cached` Rust crate **solely from the perspective +of a downstream crate author** who is adding or upgrading `cached` as a dependency. +You are not a reviewer of the implementation — you are a user of the public API. +**Do not edit any files. Do not apply any fix. Report only.** + +## Inputs (supplied in the prompt) + +- PR number and branch name +- The full diff (`git diff origin/master`) +- The current `src/lib.rs` doc comments and/or `README.md` excerpts covering the + changed APIs + +## What to assess + +For each issue you find, assign a severity: + +- **high** — a user *cannot* correctly use the feature without reading the source, + or will write obviously wrong code that compiles but silently misbehaves +- **medium** — a user would likely be confused, reach for the wrong API, or have + difficulty diagnosing a compile-fail error without extra research +- **low** — a doc gap, minor naming awkwardness, or a nice-to-have improvement + that does not block correct usage + +Specifically ask: + +1. **Intuitiveness** — Are names, method signatures, and trait bounds what a user + would expect? Are there surprising or inconsistent naming choices compared to + other `cached` APIs? +2. **Doc sufficiency** — Can a user understand and use the feature *without reading + the source*? Are there gaps, ambiguities, or missing examples in the docs? +3. **Footguns** — What easy mistakes can a user make that the docs do not warn + about? (E.g. "leaving stale entries visible via `cache_size()` after expiry" is + documented; an equivalent undocumented footgun would be a finding.) +4. **Composability** — Does the feature compose naturally with existing `cached` + attributes: `result`, `option`, `result_fallback`, `sync_writes`? Are + incompatible combinations either caught at compile time or clearly documented? +5. **Compile-fail clarity** — If a user mis-uses the feature, are the resulting + compiler or proc-macro errors clear enough to self-diagnose? If not, what would + the confused user see? + +## Output format + +List findings as a flat numbered list. Each entry: + +``` +N. [SEVERITY] Area (doc / api-surface / footgun / composability / error-msg) — one-sentence summary + Detail: what a confused user would experience and why it matters. +``` + +After the list, print a summary line: +``` +Total: X high, Y medium, Z low findings. +``` + +If you find nothing, say "No findings." Do not pad the list. diff --git a/.claude/agents/pr-fix-implementer.md b/.claude/agents/pr-fix-implementer.md new file mode 100644 index 00000000..6334ccd4 --- /dev/null +++ b/.claude/agents/pr-fix-implementer.md @@ -0,0 +1,44 @@ +--- +name: pr-fix-implementer +model: sonnet +tools: Read, Edit, Write, Bash +description: Applies an explicit, pre-specified fix to the working tree for the cached crate. Executes a fix spec verbatim — does not decide what to fix, does not expand scope beyond the spec. Used for mechanical or repetitive fixes delegated by the pr-cycle judgment core. +--- + +You apply a single, explicit fix spec to the working tree. You do **not** decide +what to fix. You do **not** expand scope beyond what is specified. You follow the +spec exactly. + +## What you receive (in the prompt) + +A structured fix spec containing: + +1. **Target file(s)** — exact path(s) to edit +2. **Location** — file:line or a unique code snippet to locate the edit point +3. **Change** — the exact new text, or a precise description of what to add/remove/ + replace (never "improve the wording" — always a precise specification) +4. **Test** — the exact test function name, location, and assertions to add. + If the spec says "no test needed" (doc-only fix), skip this step. + +## What you do + +1. **Read** the target file(s) to confirm the location matches the spec. +2. **Apply** the specified change exactly. Do not reformat surrounding code, + do not fix unrelated issues, do not rename anything not in the spec. +3. **Add the test** (if specified) in the location and with the exact assertions + given. Do not add additional test coverage beyond what is specified. +4. **Verify** by running the narrowest relevant check: + - For a `src/` change: `cargo check --no-default-features --features ` + - For a `tests/` or behavioral change: `cargo test --no-default-features --features ` + - For a `cached_proc_macro/` change: `cargo check -p cached_proc_macro` + Do not run `make ci` (that is for the orchestrator). +5. **Report** pass/fail with the exact command and output. If the check fails, + report the error verbatim and stop — do not attempt to fix the failure on your own. + +## Hard constraints + +- **Do not expand scope.** If the spec says "fix line 42 of `src/stores/ttl.rs`", + touch only that location. +- **Do not amend the commit** — you are not committing; the orchestrator commits. +- **Do not run `make ci`** — that is for the orchestrator. +- **If the spec is ambiguous**, report the ambiguity and stop. Do not guess. diff --git a/.claude/skills/consumer-experience-review b/.claude/skills/consumer-experience-review new file mode 120000 index 00000000..a6194ffb --- /dev/null +++ b/.claude/skills/consumer-experience-review @@ -0,0 +1 @@ +../../.agents/skills/consumer-experience-review \ No newline at end of file diff --git a/.claude/skills/pr-cycle b/.claude/skills/pr-cycle new file mode 120000 index 00000000..06ba97cf --- /dev/null +++ b/.claude/skills/pr-cycle @@ -0,0 +1 @@ +../../.agents/skills/pr-cycle \ No newline at end of file diff --git a/.claude/skills/pr-cycle/SKILL.md b/.claude/skills/pr-cycle/SKILL.md deleted file mode 100644 index 35b3379c..00000000 --- a/.claude/skills/pr-cycle/SKILL.md +++ /dev/null @@ -1,126 +0,0 @@ ---- -name: pr-cycle -description: Full PR review cycle — fetch open review comments, spawn an independent code-review sub-agent and a library-consumer sub-agent, evaluate all findings, apply valid fixes, run CI, amend and push, resolve all threads, and re-request review from Copilot. Use when asked to "run the pr cycle", "address pr comments", "resolve comments and re-request review", or after pushing a new round of changes. ---- - -# PR Cycle - -Run one full iteration of the review → fix → push → re-request loop. - -## Input - -Optional PR number. If omitted, infer it from the current branch using `gh pr view --json number`. - -## Steps - -### 1. Fetch open Copilot comments - -Run outside the sandbox (GitHub API requires network): - -```bash -gh api repos/OWNER/REPO/pulls/PR/comments --paginate -``` - -Filter to comments created after the last push (compare `created_at` to the last force-push timestamp). Print each with: ID, file:line, and body. - -Also fetch unresolved review threads via GraphQL to get their node IDs for later resolution: - -```bash -gh api graphql -f query='{ repository(owner:"OWNER", name:"REPO") { pullRequest(number:PR) { reviewThreads(first:50) { nodes { id isResolved comments(first:1) { nodes { databaseId } } } } } } }' -``` - -### 2. Spawn two independent sub-agents in parallel - -**Agent A — code reviewer**: Spawn with the `code-reviewer` subagent type (or general-purpose if unavailable). Prompt must include: -- The PR number and branch name -- The full diff (`git diff origin/master`) -- Instructions to review for correctness, API design, test coverage, documentation accuracy, and Rust idiom adherence -- Instructions to flag any issues with severity (high / medium / low) -- Instructions NOT to fix anything — report only - -**Agent B — library consumer**: Spawn a general-purpose agent in a read-only research role. Prompt must include: -- The PR number and branch name -- The full diff (`git diff origin/master`) -- The current `src/lib.rs` doc comments and `README.md` (or the relevant excerpts covering the changed APIs) -- Instructions to evaluate the changes **solely from the perspective of a downstream crate author** who is adding or upgrading `cached` as a dependency — not as a reviewer of the implementation -- Specifically ask it to assess: - - Is the public API surface intuitive? Are names, method signatures, and trait bounds what a user would expect? - - Are the docs sufficient to use the feature without reading the source? Are there gaps, ambiguities, or missing examples? - - Are there footguns — easy mistakes a user could make that aren't warned about in the docs? - - Does the feature compose naturally with existing `cached` features (e.g., `result`, `option`, `result_fallback`, `sync_writes`)? - - Are error messages from compile-fail cases clear enough for a user to self-diagnose? -- Instructions to flag any issues with severity (high / medium / low) -- Instructions NOT to fix anything — report only - -Launch both agents in parallel. Wait for both to complete before proceeding. - -### 3. Evaluate all findings - -Present all three sets of findings (Copilot comments + code-reviewer + consumer reviewer) together. For each finding: -- **Valid**: the concern is real and the code should change -- **Invalid**: the finding is incorrect, already addressed, or environment-specific (e.g. rustc version mismatch on trybuild golden files) - -Explain your reasoning for each verdict. Do not apply any fix silently — call out what you are doing and why. - -### 4. Apply fixes for valid findings - -For each valid finding, make the minimal correct fix. Common fix types for this repo: -- Documentation/comment updates in `src/stores/`, `src/lib.rs`, or `cached_proc_macro/src/lib.rs` -- Test additions or corrections in `tests/cached.rs` -- Trybuild golden file regeneration: `TRYBUILD=overwrite cargo test --no-default-features --features "proc_macro,time_stores" compile_fail_macro_arg_validation` -- Macro code changes in `cached_proc_macro/src/` - -### 5. Run CI - -```bash -make ci -``` - -Fix any errors. Redis and Docker failures are expected in local environments and can be ignored. All other failures must be resolved. - -If trybuild golden files drift, regenerate them: -```bash -TRYBUILD=overwrite cargo test --no-default-features --features "proc_macro,time_stores" compile_fail_macro_arg_validation -``` - -### 6. Regenerate README if `src/lib.rs` changed - -```bash -cargo readme --no-indent-headings > README.md -``` - -### 7. Amend and push - -```bash -git add -p # stage only changed files explicitly -git commit --amend --no-edit -git push --force-with-lease origin BRANCH -``` - -Do not add a `Co-Authored-By` line. - -### 8. Resolve all open threads - -For each unresolved thread node ID collected in step 1: - -```bash -gh api graphql -f query='mutation { resolveReviewThread(input: {threadId: "THREAD_ID"}) { thread { id isResolved } } }' -``` - -Run outside the sandbox. Do not leave replies — resolve silently. - -### 9. Re-request Copilot review - -```bash -gh api repos/OWNER/REPO/pulls/PR/requested_reviewers \ - -X POST -f 'reviewers[]=copilot-pull-request-reviewer[bot]' -``` - -### 10. Report - -- How many Copilot comments were found, how many were valid, how many were fixed -- How many code-reviewer findings were found, how many were valid, how many were fixed -- How many consumer-reviewer findings were found, how many were valid, how many were fixed -- Which findings were ruled invalid and why -- Confirm threads resolved and Copilot re-requested -- The resulting commit SHA and push status diff --git a/.claude/skills/pr-review b/.claude/skills/pr-review new file mode 120000 index 00000000..321fc637 --- /dev/null +++ b/.claude/skills/pr-review @@ -0,0 +1 @@ +../../.agents/skills/pr-review \ No newline at end of file diff --git a/.claude/skills/release b/.claude/skills/release new file mode 120000 index 00000000..14f8a38a --- /dev/null +++ b/.claude/skills/release @@ -0,0 +1 @@ +../../.agents/skills/release \ No newline at end of file diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md deleted file mode 100644 index 93cf52a1..00000000 --- a/.claude/skills/release/SKILL.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -name: release -description: Bump versions across all Cargo.toml files and update CHANGELOG.md for a new release. Use when asked to "cut a release", "bump the version", "prepare a release", or "release X.Y.Z". Takes a version string as the argument (e.g. `/release 1.2.0`). ---- - -# Release - -Prepare a new release by bumping versions and updating the changelog. - -## Input - -The argument is the new version string, e.g. `1.2.0`. If no argument is given, ask the user which version to release. - -## Steps - -### 1. Determine which crates to bump - -This repo has three crates: -- `cached` — always bumped -- `cached_proc_macro` — bump if this PR/branch touched `cached_proc_macro/` -- `cached_proc_macro_types` — bump only if this PR/branch touched `cached_proc_macro_types/` - -Check `git diff origin/master --name-only` to determine which crate directories were modified. When in doubt, bump `cached` and `cached_proc_macro` together (the common case); `cached_proc_macro_types` rarely changes. - -### 2. Update `Cargo.toml` versions - -Files to update (only for crates being bumped): - -**`Cargo.toml`** (the `cached` crate): -- `[package] version` → new version -- `[dependencies.cached_proc_macro] version` → new version (if bumping proc_macro) -- `[dependencies.cached_proc_macro_types] version` → new version (if bumping proc_macro_types) - -**`cached_proc_macro/Cargo.toml`**: -- `[package] version` → new version (if bumping proc_macro) - -**`cached_proc_macro_types/Cargo.toml`**: -- `[package] version` → new version (if bumping proc_macro_types) - -Use precise string replacement — do not change dependency versions for third-party crates. - -### 3. Update `CHANGELOG.md` - -- Replace `## [Unreleased]` with `## [X.Y.Z / cached_proc_macro X.Y.Z]` (include only the crates being bumped in the heading — omit `cached_proc_macro_types` if it is not bumped) -- Add a fresh `## [Unreleased]` section above the new version heading -- The changelog must always have an `[Unreleased]` section at the top - -### 4. Regenerate README - -```bash -cargo readme --no-indent-headings > README.md -``` - -### 5. Verify - -```bash -cargo check --no-default-features --features "proc_macro,time_stores" -``` - -Fix any compilation errors before proceeding. - -### 6. Commit or amend - -If there is already a single commit ahead of master on this branch, amend it: -```bash -git add Cargo.toml cached_proc_macro/Cargo.toml cached_proc_macro_types/Cargo.toml CHANGELOG.md README.md -git commit --amend --no-edit -``` - -Otherwise create a new commit: -```bash -git commit -m "release: bump version to X.Y.Z" -``` - -Do not add a `Co-Authored-By` line. - -### 7. Report - -Tell the user: -- Which crates were bumped and to what version -- Whether `cached_proc_macro_types` was left unchanged and why -- That README was regenerated -- The resulting commit SHA diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml deleted file mode 100644 index 455eb9fe..00000000 --- a/.github/workflows/codspeed.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: CodSpeed - -on: - push: - branches: - - "master" - pull_request: - # `workflow_dispatch` allows CodSpeed to trigger backtest - # performance analysis in order to generate initial data. - workflow_dispatch: - -permissions: - contents: read - id-token: write - -jobs: - codspeed: - name: Run benchmarks - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Setup rust toolchain, cache and cargo-codspeed binary - uses: moonrepo/setup-rust@v0 - with: - channel: stable - cache-target: release - bins: cargo-codspeed - - - name: Build the benchmark target(s) - run: cargo codspeed build - - - name: Run the benchmarks - uses: CodSpeedHQ/action@v4 - with: - mode: simulation - run: cargo codspeed run diff --git a/.gitignore b/.gitignore index b1bdb5a1..54e2f394 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ _tmp_readme.md .DS_Store local/ !local/.gitkeep +.antigravitycli/ diff --git a/AGENTS.md b/AGENTS.md index 760f9127..728258c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,16 @@ Key points: - After adding or removing Makefile targets, update `make help` and verify with `make check/help` - Run `make ci` to validate the full pipeline before submitting +## Git Push Protocol +Before every `git push`, show a diff summary so the user can see exactly what is going up: + +```bash +git log origin/BRANCH..HEAD --oneline # commits being pushed +git diff origin/BRANCH --stat # files changed +``` + +Follow with a one-sentence summary (e.g. "Pushing 2 commits touching src/lib.rs and CHANGELOG.md"). Then push. + --- ## Temp Files @@ -25,7 +35,7 @@ Write any scratch files, research dumps, or intermediate agent outputs to `local --- -## Store Types (current names as of v1.1) +## Store Types (current names as of v2.0) | Type | Module | Description | |---|---|---| @@ -36,6 +46,12 @@ 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) | +| `ShardedLruCache` | `cached::stores` | Fully concurrent, sharded LRU; default for `#[concurrent_cached(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(size = N, ttl = T)]`; 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, size = N)]` | **`ExpiringCache` / `ExpiringLruCache` notes:** - Neither store requires the `time_stores` feature — they are always available. @@ -95,7 +111,7 @@ The macro attributes use `ttl =` (not `time =`) and `refresh =` (not `time_refre - `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 `result = true` and a `CloneCached` store +- `result_fallback`: `bool` — on `Err`, return the last cached `Ok` value instead; requires a `Result` return type **`_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. @@ -168,6 +184,17 @@ make check/readme # verify in sync --- +## Fixes Require Tests + +Any code fix — whether from a PR review finding, a reported bug, or an internal audit — **must be accompanied by a test** that: +- **Fails without the fix** (demonstrates the bug was real) +- **Passes with the fix** (confirms the fix is correct) +- **Prevents future regression** (will catch the same bug if re-introduced) + +Use `tests/cached.rs` for integration/behavioral tests and `tests/ui/` for compile-fail tests. The test must be committed in the same change as the fix, not as a follow-up. + +--- + ## Mandatory Verification After Every Change After making **any** code change, run these steps in order: 1. **Format** — `cargo fmt` @@ -179,6 +206,18 @@ Do **not** present a change as complete until all verification steps pass. --- +## Agent Skills + +Invoke these via `/skill-name` in Claude Code or by name in agent prompts: + +| Skill | Path | When to use | +|---|---|---| +| `pr-cycle` | `.agents/skills/pr-cycle/SKILL.md` | Review → fix → push → re-request loop on an open PR | +| `release` | `.agents/skills/release/SKILL.md` | Bump versions, update CHANGELOG, create migration guide, regenerate README | +| `consumer-experience-review` | `.agents/skills/consumer-experience-review/SKILL.md` | Evaluate public API surface from a downstream crate-author perspective | + +--- + ## Key Cargo Features | Feature | Description | @@ -215,7 +254,6 @@ Do **not** present a change as complete until all verification steps pass. | `tests/cached.rs` | Integration tests | | `tests/ui/` | Compile-fail trybuild tests + `.stderr` golden files | | `examples/` | Runnable usage examples | -| `docs/MIGRATION-1.0.md` | Human-readable 0.x → 1.0 migration guide | -| `docs/MIGRATION-1.0-AGENT.md` | Machine-oriented 0.x → 1.0 migration rules for automated tooling | +| `docs/migrations/` | Per-release migration guides; `PREV-to-X.Y.Z.md` (agent) and `PREV-to-X.Y.Z-human.md` (human) | | `local/` | Gitignored scratch space — use for any temp/intermediate files | | `Makefile` | All build/test/lint/example targets | diff --git a/CHANGELOG.md b/CHANGELOG.md index a9386674..c2cfd1b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,87 @@ ## [Unreleased] +## [2.0.0 / cached_proc_macro 2.0.0] +> **Upgrading from 1.1?** See the [2.0 migration guide](docs/migrations/1.1-to-2.0-human.md). + +### Breaking Changes + +#### Trait API changes +- `Cached::cache_remove_entry(&mut self, k: &Q) -> Option<(K, V)>`: new required method on the `Cached` trait that removes an entry and returns the stored key and value. Unlike `cache_remove`, this returns `Some` even when the deleted entry was already expired, making it possible to distinguish "key absent" from "key present but expired". Always fires the store's `on_evict` callback (if set). +- `ConcurrentCached::cache_remove_entry(&self, k: &K) -> Result, Self::Error>`: same semantics on the concurrent trait; implemented for all nine concurrent stores (six sharded plus `DiskCache` / `RedisCache` / `AsyncRedisCache`). The seven non-sharded stores (`UnboundCache`, `LruCache`, etc.) gain `cache_remove_entry` via the `Cached` trait above. +- `Cached::cache_delete(&mut self, k: &Q) -> bool`: new default method on `Cached` that deletes an entry without returning it; returns `true` if an entry was physically removed (including expired entries), `false` if the key was absent. Implemented via `cache_remove_entry`. +- `DiskCache` and `RedisCache` / `AsyncRedisCache` now require `K: Clone` (in addition to existing bounds) for their `ConcurrentCached` / `ConcurrentCachedAsync` impls, which is needed to return the stored key from `cache_remove_entry`. + +#### Macro attribute changes (`#[cached]`, `#[once]`, `#[concurrent_cached]`) +- **`result = true` removed from `#[cached]` and `#[once]`**: All `Result` return types now automatically skip caching `Err` values. Remove `result = true` from all `#[cached]` and `#[once]` annotations — the behavior is now the default. To force-cache `Err` values, use the new `cache_err = true` opt-in. +- **`option = true` removed from `#[cached]` and `#[once]`**: All `Option` return types now automatically skip caching `None` values. Remove `option = true` from all `#[cached]` and `#[once]` annotations — the behavior is now the default. To force-cache `None` values, use the new `cache_none = true` opt-in. +- **`#[concurrent_cached]` now supports `Option` returns**: previously only `Result` was accepted; `Option` and plain `T: Clone` returns are now natively supported on the default in-memory sharded path. Note: `option = true` was never a recognized attribute on `#[concurrent_cached]` (it was silently ignored in 1.x); the new `cache_none = true` is the explicit opt-in to cache `None` values. +- **`#[cached]` / `#[once]` on `fn() -> Option` without attributes**: previously cached `None` as-is; now skips caching `None`. Add `cache_none = true` to preserve the old behavior. +- **`#[cached]` / `#[once]` on `fn() -> Result` without attributes**: previously cached the full `Result`; now skips caching `Err`. Add `cache_err = true` to preserve the old behavior. +- **`result_fallback = true` no longer requires `result = true`**: the explicit `result = true` companion is dropped; `result_fallback` now auto-detects `Result` return types. +- **Custom-`ty` users storing `Option` or `Result` directly**: if your cache store type holds `Option` or `Result` as the value, you must now add `cache_none = true` or `cache_err = true` respectively so the macro uses the full wrapper type rather than extracting the inner `T`. +- **`map_error` on the default in-memory sharded path is now a compile error**: previously `map_error = "…"` was silently accepted and ignored when the store was the infallible default. If you had `map_error` on a `#[concurrent_cached]` that uses no `redis`/`disk`/`ty`/`create`, remove it. If you still need `map_error` (because you are switching to a `redis` or `disk` backend), add the corresponding backend attribute. +- **`result_fallback = true` and `with_cached_flag = true` are mutually exclusive** on `#[concurrent_cached]`: using both together is now a compile error. The combination was never valid — `result_fallback` stores the inner `Ok(T)` value while `with_cached_flag` wraps it in `Return` — but the error was previously inscrutable. Remove one of the two attributes. +- **`cache_none = true` and `with_cached_flag = true` are mutually exclusive** on `#[cached]`, `#[once]`, and `#[concurrent_cached]`: using both together is now a compile error. The combination was never valid — `cache_none = true` stores `Option` as the cached value type while `with_cached_flag = true` stores the inner `T` — but the error was previously a confusing downstream type mismatch. Remove one of the two attributes. + +#### Store behavior changes +- **`cache_remove` on expiring stores** now returns `None` for expired-but-present entries. Previously `ExpiringCache`, `ExpiringLruCache`, and expiry-aware sharded stores returned `Some(value)` for an already-expired entry; now returns `None`. The entry is still removed and `on_evict` still fires. +- **`ConcurrentCached::cache_delete`** (and its `ConcurrentCachedAsync` equivalent) now returns `true` for expired-but-physically-present entries. In 1.x the method returned `false` for such entries. Use `cache_remove` if you need to distinguish a live removal from an expired one. +- **`LruCache::retain`** now fires `on_evict` and increments `cache_evictions()` for each removed entry, matching the semantics of `cache_remove`. Previously `retain` was side-effect-free. Internal TTL and expiring wrapper stores (`LruTtlCache`, `ExpiringLruCache`) use a new crate-internal `retain_silent` for their eviction sweeps, so those stores continue to count evictions exactly once. +- **`DiskCacheBuildError` gains a new `InvalidTtl(BuildError)` variant**: any exhaustive `match` on `DiskCacheBuildError` must add an arm for `InvalidTtl`. This variant is returned when a `DiskCacheBuilder` is given a zero-duration TTL. +- **`RedisCacheBuildError` gains a new `InvalidTtl(BuildError)` variant**: same as above for `RedisCacheBuildError`. Returned when a `RedisCacheBuilder` is given a zero-duration TTL. + +#### Builder & constructor renames (`size` → `max_size`) +- The size-bound builder setter and all size-bound constructors are renamed to make clear they set the **maximum** number of entries, not a current size. This affects every LRU-family store (`LruCache`, `LruTtlCache`, `ExpiringLruCache`, `TtlSortedCache`, and the sharded `ShardedLruCache` / `ShardedLruTtlCache` / `ShardedExpiringLruCache`): + - Builder setter `.size(n)` → `.max_size(n)`. + - `with_size(n)` → `with_max_size(n)`, `try_with_size(n)` → `try_with_max_size(n)`. + - `with_size_and_ttl(...)` → `with_max_size_and_ttl(...)`, `try_with_size_and_ttl(...)` → `try_with_max_size_and_ttl(...)`, `with_size_and_ttl_and_refresh(...)` → `with_max_size_and_ttl_and_refresh(...)`. + - Sharded: `with_size_and_shards(...)` → `with_max_size_and_shards(...)`, `with_size_ttl_and_shards(...)` → `with_max_size_and_ttl_and_shards(...)`. + - The `#[cached]` / `#[concurrent_cached]` **macro attribute is renamed `size = N` → `max_size = N`** to match. The old `size = N` spelling keeps working as a **deprecated alias** but now emits a deprecation warning pointing you to `max_size` (anchored at the `size` token). The sharded builder's per-shard cap setter is named `per_shard_max_size` to match. See "New macro attributes" under Added below. + +### Added + +#### New macro attributes +- `max_size = N` attribute for `#[cached]` and `#[concurrent_cached]`: the preferred spelling of the LRU-bound attribute, mirroring the renamed `max_size` builder/constructor methods. 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`. + +#### 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. +- `#[concurrent_cached]` now defaults to an in-memory sharded store when `redis = true` and `disk = true` are both absent and no custom `ty`/`create` is provided. Macro attributes `max_size = N`, `ttl = T`, `shards = S`, and `expires = true` select the matching variant. `map_error` must not be specified on this path — the stores are `Infallible` and have no errors to map (supply `redis = true`, `disk = true`, or a custom `ty`/`create` to use a fallible store). +- `#[concurrent_cached]` on the default in-memory sharded stores now accepts plain return types — any `T: Clone`, `Option`, or `Result`. `redis`, `disk`, and custom `ty`/`create` stores still require `Result`. +- Add `expires = true` attribute support to `#[concurrent_cached]` macro to automatically select `ShardedExpiringCache` (unbounded) or `ShardedExpiringLruCache` (LRU-bounded when `max_size` is also set). +- `ShardedExpiringCache` and `ShardedExpiringLruCache` require cached values to implement the `Expires` trait; `copy_from` skips entries already reporting `is_expired() == true`. Both expose `deep_clone` for snapshot copies. + +#### Other additions +- Add `cache_clear_with_on_evict()` to all six sharded stores (`ShardedCache`, `ShardedLruCache`, `ShardedTtlCache`, `ShardedLruTtlCache`, `ShardedExpiringCache`, `ShardedExpiringLruCache`): fires the `on_evict` callback for every removed entry when a callback is configured, and (where applicable) increments the evictions counter (`ShardedCache` is unbounded and has no evictions counter). The plain `clear()` inherent method remains fast and side-effect-free; `cache_clear_with_on_evict()` is the opt-in alternative. +- 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; disk/Redis builders expose `try_build()` aliases. +- Add `ExpiringLruCache::try_with_max_size(...)`, matching `LruCache::try_with_max_size(...)`. +- 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. + +### Fixed +- Unify zero-TTL validation across all TTL-capable store builders: `TtlCache`, `LruTtlCache`, `TtlSortedCache`, `ShardedTtlCache`, `ShardedLruTtlCache`, `DiskCache`, `RedisCache`, and `AsyncRedisCache` builders now all call the shared `validate_ttl` helper and return `BuildError::InvalidTtl { ttl }`. Direct constructors such as `TtlCache::with_ttl` remain permissive by design. +- Make the generated `#[concurrent_cached]` in-memory `Infallible` error shim map into the function's declared `Result<_, E>` error type, reject invalid store-selection attributes, and use UFCS for generated `ConcurrentCached` calls so sync functions compile even when both concurrent traits are in scope. +- Implement `CacheEvict` for `ShardedTtlCacheBase` and `ShardedLruTtlCacheBase`, make sharded builders return `BuildError` instead of panicking on capacity/shard overflows, avoid unnecessary `'static` bounds when building `ShardedLruTtlCache` without `on_evict`, optimize `ShardedTtlCacheBase` hits under `refresh_on_hit` by bypassing read-locks, and correct the sharded LRU capacity documentation. +- Fix timed-store eviction sweeps to use the crate's configured `Instant` type. +- Optimize `TtlSortedCache::cache_get` and `cache_get_mut` live hits to use a single hash-map lookup. +- Unify `cache_remove` semantics: removing any present entry now fires the store's `on_evict` callback (if set) and increments `evictions`. +- Tighten `#[concurrent_cached]` return-type classification so generic plain return types like `HashMap` are not mistaken for `Result` aliases. +- Tighten `Result`-return detection in all three macros to require the exact identifier `Result` rather than matching any identifier that ends with `"Result"`. Type aliases such as `type MyResult = Result` are now treated as plain values (their `Err` variant is cached). Only the literal `Result` and its fully-qualified forms (e.g. `std::result::Result`) continue to trigger skip-on-`Err` / `result_fallback` semantics. This aligns with the existing `Option`-detection behavior and makes the macro surface consistent. +- Pass the stored key (via `remove_entry`) rather than the lookup key to `on_evict` in `ShardedTtlCache::cache_remove` and `ShardedExpiringCache::cache_get` / `cache_remove`. +- `#[concurrent_cached]` now rejects `map_error` on the default in-memory sharded path with a compile error — the stores are `Infallible` and accepting `map_error` while silently ignoring it was misleading. Previously `map_error` on this path was accepted and the infallible path emitted `.expect(…)` regardless. +- Remove redundant `.clone()` on the `#[concurrent_cached]` cache-hit return path for all three return-type variants. +- Fix `#[concurrent_cached(with_cached_flag = true)]` on the default in-memory path for plain `cached::Return` returns. +- Extend `build()` panic messages on all sharded stores to include the underlying `BuildError` detail. +- Fix `ShardedLruTtlCacheBase::evict()` to remove expired inner entries without calling `cache_remove`, preventing double-counting of evictions and double-firing of `on_evict`. +- Fix `Cached::cache_delete` (now on `Cached` via `cache_remove_entry`) correctly returns `true` for entries that were present but already expired; previously `cache_delete` on `ConcurrentCached` returned `false` for expired entries. + ## [1.1.0 / cached_proc_macro 1.1.0] ### Added @@ -15,14 +96,13 @@ - Add unit tests validating the `std::fmt::Display` representation for all `BuildError` variants in `src/stores/mod.rs`. - Add standardized micro-benchmarks (`benches/cache_benches.rs`) for cache hits across all 7 core in-memory stores (`UnboundCache`, `LruCache`, `TtlCache`, `LruTtlCache`, `ExpiringLruCache`, `ExpiringCache`, `TtlSortedCache`), cache misses & inserts, eviction capacity overhead, and `RwLock` lock-synchronization (with and without `CachedRead::cache_get_read` unsynchronized reads). - Add new `bench` target to the `Makefile` to run the benchmark suite. -- Add GitHub Actions workflow (`.github/workflows/codspeed.yml`) for automated, low-noise continuous benchmarking on every push and pull request. - Add standard, runnable example `examples/expires_per_key.rs` demonstrating how to use the `Expires` trait with `ExpiringLruCache` and `ExpiringCache` for per-value expiration, including keyed caching via `#[cached(expires = true)]` and single-value caching via `#[once(expires = true)]`. - Add detailed library-level documentation and quickstart example for `Expires`, `ExpiringCache`, and `ExpiringLruCache` to `src/lib.rs` (automatically synced to `README.md`). ## [1.0.0 / cached_proc_macro 1.0.0 / cached_proc_macro_types 1.0.0] -> **Upgrading from 0.x?** See the [1.0 migration guide](docs/MIGRATION-1.0.md) +> **Upgrading from 0.x?** See the [1.0 migration guide](docs/migrations/0.x-to-1.0-human.md) > for a complete walkthrough of every breaking change (and an -> [agent-oriented version](docs/MIGRATION-1.0-AGENT.md) for automated tooling). +> [agent-oriented version](docs/migrations/0.x-to-1.0.md) for automated tooling). ## Added - Add comprehensive async integration tests in `tests/cached.rs` for `CachedAsync` methods on `TtlCache`, `LruTtlCache`, `TtlSortedCache`, `ExpiringLruCache`, and `UnboundCache` to assert correct `on_evict` invocation on expired lookups. - Add `make help` and `make check/help` targets for documenting and validating diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 85ec5c93..d34d4ef3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,3 +55,15 @@ make clean Pull requests should be made against `master`. GitHub Actions will run the full test suite on all PRs. Remember to update the changelog! + +## Releasing + +If you want your change released immediately, bump the crate versions as part of +your PR. The `release` agent skill (`/release X.Y.Z` in Claude Code) handles the +full release prep: version bumps across all `Cargo.toml` files, CHANGELOG promotion, +migration guide creation, and README regeneration. + +For self-review before submitting, the `pr-cycle` agent skill (`/pr-cycle` in +Claude Code) runs a full review → fix → push → re-request loop: it fetches open PR +comments, spawns independent code-review and library-consumer sub-agents, applies +valid fixes, runs CI, and re-requests Copilot review — all in one pass. diff --git a/Cargo.toml b/Cargo.toml index e3e036c6..8fe30304 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cached" -version = "1.1.0" +version = "2.0.0" authors = ["James Kominick "] description = "Generic cache implementations and simplified function memoization" repository = "https://github.com/jaemk/cached" @@ -42,7 +42,7 @@ wasm = [] time_stores = [] [dependencies.cached_proc_macro] -version = "1.1.0" +version = "2.0.0" path = "cached_proc_macro" optional = true @@ -111,7 +111,7 @@ copy_dir = "0.1.3" googletest = "0.11.0" tempfile = "3.10.1" trybuild = "1" -criterion = { version = "4.6.0", package = "codspeed-criterion-compat" } +criterion = "0.5" [dev-dependencies.async-std] version = "1.6" @@ -174,6 +174,14 @@ required-features = ["disk_store", "async_tokio_rt_multi_thread", "proc_macro"] name = "redis-client-side-cache-tokio" required-features = ["redis_async_cache", "async_tokio_rt_multi_thread", "proc_macro"] +[[example]] +name = "sharded" +required-features = ["time_stores", "proc_macro"] + +[[example]] +name = "sharded_expiring" +required-features = ["proc_macro"] + [[bench]] name = "cache_benches" harness = false diff --git a/Makefile b/Makefile index 776a788f..ec5b477e 100644 --- a/Makefile +++ b/Makefile @@ -14,6 +14,8 @@ CACHED_BASIC_EXAMPLES = async_std \ basic \ kitchen_sink \ tokio \ + sharded \ + sharded_expiring \ expiring_sized_cache \ disk \ disk_async diff --git a/README.md b/README.md index 888eb22c..c51e7d92 100644 --- a/README.md +++ b/README.md @@ -3,15 +3,16 @@ [![Build Status](https://github.com/jaemk/cached/actions/workflows/build.yml/badge.svg)](https://github.com/jaemk/cached/actions/workflows/build.yml) [![crates.io](https://img.shields.io/crates/v/cached.svg)](https://crates.io/crates/cached) [![docs](https://docs.rs/cached/badge.svg)](https://docs.rs/cached) -[![CodSpeed Badge](https://img.shields.io/endpoint?url=https://codspeed.io/badge.json)](https://codspeed.io/jaemk/cached?utm_source=badge) > Caching structures and simplified function memoization `cached` provides implementations of several caching structures as well as macros for defining memoized functions. -Memoized functions defined using `#[cached]`/`#[once]`/`#[concurrent_cached]` macros are thread-safe with the backing -function-cache wrapped in a mutex/rwlock, or externally synchronized in the case of `#[concurrent_cached]`. +Memoized functions defined using `#[cached]`/`#[once]` macros are thread-safe with the backing +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 @@ -22,12 +23,18 @@ of un-cached arguments, specify `#[cached(sync_writes = true)]` / `#[once(sync_w - 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 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 +> [2.0 migration guide](https://github.com/jaemk/cached/blob/master/docs/migrations/1.1-to-2.0-human.md) +> for a step-by-step walkthrough. +> > **Upgrading from a pre-1.0 release?** 1.0 contains breaking changes (store > renames, removed declarative macros, renamed macro/builder attributes, and a > changed Redis key format). See the -> [1.0 migration guide](https://github.com/jaemk/cached/blob/master/docs/MIGRATION-1.0.md) +> [1.0 migration guide](https://github.com/jaemk/cached/blob/master/docs/migrations/0.x-to-1.0-human.md) > for a step-by-step walkthrough, or the -> [agent-oriented guide](https://github.com/jaemk/cached/blob/master/docs/MIGRATION-1.0-AGENT.md) +> [agent-oriented guide](https://github.com/jaemk/cached/blob/master/docs/migrations/0.x-to-1.0.md) > for automated migration tooling. **Features** @@ -50,7 +57,8 @@ of un-cached arguments, specify `#[cached(sync_writes = true)]` / `#[once(sync_w - `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`) -- `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), and [`TtlSortedCache`](https://docs.rs/cached/latest/cached/struct.TtlSortedCache.html)). +- `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. 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. @@ -62,38 +70,128 @@ help output stays in sync with supported Makefile targets. Any custom cache that implements `cached::Cached`/`cached::CachedAsync` can be used with the `#[cached]`/`#[once]` macros in place of the built-ins. Any custom cache that implements `cached::ConcurrentCached`/`cached::ConcurrentCachedAsync` can be used with the `#[concurrent_cached]` macro. -**Store comparison** +**Macro quick reference** + +| Use case | Annotated signature | +|---|---| +| **`#[cached]`** | | +| Unbounded memoize (default) | `#[cached] 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` | +| 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` | +| Per-value expiry (value carries its own TTL) | `#[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` | +| 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` | +| Optional global — skip caching if `None` (implicit) | `#[once] fn feature_flag() -> Option` | +| **`#[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` | +| 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` | + +On `#[cached]` and `#[concurrent_cached]`, `max_size = N` is accepted as an alias for `size = N` (mirroring the `max_size` builder/constructor methods on the stores). Either spelling works; setting both on one annotation is a compile error. + +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`. -| Store | Eviction policy | Size limit | TTL | Refresh on hit | `on_evict` | Async | -|---|---|---|---|---|---|---| -| [`UnboundCache`](https://docs.rs/cached/latest/cached/struct.UnboundCache.html) | None (unbounded) | No | No | N/A | On explicit remove | Yes | -| [`LruCache`](https://docs.rs/cached/latest/cached/struct.LruCache.html) | LRU | Yes | No | N/A | Yes | Yes | -| [`TtlCache`](https://docs.rs/cached/latest/cached/struct.TtlCache.html) | TTL (insert time) | No | Global | Optional | Yes | Yes | -| [`LruTtlCache`](https://docs.rs/cached/latest/cached/struct.LruTtlCache.html) | LRU + TTL | Yes | Global | Optional | Yes | Yes | -| [`TtlSortedCache`](https://docs.rs/cached/latest/cached/struct.TtlSortedCache.html) | TTL (expiry-ordered) | Optional | Global | No | Yes | Yes | -| [`ExpiringLruCache`](https://docs.rs/cached/latest/cached/struct.ExpiringLruCache.html) | LRU + value-defined | Yes | Per-value | N/A | Yes | Yes | -| [`ExpiringCache`](https://docs.rs/cached/latest/cached/struct.ExpiringCache.html) | Value-defined | No | Per-value | N/A | Yes | Yes | +**Store comparison** -`TtlCache`/`LruTtlCache`/`TtlSortedCache` require the `time_stores` feature. +| Store | Eviction policy | Size limit | TTL | Refresh on hit | `on_evict` | Concurrent | Async | +|---|---|---|---|---|---|---|---| +| [`UnboundCache`](https://docs.rs/cached/latest/cached/struct.UnboundCache.html) | None (unbounded) | No | No | N/A | On explicit remove | No | Yes | +| [`LruCache`](https://docs.rs/cached/latest/cached/struct.LruCache.html) | LRU | Yes | No | N/A | Yes | No | Yes | +| [`TtlCache`](https://docs.rs/cached/latest/cached/struct.TtlCache.html) | TTL (insert time) | No | Global | Optional | Yes | No | Yes | +| [`LruTtlCache`](https://docs.rs/cached/latest/cached/struct.LruTtlCache.html) | LRU + TTL | Yes | Global | Optional | Yes | No | Yes | +| [`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 | +| [`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 | +| [`ShardedExpiringCache`](https://docs.rs/cached/latest/cached/type.ShardedExpiringCache.html) | Value-defined | No | Per-value | N/A | Yes | Yes (`Arc`) | Yes | +| [`ShardedExpiringLruCache`](https://docs.rs/cached/latest/cached/type.ShardedExpiringLruCache.html) | LRU + value-defined | Yes | Per-value | N/A | Yes | Yes (`Arc`) | Yes | + +> "On explicit remove" — `on_evict` fires only on `cache_remove`; there is no capacity eviction or TTL expiry trigger for these stores. +> † `ShardedLruTtlCacheBuilder::on_evict` requires `K: 'static + V: 'static`; see the builder docs for details. + +`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]`. +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. + +> **`*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. **Behavioral guarantees** -- In-memory cache stores are not internally synchronized. Macro-defined functions wrap their - backing stores in generated locks; users managing stores directly should add synchronization - at the call site when sharing across threads. +- Non-sharded in-memory stores (`UnboundCache`, `LruCache`, `TtlCache`, etc.) are not internally + synchronized. Macro-generated `#[cached]`/`#[once]` functions wrap them in locks; users + 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. + Direct sharded-store method syntax is synchronous because these stores expose inherent + `cache_get` / `cache_set` / `cache_remove` helpers. Use Universal Function Call Syntax (UFCS) + for async trait calls (e.g., `cached::ConcurrentCachedAsync::cache_get(&*STORE, &key).await.expect("ShardedCache is infallible")`), where `&*STORE` dereferences a `LazyLock` or `OnceCell` static to obtain a `&Store` reference. - `Cached::get` (and its legacy alias `cache_get`) requires mutable access because some stores update recency, expiration timestamps, or metrics during reads. - 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. +- `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 + 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 + expiry, removing a present-but-already-expired entry still evicts and fires `on_evict`, + but `cache_remove` returns `None`; use `cache_delete` or `cache_remove_entry` when you + need to know whether an entry was physically removed. +- `cache_clear()` is fast and side-effect-free: it does **not** fire `on_evict` and does + not increment the evictions counter. Use `cache_clear_with_on_evict()` when you need the + callback to fire for every removed entry (e.g., to release resources tracked via `on_evict`). + Note: neither `clear()` nor `cache_clear_with_on_evict()` is part of `ConcurrentCached` + or its async counterpart — `clear()` is exposed as an inherent method on each concrete + sharded store type, and `cache_clear_with_on_evict()` is inherent-only as well; generic code + parameterized over `ConcurrentCached` cannot call either. - Bounded caches enforce capacity on insertion. Time-bounded caches enforce freshness on lookup. -- Redis and disk stores serialize values and return owned values; in-memory stores return - references from direct store APIs and macro-generated functions clone cached return values. -- Macro-generated cache statics use `RwLock` by default. Named cache - statics should be inspected with `.read()` or `.write()` unless `sync_lock = "mutex"` is set. +- Redis and disk stores serialize values and return owned values. Non-sharded in-memory stores + return references from direct store APIs; sharded stores return owned `Option` values + (cloned under a shard lock). Macro-generated functions clone cached return values in all cases. +- Macro-generated `#[cached]` / `#[once]` cache statics use `RwLock` by default. Named cache + statics for those macros should be inspected with `.read()` or `.write()` unless + `sync_lock = "mutex"` is set. Named `#[concurrent_cached]` statics hold a self-synchronizing + store directly: sync functions use `LazyLock`, and async functions use + `OnceCell`. - `CachedPeek` provides non-mutating lookups that do not update recency, refresh TTLs, or record metrics. `CachedRead` is narrower and is only implemented where shared-lock lookups can preserve normal read-side semantics without recency or refresh mutation. +- Sharded stores implement `ConcurrentCached`/`ConcurrentCachedAsync` instead of + `Cached`/`CachedAsync`. Generic code parameterized over `Cached` cannot accept sharded + stores; use a `ConcurrentCached` bound or a concrete type instead. + Sharded stores also do not implement `CachedIter` or `CachedPeek`. Code that is generic over + `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. **Per-Value Expiry via the `Expires` Trait** @@ -101,12 +199,17 @@ While standard timed stores (`TtlCache`, `LruTtlCache`, `TtlSortedCache`) enforc This approach is highly useful when caching payloads like OAuth tokens, HTTP responses with varying `Cache-Control` headers, or database records that contain their own absolute expiration timestamps. -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 `size` is also specified. For `#[once]`, this stores a single value whose expiry is polled on each call. +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. -> **Memory note:** `ExpiringCache` is unbounded and only removes expired entries when the same -> key is accessed again. `CachedIter::iter()` filters expired entries from the iterator but does -> not remove them from the map. For high-cardinality workloads, call `evict()` periodically or -> prefer `ExpiringLruCache` with a `size` bound. +For concurrent (multi-thread, no external lock) use, the sharded equivalents [`ShardedExpiringCache`] and [`ShardedExpiringLruCache`] provide the same per-value expiry with internally-synchronized sharded storage. Use `#[concurrent_cached(expires = true)]` to select them automatically. + +> **Memory note:** `ExpiringCache` and `ShardedExpiringCache` are unbounded and only remove +> expired entries when the same key is accessed again. `CachedIter::iter()` (implemented on the +> non-sharded `ExpiringCache` / `ExpiringLruCache` only, not on the sharded variants) filters +> expired entries from the iterator but does not remove them from the map. For high-cardinality workloads, +> call `evict()` periodically (bring [`CacheEvict`] into scope: `use cached::CacheEvict;`; note +> that `evict()` on sharded TTL and expiring stores requires `K: Clone`) or +> prefer `ExpiringLruCache` / `ShardedExpiringLruCache` with a `max_size` bound. ```rust use cached::{Cached, Expires, ExpiringCache, ExpiringLruCache}; @@ -137,8 +240,8 @@ cache.cache_set("key2", Response { expires_at: now + Duration::from_secs(3600), }); -// ExpiringLruCache — LRU-bounded, used with `#[cached(expires = true, size = N)]` -let mut lru = ExpiringLruCache::with_size(10); +// ExpiringLruCache — LRU-bounded, used with `#[cached(expires = true, max_size = N)]` +let mut lru = ExpiringLruCache::with_max_size(10); lru.cache_set("key1", Response { payload: "a".to_string(), expires_at: now + Duration::from_secs(1), @@ -174,7 +277,7 @@ use cached::LruCache; /// Use an explicit cache-type with a custom creation block and custom cache-key generating block #[cached( ty = "LruCache", - create = "{ LruCache::with_size(100) }", + create = "{ LruCache::with_max_size(100) }", convert = r#"{ format!("{}{}", a, b) }"# )] fn keyed(a: &str, b: &str) -> usize { @@ -197,7 +300,7 @@ use cached::macros::once; /// will synchronize (`sync_writes`) so the function /// is only executed once. # #[cfg(feature = "time_stores")] -#[once(ttl =10, option = true, sync_writes = true)] +#[once(ttl =10, sync_writes = true)] fn keyed(a: String) -> Option { if a == "a" { Some(a.len()) @@ -215,7 +318,6 @@ use cached::macros::cached; /// Cannot use sync_writes and result_fallback together #[cached( - result = true, ttl = 1, sync_writes = "default", result_fallback = true @@ -240,16 +342,15 @@ enum ExampleError { /// Cache the results of an async function in redis. Cache /// keys will be prefixed with `cache_redis_prefix`. -/// A `map_error` closure must be specified to convert any -/// redis cache errors into the same type of error returned -/// by your function. All `concurrent_cached` functions must return `Result`s. +/// 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::new("cached_redis_prefix", Duration::from_secs(1)) + AsyncRedisCache::builder("cached_redis_prefix", Duration::from_secs(1)) .refresh(true) - .build() + .try_build() .await .expect("error building example redis cache") } "## @@ -276,9 +377,8 @@ enum ExampleError { /// Cache the results of a function on disk. /// Cache files will be stored under the system cache dir /// unless otherwise specified with `disk_dir` or the `create` argument. -/// A `map_error` closure must be specified to convert any -/// disk cache errors into the same type of error returned -/// by your function. All `concurrent_cached` functions must return `Result`s. +/// Disk stores require `Result`; supply a `map_error` closure +/// to convert store errors into your error type. #[concurrent_cached( map_error = r##"|e| ExampleError::DiskError(format!("{:?}", e))"##, disk = true @@ -289,6 +389,41 @@ fn cached_sleep_secs(secs: u64) -> Result { } ``` +---- + +```rust,no_run,ignore +use cached::macros::concurrent_cached; + +/// Memoize with the default in-memory sharded store — no `map_error`, `ty`, +/// or `create` needed. Add `max_size` for LRU eviction or `ttl` for time-based +/// expiry (requires the `time_stores` feature). +/// +/// `#[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` +/// 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. +#[concurrent_cached] +fn slow_double(x: u64) -> u64 { + std::thread::sleep(cached::time::Duration::from_millis(10)); + x * 2 +} + +/// LRU capacity of 1 000 entries spread across shards. +#[concurrent_cached(max_size = 1000)] +fn slow_triple(x: u64) -> u64 { + x * 3 +} + +/// Only cache successful lookups — `Err` is returned but not stored. +#[concurrent_cached] +fn load_user(id: u64) -> Result { + Ok(format!("user_{id}")) +} +``` + Functions defined via macros will have their results cached using the function's arguments as a key, or a `convert` expression specified on the macro. @@ -300,14 +435,26 @@ Due to the requirements of storing arguments and return values in a global cache - Function return types: - For in-memory stores (`#[cached]` / `#[once]`), must be owned and implement `Clone` - - For I/O-backed stores used by `#[concurrent_cached]` (Redis and disk), must be owned, implement - `Clone` (the generated code clones the successful value), and additionally implement - `serde::Serialize + serde::DeserializeOwned` (the store serializes it) + - For in-memory `#[concurrent_cached]` (sharded stores — the default), must implement `Clone`. + Any return type is accepted: plain `T`, `Option`, or `Result`. `Option` skips + caching `None` by default; use `cache_none = true` to also cache `None`. When the + return type is `Result`, only `Ok(v)` is stored — `Err` values are returned but not cached. + Use `cache_err = true` to also cache `Err` values. + - For I/O-backed stores used by `#[concurrent_cached]` (Redis and disk), must be `Result` + where `T: Clone + serde::Serialize + serde::DeserializeOwned` (the store serializes it). + `map_error` must be supplied to convert the store's error into `E`. - Function arguments: - For in-memory stores (`#[cached]` / `#[once]`), must either be owned and implement `Hash + Eq + Clone`, or a `convert` expression must be specified on the macro to produce a key of a `Hash + Eq + Clone` type. + - For in-memory `#[concurrent_cached]` (sharded stores), must implement `Hash + Eq + Clone`. The + macro's default key construction always clones function arguments, so `K: Clone` is required on + every in-memory path. (When using `convert` to supply an already-owned key, only the store's + own bounds apply: `K: Hash + Eq` for unbounded/TTL-only variants, `K: Hash + Eq + Clone` for LRU + variants — except when `result_fallback = true` is also set, which always requires `K: Clone` + regardless of store variant because the generated code clones the key into the fallback store.) - For I/O-backed stores used by `#[concurrent_cached]` (Redis and disk), must either be owned and - implement `Display`, or a `convert` expression must be used to produce a key of a `Display` type. + 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. - 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! diff --git a/benches/cache_benches.rs b/benches/cache_benches.rs index af21e24e..3e0ecf8e 100644 --- a/benches/cache_benches.rs +++ b/benches/cache_benches.rs @@ -1,11 +1,14 @@ use cached::time::Duration; use cached::{ - Cached, CachedRead, Expires, ExpiringCache, ExpiringLruCache, LruCache, LruTtlCache, TtlCache, - TtlSortedCache, UnboundCache, + Cached, CachedRead, Expires, ExpiringCache, ExpiringLruCache, LruCache, LruTtlCache, + ShardedCache, ShardedLruCache, ShardedLruTtlCache, TtlCache, TtlSortedCache, UnboundCache, }; -use criterion::{black_box, criterion_group, criterion_main, Criterion}; -use parking_lot::RwLock; -use std::sync::Arc; +use criterion::{black_box, criterion_group, criterion_main, Criterion, Throughput}; +use parking_lot::{Mutex, RwLock}; +use std::collections::HashMap; +use std::sync::{Arc, Barrier}; +use std::thread; +use std::time::Instant; #[derive(Clone)] #[allow(dead_code)] @@ -37,7 +40,7 @@ fn bench_cache_hits(c: &mut Criterion) { }); // 2. LruCache - let mut lru = LruCache::with_size(limit); + let mut lru = LruCache::with_max_size(limit); for i in 0..limit { lru.cache_set(i, i * 2); } @@ -62,7 +65,7 @@ fn bench_cache_hits(c: &mut Criterion) { // 4. LruTtlCache let mut lru_ttl_cache = LruTtlCache::builder() - .size(limit) + .max_size(limit) .ttl(Duration::from_secs(3600)) .build(); for i in 0..limit { @@ -76,7 +79,7 @@ fn bench_cache_hits(c: &mut Criterion) { }); // 5. ExpiringLruCache - let mut expiring_lru_cache = ExpiringLruCache::builder().size(limit).build(); + let mut expiring_lru_cache = ExpiringLruCache::builder().max_size(limit).build(); for i in 0..limit { expiring_lru_cache.cache_set(i, ExpiringValue { val: i * 2 }); } @@ -130,7 +133,7 @@ fn bench_cache_misses_and_inserts(c: &mut Criterion) { }); group.bench_function("LruCache insert (no eviction)", |b| { - let mut cache = LruCache::with_size(100_000); + let mut cache = LruCache::with_max_size(100_000); let mut key = 0; b.iter(|| { cache.cache_set(key, key * 2); @@ -149,7 +152,7 @@ fn bench_cache_misses_and_inserts(c: &mut Criterion) { group.bench_function("LruTtlCache insert (no eviction)", |b| { let mut cache = LruTtlCache::builder() - .size(100_000) + .max_size(100_000) .ttl(Duration::from_secs(3600)) .build(); let mut key = 0; @@ -176,7 +179,7 @@ fn bench_eviction_overhead(c: &mut Criterion) { let capacity = 1000; // LRU Cache constantly evicting (inserting into full cache) - let mut lru = LruCache::with_size(capacity); + let mut lru = LruCache::with_max_size(capacity); for i in 0..capacity { lru.cache_set(i, i * 2); } @@ -190,7 +193,7 @@ fn bench_eviction_overhead(c: &mut Criterion) { // LruTtl Cache constantly evicting let mut lru_ttl = LruTtlCache::builder() - .size(capacity) + .max_size(capacity) .ttl(Duration::from_secs(3600)) .build(); for i in 0..capacity { @@ -244,11 +247,314 @@ fn bench_lock_synchronization(c: &mut Criterion) { group.finish(); } +// --------------------------------------------------------------------------- +// Concurrent benchmarks: sharded stores vs. single-lock equivalents +// +// Each group runs N_THREADS threads concurrently (barrier-synchronized) and +// reports combined throughput so the comparison is apples-to-apples. +// +// Throughput is set to N_THREADS elements per iteration: with iter_custom, +// each thread does `iters` ops so total ops = N_THREADS * iters. Returning +// wall-clock elapsed (not summed CPU time) and setting throughput(N_THREADS) +// makes Criterion report the aggregate concurrent ops/sec for the whole pool. +// --------------------------------------------------------------------------- + +const N_THREADS: usize = 4; +const N_KEYS: usize = 1_000; + +/// Scattered read key: distributes thread-local sequential accesses across the +/// key space so that adjacent iterations don't alias in cache lines. +#[inline(always)] +fn read_key(i: usize, thread_id: usize) -> usize { + (i.wrapping_mul(7).wrapping_add(thread_id.wrapping_mul(53))) % N_KEYS +} + +/// Write key: each thread owns a distinct slice of the key space so writes on +/// different threads never contend on the same logical entry. The single-lock +/// baselines still serialize all writes through one lock, but at the same +/// logical write rate; the sharded stores can serve them in parallel. +#[inline(always)] +fn write_key(i: usize, thread_id: usize) -> usize { + let stride = N_KEYS / N_THREADS; + thread_id * stride + (i % stride) +} + +macro_rules! run_concurrent { + ($cache:ident, $iters:expr, $thread_id:ident, $idx:ident, $bench_fn:block) => {{ + let ready_barrier = Arc::new(Barrier::new(N_THREADS + 1)); + let start_barrier = Arc::new(Barrier::new(N_THREADS + 1)); + let handles: Vec<_> = (0..N_THREADS) + .map(|t| { + let ready_barrier = ready_barrier.clone(); + let start_barrier = start_barrier.clone(); + let $cache = $cache.clone(); + thread::spawn(move || { + ready_barrier.wait(); + start_barrier.wait(); + let $thread_id = t; + let iters = $iters as usize; + for $idx in 0..iters { + $bench_fn + } + }) + }) + .collect(); + ready_barrier.wait(); + let start = Instant::now(); + start_barrier.wait(); + for h in handles { + h.join().expect("bench thread panicked"); + } + start.elapsed() + }}; +} + +// ---- Group 1: unbounded cache ------------------------------------------------- + +fn bench_sharded_unbound_concurrent(c: &mut Criterion) { + let mut group = c.benchmark_group("Concurrent Reads: ShardedCache vs single-lock"); + group.throughput(Throughput::Elements(N_THREADS as u64)); + + // Baseline A: Mutex — every read takes an exclusive lock. + let mutex_map: Arc>> = + Arc::new(Mutex::new((0..N_KEYS).map(|i| (i, i * 2)).collect())); + group.bench_function("Mutex", |b| { + b.iter_custom(|iters| { + let map = mutex_map.clone(); + run_concurrent!(map, iters, t, i, { + black_box(map.lock().get(&read_key(i, t)).copied()); + }) + }) + }); + + // Baseline B: RwLock — readers share the lock, writers exclude. + let rw_map: Arc>> = + Arc::new(RwLock::new((0..N_KEYS).map(|i| (i, i * 2)).collect())); + group.bench_function("RwLock", |b| { + b.iter_custom(|iters| { + let map = rw_map.clone(); + run_concurrent!(map, iters, t, i, { + black_box(map.read().get(&read_key(i, t)).copied()); + }) + }) + }); + + // 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 + // by keeping both the lock and the counters per-shard. + let rw_unbound = Arc::new(RwLock::new({ + let mut c = UnboundCache::new(); + for i in 0..N_KEYS { + c.cache_set(i, i * 2usize); + } + c + })); + group.bench_function("RwLock (CachedRead)", |b| { + b.iter_custom(|iters| { + let cache = rw_unbound.clone(); + run_concurrent!(cache, iters, t, i, { + black_box(cache.read().cache_get_read(&read_key(i, t))); + }) + }) + }); + + // ShardedCache: per-shard RwLocks eliminate inter-thread read contention. + let sharded = ShardedCache::::new(); + for i in 0..N_KEYS { + sharded.cache_set(i, i * 2).expect("infallible"); + } + group.bench_function("ShardedCache", |b| { + b.iter_custom(|iters| { + let cache = sharded.clone(); // Arc clone + run_concurrent!(cache, iters, t, i, { + black_box(cache.cache_get(&read_key(i, t)).expect("infallible")); + }) + }) + }); + + group.finish(); + + // ---- Write benchmark (distinct keys, measures lock contention on inserts) ---- + let mut group = c.benchmark_group("Concurrent Writes: ShardedCache vs single-lock"); + group.throughput(Throughput::Elements(N_THREADS as u64)); + + let mutex_map_w: Arc>> = Arc::new(Mutex::new(HashMap::new())); + group.bench_function("Mutex", |b| { + b.iter_custom(|iters| { + let map = mutex_map_w.clone(); + run_concurrent!(map, iters, t, i, { + map.lock().insert(write_key(i, t), i * 2); + }) + }) + }); + + let sharded_w = ShardedCache::::new(); + group.bench_function("ShardedCache", |b| { + b.iter_custom(|iters| { + let cache = sharded_w.clone(); + run_concurrent!(cache, iters, t, i, { + cache.cache_set(write_key(i, t), i * 2).expect("infallible"); + }) + }) + }); + + group.finish(); +} + +// ---- Group 2: LRU cache ------------------------------------------------------- +// +// LruCache::cache_get updates recency so it needs &mut self — every read must +// take an exclusive lock. ShardedLruCache distributes that across shards. + +fn bench_sharded_lru_concurrent(c: &mut Criterion) { + let cap = 4 * N_KEYS; // large enough that eviction doesn't happen during reads + + let mut group = c.benchmark_group("Concurrent Reads: ShardedLruCache vs Mutex"); + group.throughput(Throughput::Elements(N_THREADS as u64)); + + let mutex_lru: Arc>> = + Arc::new(Mutex::new(LruCache::with_max_size(cap))); + { + let mut g = mutex_lru.lock(); + for i in 0..N_KEYS { + g.cache_set(i, i * 2); + } + } + group.bench_function("Mutex", |b| { + b.iter_custom(|iters| { + let cache = mutex_lru.clone(); + run_concurrent!(cache, iters, t, i, { + black_box(cache.lock().cache_get(&read_key(i, t))); + }) + }) + }); + + let sharded_lru = ShardedLruCache::::with_max_size(cap); + for i in 0..N_KEYS { + sharded_lru.cache_set(i, i * 2).expect("infallible"); + } + group.bench_function("ShardedLruCache", |b| { + b.iter_custom(|iters| { + let cache = sharded_lru.clone(); + run_concurrent!(cache, iters, t, i, { + black_box(cache.cache_get(&read_key(i, t)).expect("infallible")); + }) + }) + }); + + group.finish(); + + // ---- Write benchmark ------------------------------------------------------ + let mut group = c.benchmark_group("Concurrent Writes: ShardedLruCache vs Mutex"); + group.throughput(Throughput::Elements(N_THREADS as u64)); + + let mutex_lru_w: Arc>> = + Arc::new(Mutex::new(LruCache::with_max_size(cap))); + group.bench_function("Mutex", |b| { + b.iter_custom(|iters| { + let cache = mutex_lru_w.clone(); + run_concurrent!(cache, iters, t, i, { + cache.lock().cache_set(write_key(i, t), i * 2); + }) + }) + }); + + let sharded_lru_w = ShardedLruCache::::with_max_size(cap); + group.bench_function("ShardedLruCache", |b| { + b.iter_custom(|iters| { + let cache = sharded_lru_w.clone(); + run_concurrent!(cache, iters, t, i, { + cache.cache_set(write_key(i, t), i * 2).expect("infallible"); + }) + }) + }); + + group.finish(); +} + +// ---- Group 3: LRU + TTL ------------------------------------------------------- + +fn bench_sharded_lru_ttl_concurrent(c: &mut Criterion) { + let cap = 4 * N_KEYS; + let long_ttl = Duration::from_secs(3600); + + let mut group = c.benchmark_group("Concurrent Reads: ShardedLruTtlCache vs Mutex"); + group.throughput(Throughput::Elements(N_THREADS as u64)); + + let mutex_lru_ttl: Arc>> = Arc::new(Mutex::new( + LruTtlCache::builder().max_size(cap).ttl(long_ttl).build(), + )); + { + let mut g = mutex_lru_ttl.lock(); + for i in 0..N_KEYS { + g.cache_set(i, i * 2); + } + } + group.bench_function("Mutex", |b| { + b.iter_custom(|iters| { + let cache = mutex_lru_ttl.clone(); + run_concurrent!(cache, iters, t, i, { + black_box(cache.lock().cache_get(&read_key(i, t))); + }) + }) + }); + + let sharded_lru_ttl = ShardedLruTtlCache::::with_max_size_and_ttl(cap, long_ttl); + for i in 0..N_KEYS { + sharded_lru_ttl.cache_set(i, i * 2).expect("infallible"); + } + group.bench_function("ShardedLruTtlCache", |b| { + b.iter_custom(|iters| { + let cache = sharded_lru_ttl.clone(); + run_concurrent!(cache, iters, t, i, { + black_box(cache.cache_get(&read_key(i, t)).expect("infallible")); + }) + }) + }); + + group.finish(); + + // ---- Write benchmark ------------------------------------------------------ + let mut group = + c.benchmark_group("Concurrent Writes: ShardedLruTtlCache vs Mutex"); + group.throughput(Throughput::Elements(N_THREADS as u64)); + + let mutex_lru_ttl_w: Arc>> = Arc::new(Mutex::new( + LruTtlCache::builder().max_size(cap).ttl(long_ttl).build(), + )); + group.bench_function("Mutex", |b| { + b.iter_custom(|iters| { + let cache = mutex_lru_ttl_w.clone(); + run_concurrent!(cache, iters, t, i, { + cache.lock().cache_set(write_key(i, t), i * 2); + }) + }) + }); + + let sharded_lru_ttl_w = + ShardedLruTtlCache::::with_max_size_and_ttl(cap, long_ttl); + group.bench_function("ShardedLruTtlCache", |b| { + b.iter_custom(|iters| { + let cache = sharded_lru_ttl_w.clone(); + run_concurrent!(cache, iters, t, i, { + cache.cache_set(write_key(i, t), i * 2).expect("infallible"); + }) + }) + }); + + group.finish(); +} + criterion_group!( benches, bench_cache_hits, bench_cache_misses_and_inserts, bench_eviction_overhead, - bench_lock_synchronization + bench_lock_synchronization, + bench_sharded_unbound_concurrent, + bench_sharded_lru_concurrent, + bench_sharded_lru_ttl_concurrent, ); criterion_main!(benches); diff --git a/cached_proc_macro/Cargo.toml b/cached_proc_macro/Cargo.toml index b107471e..5f155da9 100644 --- a/cached_proc_macro/Cargo.toml +++ b/cached_proc_macro/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cached_proc_macro" -version = "1.1.0" +version = "2.0.0" authors = ["csos95 ", "James Kominick "] description = "Generic cache implementations and simplified function memoization" repository = "https://github.com/jaemk/cached" diff --git a/cached_proc_macro/src/cached.rs b/cached_proc_macro/src/cached.rs index 7913e10b..125d2da3 100644 --- a/cached_proc_macro/src/cached.rs +++ b/cached_proc_macro/src/cached.rs @@ -27,13 +27,17 @@ impl FromMeta for SyncLock { } #[derive(FromMeta)] -struct MacroArgs { +struct CachedMacroArgs { #[darling(default)] name: Option, #[darling(default)] unbound: bool, #[darling(default)] size: Option, + /// Alias for `size` — sets the maximum number of cached entries (the LRU bound). + /// Mirrors the `max_size` builder/constructor naming on the cache stores. + #[darling(default)] + max_size: Option, #[darling(default)] ttl: Option, #[darling(default)] @@ -47,9 +51,9 @@ struct MacroArgs { #[darling(default)] convert: Option, #[darling(default)] - result: bool, + cache_err: bool, #[darling(default)] - option: bool, + cache_none: bool, #[darling(default)] sync_writes: SyncWriteMode, #[darling(default = "default_sync_writes_buckets")] @@ -68,6 +72,11 @@ struct MacroArgs { unsync_reads: bool, #[darling(default)] expires: bool, + // Removed attributes intercepted to provide helpful error messages + #[darling(default)] + result: Option, + #[darling(default)] + option: Option, } fn default_sync_writes_buckets() -> usize { @@ -81,12 +90,25 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { return TokenStream::from(darling::Error::from(e).write_errors()); } }; - let args = match MacroArgs::from_list(&attr_args) { + let mut args = match CachedMacroArgs::from_list(&attr_args) { Ok(v) => v, Err(e) => { return TokenStream::from(e.write_errors()); } }; + // `max_size` is an alias for `size`; reconcile them into `size` so the rest + // of the macro only has to consult one field. Specifying both is ambiguous. + if args.size.is_some() && args.max_size.is_some() { + return TokenStream::from( + darling::Error::custom( + "cannot specify both `size` and `max_size` — they are aliases; use one", + ) + .write_errors(), + ); + } + args.size = args.size.or(args.max_size); + // Nudge `size = N` users toward `max_size = N` (empty unless the deprecated spelling was used). + let size_deprecation = size_attr_deprecation_notice(&attr_args); let input = parse_macro_input!(input as ItemFn); // pull out the parts of the input @@ -131,6 +153,28 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { .into(); } + if args.result.is_some() { + return syn::Error::new( + fn_ident.span(), + "the `result` attribute has been removed. `Result` returns now skip caching \ + `Err` by default. Remove `result = true` (or `result = false`), or use \ + `cache_err = true` to force-cache `Err` values.", + ) + .to_compile_error() + .into(); + } + + if args.option.is_some() { + return syn::Error::new( + fn_ident.span(), + "the `option` attribute has been removed. `Option` returns now skip caching \ + `None` by default. Remove `option = true` (or `option = false`), or use \ + `cache_none = true` to force-cache `None` values.", + ) + .to_compile_error() + .into(); + } + if args.expires && args.ttl.is_some() { return syn::Error::new( fn_ident.span(), @@ -203,6 +247,17 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { .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 \ + 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`).", + ) + .to_compile_error() + .into(); + } let input_tys = get_input_types(&inputs); let input_names = get_input_names(&inputs); @@ -221,7 +276,54 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { return with_cache_flag_error(output_span, output_type_display); } - let cache_value_ty = match find_value_type(args.result, args.option, &output, output_ty) { + let is_result_return = is_result_return_type(&output); + let is_option_return = is_option_return_type(&output); + + if args.cache_err && !is_result_return { + return syn::Error::new( + fn_ident.span(), + "`cache_err = true` requires the function to return `Result`", + ) + .to_compile_error() + .into(); + } + if args.cache_none && !is_option_return { + return syn::Error::new( + fn_ident.span(), + "`cache_none = true` requires the function to return `Option`", + ) + .to_compile_error() + .into(); + } + if args.cache_err && args.result_fallback { + return syn::Error::new( + fn_ident.span(), + "`cache_err` and `result_fallback` are mutually exclusive", + ) + .to_compile_error() + .into(); + } + if args.cache_none && args.with_cached_flag { + return syn::Error::new( + 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 \ + 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).", + ) + .to_compile_error() + .into(); + } + + // `is_smart_result`: cache only Ok, skip Err (default for Result returns; opt out with cache_err) + // `is_smart_option`: cache only Some, skip None (default for Option returns; opt out with cache_none) + let is_smart_result = is_result_return && !args.cache_err; + let is_smart_option = is_option_return && !args.cache_none; + + let cache_value_ty = match find_value_type(is_smart_result, is_smart_option, &output, output_ty) + { Ok(value_ty) => value_ty, Err(e) => return e.to_compile_error().into(), }; @@ -243,7 +345,7 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { if let Some(size) = args.size { ( quote! { cached::ExpiringLruCache<#cache_key_ty, #cache_value_ty> }, - quote! { cached::ExpiringLruCache::with_size(#size) }, + quote! { cached::ExpiringLruCache::with_max_size(#size) }, ) } else { ( @@ -267,7 +369,7 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { } (false, Some(size), None, None, None, _) => { let cache_ty = quote! {cached::LruCache<#cache_key_ty, #cache_value_ty>}; - let cache_create = quote! {cached::LruCache::with_size(#size)}; + let cache_create = quote! {cached::LruCache::with_max_size(#size)}; (cache_ty, cache_create) } (false, None, Some(ttl), None, None, refresh) => { @@ -277,7 +379,7 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { } (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::with_size_and_ttl_and_refresh(#size, ::cached::time::Duration::from_secs(#ttl), #refresh)}; + let cache_create = quote! {cached::LruTtlCache::with_max_size_and_ttl_and_refresh(#size, ::cached::time::Duration::from_secs(#ttl), #refresh)}; (cache_ty, cache_create) } (false, None, None, None, None, _) => { @@ -334,7 +436,7 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { }; // make the set cache and return cache blocks - let (set_cache_block, return_cache_block) = match (&args.result, &args.option) { + 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 return_cache_block = if args.with_cached_flag { @@ -370,14 +472,7 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { }; (set_cache_block, return_cache_block) } - _ => { - return syn::Error::new( - fn_ident.span(), - "`result` and `option` attributes are mutually exclusive", - ) - .to_compile_error() - .into(); - } + (true, true) => unreachable!("return type cannot be both Result and Option"), }; if let Err(error) = validate_sync_writes_buckets(args.sync_writes_buckets, fn_ident.span()) { @@ -393,10 +488,10 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { .into(); } - if args.result_fallback && !args.result { + if args.result_fallback && !is_result_return { return syn::Error::new( fn_ident.span(), - "`result_fallback` requires `result = true` because it falls back from `Err` to a cached `Ok` value", + "`result_fallback` requires a `Result` return type", ) .to_compile_error() .into(); @@ -694,6 +789,7 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { // put it all together let expanded = quote! { + #size_deprecation // Cached static #[doc = #cache_ident_doc] #ty diff --git a/cached_proc_macro/src/concurrent_cached.rs b/cached_proc_macro/src/concurrent_cached.rs index f8e93320..221e9188 100644 --- a/cached_proc_macro/src/concurrent_cached.rs +++ b/cached_proc_macro/src/concurrent_cached.rs @@ -10,8 +10,9 @@ use syn::{ }; #[derive(FromMeta)] -struct IOMacroArgs { - map_error: String, +struct ConcurrentCachedArgs { + #[darling(default)] + map_error: Option, #[darling(default)] disk: bool, #[darling(default)] @@ -37,6 +38,16 @@ struct IOMacroArgs { #[darling(default)] with_cached_flag: bool, #[darling(default)] + cache_err: bool, + #[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 + /// `ConcurrentCloneCached::cache_get_with_expiry_status` (no separate store is + /// created) and re-cached with a fresh TTL window on `Err`. + #[darling(default)] + result_fallback: bool, + #[darling(default)] ty: Option, #[darling(default)] create: Option, @@ -44,6 +55,20 @@ struct IOMacroArgs { sync_to_disk_on_cache_change: Option, #[darling(default)] connection_config: Option, + /// Total LRU capacity for the default in-memory sharded store. + /// Only meaningful when `redis=false`, `disk=false`, and `create` is not set. + #[darling(default)] + size: Option, + /// Alias for `size` — sets the maximum number of cached entries (the LRU bound). + /// Mirrors the `max_size` builder/constructor naming on the cache stores. + #[darling(default)] + max_size: Option, + /// Number of shards for the default in-memory sharded store. + /// Only meaningful when `redis=false`, `disk=false`, and `create` is not set. + #[darling(default)] + shards: Option, + #[darling(default)] + expires: bool, } /// When a `create` block is supplied the user fully constructs the store, so @@ -52,7 +77,21 @@ struct IOMacroArgs { /// them — otherwise `disk_dir` / `sync_to_disk_on_cache_change` / /// `connection_config` (and `ttl` / `refresh` / `cache_prefix_block`) look /// applied but are not. -fn check_create_conflicts(args: &IOMacroArgs, span: proc_macro2::Span) -> Result<(), syn::Error> { +/// Returns the attribute name the user actually wrote for the LRU bound. +/// `max_size` is an alias for `size` and is reconciled into `size` early in +/// expansion, so diagnostics must consult `max_size` to echo the user's wording. +fn size_attr_name(args: &ConcurrentCachedArgs) -> &'static str { + if args.max_size.is_some() { + "max_size" + } else { + "size" + } +} + +fn check_create_conflicts( + args: &ConcurrentCachedArgs, + span: proc_macro2::Span, +) -> Result<(), syn::Error> { let mut conflicting = Vec::new(); if args.ttl.is_some() { conflicting.push("ttl"); @@ -72,6 +111,12 @@ fn check_create_conflicts(args: &IOMacroArgs, span: proc_macro2::Span) -> Result if args.sync_to_disk_on_cache_change.is_some() { conflicting.push("sync_to_disk_on_cache_change"); } + if args.size.is_some() { + conflicting.push(size_attr_name(args)); + } + if args.shards.is_some() { + conflicting.push("shards"); + } if conflicting.is_empty() { return Ok(()); } @@ -90,6 +135,41 @@ fn check_create_conflicts(args: &IOMacroArgs, span: proc_macro2::Span) -> Result )) } +fn reject_cached_only_attrs(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() { + "result" => Some( + "`result` is not a valid attribute for `#[concurrent_cached]`; \ + return `Result` and only `Ok` values are cached by default. \ + Use `cache_err = true` to also cache `Err` values.", + ), + "option" => Some( + "`option = true` is not a valid attribute for `#[concurrent_cached]`; \ + `Option` returns skip `None` by default. \ + Use `cache_none = true` to force caching `None` values.", + ), + "sync_writes" => Some( + "`sync_writes` is not supported by #[concurrent_cached]; concurrent stores \ + synchronize cache access internally but do not deduplicate first-call execution", + ), + _ => None, + }; + if let Some(message) = message { + return Err(syn::Error::new(meta.span(), message)); + } + } + Ok(()) +} + pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { let attr_args = match NestedMeta::parse_meta_list(args.into()) { Ok(v) => v, @@ -97,12 +177,28 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { return TokenStream::from(darling::Error::from(e).write_errors()); } }; - let args = match IOMacroArgs::from_list(&attr_args) { + if let Err(e) = reject_cached_only_attrs(&attr_args) { + return e.to_compile_error().into(); + } + let mut args = match ConcurrentCachedArgs::from_list(&attr_args) { Ok(v) => v, Err(e) => { return TokenStream::from(e.write_errors()); } }; + // `max_size` is an alias for `size`; reconcile them into `size` so the rest + // of the macro only has to consult one field. Specifying both is ambiguous. + if args.size.is_some() && args.max_size.is_some() { + return TokenStream::from( + darling::Error::custom( + "cannot specify both `size` and `max_size` — they are aliases; use one", + ) + .write_errors(), + ); + } + args.size = args.size.or(args.max_size); + // Nudge `size = N` users toward `max_size = N` (empty unless the deprecated spelling was used). + let size_deprecation = size_attr_deprecation_notice(&attr_args); let input = parse_macro_input!(input as ItemFn); // pull out the parts of the input @@ -147,6 +243,94 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { .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", + ) + .to_compile_error() + .into(); + } + if args.disk { + return syn::Error::new( + fn_ident.span(), + "`expires` and `disk` are mutually exclusive — `expires` selects sharded in-memory expiring stores", + ) + .to_compile_error() + .into(); + } + if args.ty.is_some() { + return syn::Error::new( + fn_ident.span(), + "`expires` and `ty` are mutually exclusive — `expires` generates the store type automatically", + ) + .to_compile_error() + .into(); + } + if args.create.is_some() { + return syn::Error::new( + fn_ident.span(), + "`expires` and `create` are mutually exclusive — `expires` generates the store constructor automatically", + ) + .to_compile_error() + .into(); + } + if args.refresh.is_some() { + return syn::Error::new( + fn_ident.span(), + "`expires` and `refresh` are mutually exclusive — `expires` delegates expiry to the value via `Expires::is_expired`", + ) + .to_compile_error() + .into(); + } + if args.cache_none { + return syn::Error::new( + fn_ident.span(), + "`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`).", + ) + .to_compile_error() + .into(); + } + if args.result_fallback { + return syn::Error::new( + fn_ident.span(), + "`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`).", + ) + .to_compile_error() + .into(); + } + if args.cache_err { + return syn::Error::new( + fn_ident.span(), + "`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`.", + ) + .to_compile_error() + .into(); + } + } + let input_tys = get_input_types(&inputs); let input_names = get_input_names(&inputs); @@ -161,6 +345,113 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { let output_ts = TokenStream::from(output_ty); let output_type_display = output_ts.to_string().replace(' ', ""); + // Detect return type shape. These drive smart-caching decisions throughout. + 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. + // 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; + // 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; + + if args.cache_err && !is_result_return { + return syn::Error::new( + fn_ident.span(), + "`cache_err = true` requires the function to return `Result`", + ) + .to_compile_error() + .into(); + } + if args.cache_none && !is_option_return { + return syn::Error::new( + fn_ident.span(), + "`cache_none = true` requires the function to return `Option`", + ) + .to_compile_error() + .into(); + } + if args.result_fallback && !is_result_return { + return syn::Error::new( + fn_ident.span(), + "`result_fallback` requires a `Result` return type", + ) + .to_compile_error() + .into(); + } + if args.result_fallback && args.cache_err { + return syn::Error::new( + fn_ident.span(), + "`result_fallback` and `cache_err` are mutually exclusive", + ) + .to_compile_error() + .into(); + } + if args.result_fallback && args.with_cached_flag { + return syn::Error::new( + 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 \ + 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.", + ) + .to_compile_error() + .into(); + } + + // `is_smart_option` on non-default paths is unsupported: stripping Option requires + // storing T, but redis/disk/custom stores are configured by the user to store the full + // return type. The same check catches `cache_none = false` (default) on these paths. + if is_smart_option { + if args.redis { + return syn::Error::new( + fn_ident.span(), + "`Option` return types that skip `None` are only supported for the default \ + in-memory sharded stores, not `redis = true`. \ + Use `Result` as the return type, or remove `redis = true` to use the \ + default in-memory sharded path.", + ) + .to_compile_error() + .into(); + } + if args.disk { + return syn::Error::new( + fn_ident.span(), + "`Option` return types that skip `None` are only supported for the default \ + in-memory sharded stores, not `disk = true`. \ + Use `Result` as the return type, or remove `disk = true` to use the \ + default in-memory sharded path.", + ) + .to_compile_error() + .into(); + } + if args.ty.is_some() { + return syn::Error::new( + fn_ident.span(), + "`Option` return types that skip `None` are only supported for the default \ + in-memory sharded stores, not a custom `ty`. \ + Use `Result` as the return type, or remove `ty` to use the \ + default in-memory sharded path.", + ) + .to_compile_error() + .into(); + } + if args.create.is_some() { + return syn::Error::new( + fn_ident.span(), + "`Option` return types that skip `None` are only supported for the default \ + in-memory sharded stores, not a custom `create`. \ + Use `Result` as the return type, or remove `create` to use the \ + default in-memory sharded path.", + ) + .to_compile_error() + .into(); + } + } + // if `with_cached_flag = true`, then enforce that the return type // is something wrapped in `Return`. Either `Return` or the // fully qualified `cached::Return` @@ -171,7 +462,9 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { "\nWhen specifying `with_cached_flag = true`, \ the return type must be wrapped in `cached::Return`. \n\ The following return types are supported: \n\ + | `cached::Return`\n\ | `Result, E>`\n\ + | `Option>`\n\ Found type: {t}.", t = output_type_display ), @@ -180,42 +473,36 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { .into(); } - // Find the type of the value to store. - // Return type always needs to be a result, so we want the (first) inner type. - // For Result, store i32, etc. - let cache_value_ty = { + // `Option>` without smart-option mode would fall into the plain-Return + // 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 \ + 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.", + ) + .to_compile_error() + .into(); + } + + // Find the type of the value to store in the cache. + // For Result (is_smart_result): store the Ok type T. + // For Option (is_smart_option): store the inner T (None is not cached). + // For with_cached_flag: unwrap Return one further level to store T. + // For plain return types (cache_err/cache_none opt-ins): store the return type as-is. + let unable = format!( + "#[concurrent_cached] unable to determine cache value type, found {output_type_display:?}" + ); + let cache_value_ty = if is_smart_result { let ReturnType::Type(_, ty) = output.clone() else { - return syn::Error::new( - output_span, - format!( - "#[concurrent_cached] functions must return `Result`s, found {output_type_display:?}" - ), - ) - .to_compile_error() - .into(); + unreachable!("is_smart_result=true implies ReturnType::Type") }; - // The outer type must be a `Result` — the generated body calls - // `.map_err(#map_error)?` on it. Verify structurally so `-> Option`, - // `-> Vec`, `-> T`, etc. fail here with a clear message instead of - // deeper inside the generated code. A proc macro only sees tokens, so a - // `Result` *type alias* renamed away from `Result` is not recognized - // (the same token-only limitation documented for `with_cached_flag`). - let is_result = matches!( - &*ty, - Type::Path(tp) if tp.path.segments.last().is_some_and(|s| s.ident == "Result") - ); - if !is_result { - return syn::Error::new( - output_span, - format!( - "#[concurrent_cached] functions must return `Result`s, found {output_type_display:?}" - ), - ) - .to_compile_error() - .into(); - } - // The `Ok` type of the function's `Result<…, E>`. let ok_ty = match first_type_arg( &ty, @@ -233,11 +520,8 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { // 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 unable = format!( - "#[concurrent_cached] unable to determine cache value type, found {output_type_display:?}" - ); let GenericArgument::Type(return_ty) = ok_ty else { - return syn::Error::new(output_span, unable) + return syn::Error::new(output_span, &unable) .to_compile_error() .into(); }; @@ -248,8 +532,53 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { } else { quote! { #ok_ty } } + } else if is_smart_option { + // Option or Option>: strip the outer Option<_>. + let ReturnType::Type(_, ty) = output.clone() else { + unreachable!("is_smart_option=true implies ReturnType::Type") + }; + let inner_ty = match first_type_arg(&ty, output_span, &unable, &unable) { + Ok(arg) => arg, + Err(error) => return error.to_compile_error().into(), + }; + if args.with_cached_flag { + // Option>: peel Return one more level. + let GenericArgument::Type(return_ty) = inner_ty else { + return syn::Error::new(output_span, &unable) + .to_compile_error() + .into(); + }; + match first_type_arg(return_ty, output_span, &unable, &unable) { + Ok(inner) => quote! { #inner }, + Err(error) => return error.to_compile_error().into(), + } + } else { + quote! { #inner_ty } + } + } else { + // Plain return type (cache_err = true, cache_none = true, or non-Result/Option type). + // `with_cached_flag` stores the inner `T` from `Return`. + // Validation that this combination is only allowed on the infallible + // in-memory sharded path happens after store selection below. + let plain_ty = match &output { + ReturnType::Default => quote! { () }, + ReturnType::Type(_, ty) => { + if args.with_cached_flag { + match first_type_arg(ty, output_span, &unable, &unable) { + Ok(inner) => quote! { #inner }, + Err(error) => return error.to_compile_error().into(), + } + } else { + quote! { #ty } + } + } + }; + plain_ty }; + let with_cached_flag_result = args.with_cached_flag && is_smart_result; + let with_cached_flag_option = args.with_cached_flag && is_smart_option; + // make the cache identifier let cache_ident = match args.name { Some(ref name) => Ident::new(name, fn_ident.span()), @@ -263,19 +592,63 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { Err(error) => return error.to_compile_error().into(), }; + // 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(…)`. + let mut infallible_default = false; + // make the cache type and create statement let (cache_ty, cache_create) = match (&args.redis, &args.disk) { - (true, false) => match get_redis_cache_type_and_create( - &args, - &cache_ident, - &cache_key_ty, - &cache_value_ty, - asyncness.is_some(), - ) { - Ok(v) => v, - Err(e) => return e.to_compile_error().into(), - }, + (true, false) => { + if args.shards.is_some() { + return syn::Error::new( + fn_ident.span(), + "`shards` only applies to the default in-memory store, not `redis = true`", + ) + .to_compile_error() + .into(); + } + if args.size.is_some() { + return syn::Error::new( + fn_ident.span(), + format!( + "`{}` only applies to the default in-memory store, not `redis = true`", + size_attr_name(&args) + ), + ) + .to_compile_error() + .into(); + } + match get_redis_cache_type_and_create( + &args, + &cache_ident, + &cache_key_ty, + &cache_value_ty, + asyncness.is_some(), + ) { + Ok(v) => v, + Err(e) => return e.to_compile_error().into(), + } + } (false, true) => { + if args.shards.is_some() { + return syn::Error::new( + fn_ident.span(), + "`shards` only applies to the default in-memory store, not `disk = true`", + ) + .to_compile_error() + .into(); + } + if args.size.is_some() { + return syn::Error::new( + fn_ident.span(), + format!( + "`{}` only applies to the default in-memory store, not `disk = true`", + size_attr_name(&args) + ), + ) + .to_compile_error() + .into(); + } match get_disk_cache_type_and_create( &args, &cache_name, @@ -287,63 +660,268 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { Err(e) => return e.to_compile_error().into(), } } - _ => match get_custom_cache_type_and_create(&args, &fn_ident) { - Ok(v) => v, - Err(e) => return e.to_compile_error().into(), - }, + (true, true) => { + return syn::Error::new( + fn_ident.span(), + "`redis = true` and `disk = true` are mutually exclusive", + ) + .to_compile_error() + .into(); + } + _ => { + // Default cascade: when no `redis`/`disk` and no custom `ty`/`create`, fall + // back to one of the sharded in-memory stores. With `ty` or `create`, the + // user supplies everything and we delegate to the custom path. + if args.ty.is_some() || args.create.is_some() { + match get_custom_cache_type_and_create(&args, &fn_ident) { + Ok(v) => v, + Err(e) => return e.to_compile_error().into(), + } + } else { + match get_sharded_cache_type_and_create( + &args, + &cache_key_ty, + &cache_value_ty, + &fn_ident, + ) { + Ok(v) => { + infallible_default = true; + v + } + Err(e) => return e.to_compile_error().into(), + } + } + } }; - let map_error = &args.map_error; - let map_error = match parse_str::(map_error) { - Ok(map_error) => map_error, - Err(error) => { + // cache_none / cache_err are only valid for the in-memory sharded default path; give + // targeted errors before the generic non-Result check below so the message names the + // offending attribute rather than the return type. + if args.cache_none && !infallible_default { + return syn::Error::new( + fn_ident.span(), + "`cache_none = true` is only supported for the default in-memory sharded stores", + ) + .to_compile_error() + .into(); + } + + // Non-Result return types (plain, or Option after cache_none is handled above) are only + // valid for the in-memory sharded default path. Result return types work with all + // store backends (redis/disk/custom). + if !is_result_return && !infallible_default { + let kind = if is_option_return { + "Option" + } else { + "plain" + }; + return syn::Error::new( + output_span, + format!( + "#[concurrent_cached] {kind} return types are only supported for the default \ + in-memory sharded stores. Use `Result` when specifying `redis`, `disk`, \ + or a custom `ty`/`create`." + ), + ) + .to_compile_error() + .into(); + } + + if args.cache_err && !infallible_default { + return syn::Error::new( + fn_ident.span(), + "`cache_err = true` is only supported for the default in-memory sharded stores", + ) + .to_compile_error() + .into(); + } + + if args.result_fallback && !infallible_default { + return syn::Error::new( + fn_ident.span(), + "`result_fallback` is only supported for the default in-memory sharded stores", + ) + .to_compile_error() + .into(); + } + + if args.result_fallback && args.ttl.is_none() { + return syn::Error::new( + fn_ident.span(), + "`result_fallback` requires `ttl` to be set. Set a TTL to use a time-bounded \ + sharded store (e.g. `ttl = 60`). Without a TTL, the primary cache never expires \ + and `result_fallback` would only activate on cache misses that return `Err`, \ + providing no meaningful fallback behavior.", + ) + .to_compile_error() + .into(); + } + + // 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. + // + // 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) { + (Some(_), true) => { return syn::Error::new( fn_ident.span(), - format!("unable to parse `map_error` closure: {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.", + ) + .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}"), + ) + .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(); } }; + // 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. + 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)"); + (quote! { .map_err(#me)? }, quote! { .map_err(#me)? }) + }; + // 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(); + // make the set cache and return cache blocks - let (set_cache_block, return_cache_block) = { - let (set_cache_block, return_cache_block) = if args.with_cached_flag { - ( - if asyncness.is_some() { - quote! { - if let Ok(result) = &result { - cache.cache_set(key, result.value.clone()).await.map_err(#map_error)?; - } + let (set_cache_block, return_cache_block) = if with_cached_flag_result { + // Result, E>: cache the inner T from Ok(Return). + ( + if asyncness.is_some() { + quote! { + if let Ok(result) = &result { + ::cached::ConcurrentCachedAsync::cache_set(cache, key, result.value.clone()).await #cache_set_unwrap_async; } - } else { - quote! { - if let Ok(result) = &result { - cache.cache_set(key, result.value.clone()).map_err(#map_error)?; - } + } + } else { + quote! { + if let Ok(result) = &result { + ::cached::ConcurrentCached::cache_set(cache, key, result.value.clone()) #cache_set_unwrap; } - }, - quote! { let mut r = ::cached::Return::new(result.clone()); r.was_cached = true; return Ok(r) }, - ) - } else { - ( - if asyncness.is_some() { - quote! { - if let Ok(result) = &result { - cache.cache_set(key, result.clone()).await.map_err(#map_error)?; - } + } + }, + quote! { let mut r = ::cached::Return::new(result); r.was_cached = true; return Ok(r) }, + ) + } else if with_cached_flag_option { + // Option>: cache the inner T from Some(Return), skip None. + ( + if asyncness.is_some() { + quote! { + if let Some(result) = &result { + ::cached::ConcurrentCachedAsync::cache_set(cache, key, result.value.clone()).await #cache_set_unwrap_async; } - } else { - quote! { - if let Ok(result) = &result { - cache.cache_set(key, result.clone()).map_err(#map_error)?; - } + } + } else { + quote! { + if let Some(result) = &result { + ::cached::ConcurrentCached::cache_set(cache, key, result.value.clone()) #cache_set_unwrap; } - }, - quote! { return Ok(result.clone()) }, - ) - }; - (set_cache_block, return_cache_block) + } + }, + quote! { let mut r = ::cached::Return::new(result); r.was_cached = true; return Some(r) }, + ) + } else if args.with_cached_flag { + // Plain Return: cache the inner T directly. + ( + if asyncness.is_some() { + quote! { + ::cached::ConcurrentCachedAsync::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 }, + ) + } else if is_smart_result { + // Result return type: cache only Ok(T), skip Err + ( + if asyncness.is_some() { + quote! { + if let Ok(result) = &result { + ::cached::ConcurrentCachedAsync::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! { return Ok(result) }, + ) + } else if is_smart_option { + // Option: cache Some(T), skip None. infallible_default guaranteed. + ( + if asyncness.is_some() { + quote! { + if let Some(result) = &result { + ::cached::ConcurrentCachedAsync::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! { return Some(result) }, + ) + } else { + // Plain return type — infallible_default is guaranteed true here. + // No Ok/Err wrapping: the result is the value directly. + ( + if asyncness.is_some() { + quote! { + ::cached::ConcurrentCachedAsync::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 }, + ) }; // Clone the full original signature and rename it to `inner`. Quoting the @@ -352,11 +930,56 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { let mut inner_sig = signature.clone(); inner_sig.ident = Ident::new("inner", fn_ident.span()); - let do_set_return_block = if asyncness.is_some() { + // `do_set_return_block`: runs `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. + 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_sig #body let result = inner(#(#input_names),*).await; - let cache = &#cache_ident.get_or_init(init).await; + let result = match (result.is_err(), old_val) { + (true, Some(old_val)) => Ok(old_val), + _ => result, + }; + if let Ok(ok_val) = &result { + ::cached::ConcurrentCachedAsync::cache_set(cache, key.clone(), ok_val.clone()).await #cache_set_unwrap_async; + } + 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_sig #body + let result = inner(#(#input_names),*); + let result = match (result.is_err(), old_val) { + (true, Some(old_val)) => Ok(old_val), + _ => result, + }; + if let Ok(ok_val) = &result { + ::cached::ConcurrentCached::cache_set(cache, key.clone(), ok_val.clone()) #cache_set_unwrap; + } + 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; #set_cache_block result } @@ -364,7 +987,7 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { quote! { #inner_sig #body let result = inner(#(#input_names),*); - let cache = &#cache_ident; + let cache = &*#cache_ident; #set_cache_block result } @@ -386,84 +1009,105 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { ); fill_in_attributes(&mut attributes, cache_fn_doc_extra); - let async_trait = if asyncness.is_some() { + // `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, + // 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! { - use cached::ConcurrentCachedAsync; + #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::cache_set(cache, key.clone(), ok_val.clone()).await #cache_set_unwrap_async; + } + result } - } else { + } else if args.result_fallback { quote! { - use cached::ConcurrentCached; + #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; + } + result } + } else { + do_set_return_block.clone() }; - let async_cache_get_return = if asyncness.is_some() { + // `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. + let initial_cache_lookup_async = if args.result_fallback { + quote! {} + } else { quote! { - if let Some(result) = cache.cache_get(&key).await.map_err(#map_error)? { - #return_cache_block + { + // check if the result is cached + let cache = #cache_ident.get_or_init(|| async { #cache_create }).await; + if let Some(result) = ::cached::ConcurrentCachedAsync::cache_get(cache, &key).await #cache_get_unwrap_async { + #return_cache_block + } } } + }; + let initial_cache_lookup_sync = if args.result_fallback { + quote! {} } else { quote! { - if let Some(result) = cache.cache_get(&key).map_err(#map_error)? { - #return_cache_block + { + // 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 + } } } }; + // put it all together let expanded = if asyncness.is_some() { quote! { + #size_deprecation // Cached static #[doc = #cache_ident_doc] #visibility static #cache_ident: ::cached::async_sync::OnceCell<#cache_ty> = ::cached::async_sync::OnceCell::const_new(); // Cached function #(#attributes)* #visibility #signature_no_muts { - let init = || async { #cache_create }; - #async_trait let key = #key_convert_block; - { - // check if the result is cached - let cache = &#cache_ident.get_or_init(init).await; - #async_cache_get_return - } + #initial_cache_lookup_async #do_set_return_block } // Prime cached function #[doc = #prime_fn_indent_doc] #[allow(dead_code)] #visibility #prime_sig { - #async_trait - let init = || async { #cache_create }; let key = #key_convert_block; - #do_set_return_block + #prime_do_set_return_block } } } else { quote! { + #size_deprecation // Cached static #[doc = #cache_ident_doc] #visibility static #cache_ident: ::std::sync::LazyLock<#cache_ty> = ::std::sync::LazyLock::new(|| #cache_create); // Cached function #(#attributes)* #visibility #signature_no_muts { - use cached::ConcurrentCached; let key = #key_convert_block; - { - // check if the result is cached - let cache = &#cache_ident; - if let Some(result) = cache.cache_get(&key).map_err(#map_error)? { - #return_cache_block - } - } + #initial_cache_lookup_sync #do_set_return_block } // Prime cached function #[doc = #prime_fn_indent_doc] #[allow(dead_code)] #visibility #prime_sig { - use cached::ConcurrentCached; let key = #key_convert_block; - #do_set_return_block + #prime_do_set_return_block } } }; @@ -472,7 +1116,7 @@ pub fn concurrent_cached(args: TokenStream, input: TokenStream) -> TokenStream { } fn get_redis_cache_type_and_create( - args: &IOMacroArgs, + args: &ConcurrentCachedArgs, cache_ident: &Ident, cache_key_ty: &proc_macro2::TokenStream, cache_value_ty: &proc_macro2::TokenStream, @@ -560,7 +1204,7 @@ fn get_redis_cache_type_and_create( } fn get_disk_cache_type_and_create( - args: &IOMacroArgs, + args: &ConcurrentCachedArgs, cache_name: &str, cache_key_ty: &proc_macro2::TokenStream, cache_value_ty: &proc_macro2::TokenStream, @@ -648,8 +1292,167 @@ fn get_disk_cache_type_and_create( Ok((cache_ty, cache_create)) } +/// Default in-memory sharded store selector. +/// +/// Selects one of the four `Sharded*Cache` variants based on the `size` / `ttl` +/// attributes supplied to the macro: +/// +/// | size | ttl | store | +/// |------|-----|-------| +/// | no | no | `ShardedCache` | +/// | yes | no | `ShardedLruCache` | +/// | no | yes | `ShardedTtlCache` (requires `time_stores` feature on `cached`) | +/// | yes | yes | `ShardedLruTtlCache` (requires `time_stores` feature on `cached`) | +/// +/// `shards = N` is honored on every variant and routes through the `_and_shards` +/// shortcut constructor. +fn get_sharded_cache_type_and_create( + args: &ConcurrentCachedArgs, + cache_key_ty: &proc_macro2::TokenStream, + cache_value_ty: &proc_macro2::TokenStream, + fn_ident: &Ident, +) -> Result<(proc_macro2::TokenStream, proc_macro2::TokenStream), syn::Error> { + if matches!(args.shards, Some(0)) { + return Err(syn::Error::new(fn_ident.span(), "`shards` must be >= 1")); + } + if matches!(args.size, Some(0)) { + return Err(syn::Error::new(fn_ident.span(), "`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() { + return Err(syn::Error::new( + fn_ident.span(), + "`refresh` requires `ttl` to be set on the default in-memory sharded path", + )); + } + + // Reject attributes that don't apply to the in-memory default path. + let mut conflicting = Vec::new(); + if args.cache_prefix_block.is_some() { + conflicting.push("cache_prefix_block"); + } + if args.disk_dir.is_some() { + conflicting.push("disk_dir"); + } + if args.connection_config.is_some() { + conflicting.push("connection_config"); + } + if args.sync_to_disk_on_cache_change.is_some() { + conflicting.push("sync_to_disk_on_cache_change"); + } + if !conflicting.is_empty() { + let list = conflicting + .iter() + .map(|a| format!("`{a}`")) + .collect::>() + .join(", "); + return Err(syn::Error::new( + fn_ident.span(), + format!( + "{list} only apply to the redis/disk paths; for the default in-memory \ + sharded store remove these attributes or provide a custom `create` block" + ), + )); + } + + let (cache_ty, cache_create) = if args.expires { + match args.size { + Some(size) => { + let ty = + quote! { ::cached::ShardedExpiringLruCache<#cache_key_ty, #cache_value_ty> }; + let create = match args.shards { + Some(n) => { + quote! { ::cached::ShardedExpiringLruCache::with_max_size_and_shards(#size, #n) } + } + None => quote! { ::cached::ShardedExpiringLruCache::with_max_size(#size) }, + }; + (ty, create) + } + None => { + let ty = quote! { ::cached::ShardedExpiringCache<#cache_key_ty, #cache_value_ty> }; + let create = match args.shards { + Some(n) => quote! { ::cached::ShardedExpiringCache::with_shards(#n) }, + None => quote! { ::cached::ShardedExpiringCache::new() }, + }; + (ty, create) + } + } + } else { + match (args.size, args.ttl) { + (None, None) => { + let ty = quote! { ::cached::ShardedCache<#cache_key_ty, #cache_value_ty> }; + let create = match args.shards { + Some(n) => quote! { ::cached::ShardedCache::with_shards(#n) }, + None => quote! { ::cached::ShardedCache::new() }, + }; + (ty, create) + } + (Some(size), None) => { + let ty = quote! { ::cached::ShardedLruCache<#cache_key_ty, #cache_value_ty> }; + let create = match args.shards { + Some(n) => { + quote! { ::cached::ShardedLruCache::with_max_size_and_shards(#size, #n) } + } + None => quote! { ::cached::ShardedLruCache::with_max_size(#size) }, + }; + (ty, create) + } + (None, Some(ttl)) => { + let ty = quote! { ::cached::ShardedTtlCache<#cache_key_ty, #cache_value_ty> }; + let refresh = args.refresh.unwrap_or(false); + let create = match args.shards { + Some(n) => quote! {{ + let __c = ::cached::ShardedTtlCache::with_ttl_and_shards( + ::cached::time::Duration::from_secs(#ttl), + #n, + ); + __c.set_refresh_on_hit(#refresh); + __c + }}, + None => quote! {{ + let __c = ::cached::ShardedTtlCache::with_ttl( + ::cached::time::Duration::from_secs(#ttl), + ); + __c.set_refresh_on_hit(#refresh); + __c + }}, + }; + (ty, create) + } + (Some(size), Some(ttl)) => { + let ty = quote! { ::cached::ShardedLruTtlCache<#cache_key_ty, #cache_value_ty> }; + let refresh = args.refresh.unwrap_or(false); + let create = match args.shards { + Some(n) => quote! {{ + let __c = ::cached::ShardedLruTtlCache::with_max_size_and_ttl_and_shards( + #size, + ::cached::time::Duration::from_secs(#ttl), + #n, + ); + __c.set_refresh_on_hit(#refresh); + __c + }}, + None => quote! {{ + let __c = ::cached::ShardedLruTtlCache::with_max_size_and_ttl( + #size, + ::cached::time::Duration::from_secs(#ttl), + ); + __c.set_refresh_on_hit(#refresh); + __c + }}, + }; + (ty, create) + } + } + }; + + Ok((cache_ty, cache_create)) +} + fn get_custom_cache_type_and_create( - args: &IOMacroArgs, + args: &ConcurrentCachedArgs, fn_ident: &Ident, ) -> Result<(proc_macro2::TokenStream, proc_macro2::TokenStream), syn::Error> { let cache_ty = match &args.ty { diff --git a/cached_proc_macro/src/helpers.rs b/cached_proc_macro/src/helpers.rs index aec36ffe..a7458a38 100644 --- a/cached_proc_macro/src/helpers.rs +++ b/cached_proc_macro/src/helpers.rs @@ -1,16 +1,68 @@ +use darling::ast::NestedMeta; use darling::{Error, FromMeta}; use proc_macro::TokenStream; use proc_macro2::TokenStream as TokenStream2; use quote::__private::Span; -use quote::quote; +use quote::{quote, quote_spanned}; use std::ops::Deref; use syn::punctuated::Punctuated; +use syn::spanned::Spanned; use syn::token::Comma; use syn::{ parse_quote, parse_str, Attribute, Block, FnArg, GenericArgument, Pat, PatType, PathArguments, ReturnType, Signature, Type, }; +/// Span of the named attribute (`name = …`) within a parsed attribute list, if present. +/// Used to anchor the `size` → `max_size` deprecation warning at the exact attribute. +fn named_meta_span(attr_args: &[NestedMeta], name: &str) -> Option { + attr_args.iter().find_map(|nm| match nm { + NestedMeta::Meta(meta) if meta.path().is_ident(name) => Some(meta.span()), + _ => None, + }) +} + +/// Tokens that emit a `deprecated` compiler warning nudging `size = N` users toward +/// `max_size = N`, anchored at the user's `size` attribute. Expands to a zero-cost +/// anonymous `const` item (valid at module or block scope) referencing the deprecated +/// `cached::__DEPRECATED_SIZE_ATTR` marker. Returns an empty stream when the +/// (non-deprecated) `max_size` spelling — or neither — was used. +pub(super) fn size_attr_deprecation_notice(attr_args: &[NestedMeta]) -> TokenStream2 { + match named_meta_span(attr_args, "size") { + Some(span) => quote_spanned! {span=> + const _: () = ::cached::__DEPRECATED_SIZE_ATTR; + }, + None => TokenStream2::new(), + } +} + +/// 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 { + ReturnType::Default => false, + ReturnType::Type(_, ty) => match &**ty { + Type::Path(tp) => tp.path.segments.last().is_some_and(|s| { + !matches!(s.arguments, PathArguments::None) && s.ident == "Result" + }), + _ => false, + }, + } +} + +/// 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, + ReturnType::Type(_, ty) => match &**ty { + Type::Path(tp) => tp.path.segments.last().is_some_and(|s| { + !matches!(s.arguments, PathArguments::None) && s.ident == "Option" + }), + _ => false, + }, + } +} + #[derive(Debug, Default, Eq, PartialEq)] pub(super) enum SyncWriteMode { #[default] diff --git a/cached_proc_macro/src/lib.rs b/cached_proc_macro/src/lib.rs index f0453086..25e16aa3 100644 --- a/cached_proc_macro/src/lib.rs +++ b/cached_proc_macro/src/lib.rs @@ -10,8 +10,9 @@ use proc_macro::TokenStream; /// /// # Attributes /// - `name`: (optional, string) specify the name for the generated cache, defaults to the function name uppercase. -/// - `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`. +/// - `max_size`: (optional, usize) specify an LRU max size, implies the cache type is a `LruCache` or `LruTtlCache`. +/// - `size`: **deprecated** alias for `max_size` — using it emits a deprecation warning. Prefer `max_size`. +/// - `ttl`: (optional, u64) specify a cache TTL in seconds, implies the cache type is a `TtlCache` or `LruTtlCache` (requires the `time_stores` feature). /// - `refresh`: (optional, bool) specify whether to refresh the TTL on cache hits. /// - `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` @@ -29,9 +30,9 @@ use proc_macro::TokenStream; /// 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 `size` is specified, defaults to `LruCache`. +/// specified, defaults to `UnboundCache`. When `max_size` is specified, defaults to `LruCache`. /// When `ttl` is specified, defaults to `TtlCache`. -/// When `size` and `ttl` are specified, defaults to `LruTtlCache`. When `ty` is +/// When `max_size` and `ttl` are specified, defaults to `LruTtlCache`. When `ty` is /// specified, `create` must also be specified. /// - `create`: (optional, string expr) specify an expression used to create a new cache store, e.g. `create = r##"{ CacheType::new() }"##`. /// - `key`: (optional, string type) specify what type to use for the cache key, e.g. `key = "u32"`. @@ -39,9 +40,10 @@ use proc_macro::TokenStream; /// - `convert`: (optional, string expr) specify an expression used to convert function arguments to a cache /// key, e.g. `convert = r##"{ format!("{}:{}", arg1, arg2) }"##`. When `convert` is specified, /// `key` or `ty` must also be set. -/// - `result`: (optional, bool) If your function returns a `Result`, only cache `Ok` values returned by the function. -/// - `option`: (optional, bool) If your function returns an `Option`, only cache `Some` values returned by the function. -/// - `with_cached_flag`: (optional, bool) If your function returns a `cached::Return` or `Result`, +/// - `cache_err`: (optional, bool) If your function returns a `Result`, also cache `Err` values (by default only `Ok` is cached). +/// - `cache_none`: (optional, bool) If your function returns an `Option`, also cache `None` values (by default only `Some` is cached). +/// - `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 /// qualified, or imported from `cached` (`use cached::Return;`). A proc macro @@ -53,13 +55,13 @@ 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`), `size` + `ttl` (uses `LruTtlCache`), and `expires` (uses `ExpiringCache`/`ExpiringLruCache`). +/// `ttl` (uses `TtlCache`), `max_size` + `ttl` (uses `LruTtlCache`), and `expires` (uses `ExpiringCache`/`ExpiringLruCache`). /// A custom `ty` that implements `CloneCached` is also accepted. /// - `expires`: (optional, bool) Auto-select an expiry-aware store whose entries expire based on /// per-value logic rather than a single global TTL. -/// The return type (or its inner type when `result`/`option` is also set) must implement `Expires`. -/// Without `size`, uses `ExpiringCache` (unbounded). -/// With `size = N`, uses `ExpiringLruCache` (LRU-bounded to N entries). +/// 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, /// 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(...)`, @@ -89,9 +91,10 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { /// 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"` /// is not supported by `#[once]` because a `#[once]` cache stores a single value for all arguments. -/// - `result`: (optional, bool) If your function returns a `Result`, only cache `Ok` values returned by the function. -/// - `option`: (optional, bool) If your function returns an `Option`, only cache `Some` values returned by the function. -/// - `with_cached_flag`: (optional, bool) If your function returns a `cached::Return` or `Result`, +/// - `cache_err`: (optional, bool) If your function returns a `Result`, also cache `Err` values (by default only `Ok` is cached). +/// - `cache_none`: (optional, bool) If your function returns an `Option`, also cache `None` values (by default only `Some` is cached). +/// - `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 /// qualified, or imported from `cached` (`use cached::Return;`). A proc macro @@ -100,7 +103,7 @@ pub fn cached(args: TokenStream, input: TokenStream) -> TokenStream { /// compile in the generated body (it calls `::cached::Return::new` / /// `.was_cached`). Use a different name for any non-`cached` `Return` type. /// - `expires`: (optional, bool) Delegate expiry to the cached value instead of a fixed TTL. -/// The return type (or its inner type when `result`/`option` is also set) must implement `Expires`. +/// The return type must implement `Expires`; for `Result` or `Option` returns, the inner `T` must implement `Expires`. /// 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 @@ -112,11 +115,56 @@ 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) +/// `cached::ConcurrentCachedAsync` for async functions). +/// +/// By default (no `redis`, `disk`, `ty`, or `create` attributes) the macro selects a sharded in-memory +/// store based on the combination of `max_size`, `ttl`, and `expires`: +/// +/// | 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 | +/// +/// 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 +/// cached as-is. `Option` skips caching `None` by default; use `cache_none = true` +/// 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`). +/// +/// 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`) — +/// is treated as a plain return value and its `Err` variant will be cached. Return `Result` +/// directly when you need Ok-only caching behavior. +/// +/// **Note:** `on_evict` callbacks are not available via `#[concurrent_cached]`. To use an +/// eviction callback, construct the store manually with its builder (e.g. +/// `ShardedLruCache::builder().max_size(N).on_evict(|k, v| { ... }).build()`) and supply it via +/// `ty`/`create` (see the `ty` and `create` attributes below). +/// +/// **Note (async + method disambiguation):** When calling `ConcurrentCachedAsync` methods +/// (`.cache_get`, `.cache_set`, etc.) directly on an async sharded store, both +/// `ConcurrentCached` and `ConcurrentCachedAsync` are in scope and the compiler may report +/// "multiple applicable items in scope". Use fully-qualified syntax to disambiguate: +/// `ConcurrentCachedAsync::cache_get(&*STORE, &key).await`. +/// +/// **Clone requirement:** When no `key` or `convert` attribute is specified, function arguments +/// are cloned to form the cache key tuple, so all argument types must implement `Clone`. +/// Use `key` + `convert` to map to an explicit key type and avoid the clone if needed. /// /// # Attributes -/// - `map_error`: (string, expr closure) specify a closure used to map any IO-store errors into -/// the error type returned by your function. +/// - `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` +/// 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. /// - `redis`: (optional, bool) default to a `RedisCache` or `AsyncRedisCache` /// - `disk`: (optional, bool) use a `DiskCache`, this must be set to true even if `type` and `create` are specified. @@ -124,10 +172,34 @@ pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { /// `spawn_blocking` (so it does not stall the async runtime); this requires a Tokio /// runtime context and surfaces a `DiskCacheError::BackgroundTaskFailed` if that task is /// cancelled or panics. -/// - `ttl`: (optional, u64) specify a cache TTL in seconds, applied to the backing concurrent store -/// (e.g. the Redis key expiry, or the `DiskCache` entry TTL). `#[concurrent_cached]` uses a -/// Redis/disk/custom `ConcurrentCached` store, not a `TtlCache`/`LruTtlCache`. -/// - `refresh`: (optional, bool) specify whether to refresh the TTL on cache hits. +/// - `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 +/// `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`. +/// - `size`: **deprecated** alias for `max_size` — using it emits a deprecation warning. Prefer `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. +/// - `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. +/// 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` +/// without `ttl` is accepted but has no effect). On `redis`/`disk` paths `refresh` is forwarded +/// to the backend store builder. +/// - `expires`: (optional, bool) select a per-value expiry store. The cached value type must +/// implement the `Expires` trait. Without `max_size`, selects `ShardedExpiringCache` (unbounded); +/// with `max_size = N`, selects `ShardedExpiringLruCache` (LRU-bounded). Mutually exclusive with +/// `ttl`, `redis`, `disk`, `ty`, `create`, and `refresh`. May be combined with +/// `with_cached_flag`; in that case the inner `T` of `Return` (not `Return` itself) +/// must implement `Expires`. When the function returns `Option`, `None` is not cached +/// by default (implicit smart-option), and the inner `T` (not `Option`) +/// must implement `Expires`. When a cached entry is found but `is_expired()` returns `true`, +/// the function re-executes and the result is treated as a fresh uncached value; the returned +/// `Return` will have `was_cached = false` in this case. /// - `ty`: (optional, string type) explicitly specify the cache store type to use. /// - `cache_prefix_block`: (optional, string expr) specify an expression used to create the string used as a /// prefix for all cache keys of this function, e.g. `cache_prefix_block = r##"{ "my_prefix" }"##`. @@ -140,8 +212,36 @@ pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { /// - `convert`: (optional, string expr) specify an expression used to convert function arguments to a cache /// key, e.g. `convert = r##"{ format!("{}:{}", arg1, arg2) }"##`. When `convert` is specified, /// `key` or `ty` must also be set. -/// - `with_cached_flag`: (optional, bool) If your function returns a `cached::Return` or `Result`, -/// the `cached::Return.was_cached` flag will be updated when a cached value is returned. +/// - `cache_none`: (optional, bool) If your function returns an `Option`, also cache `None` values. +/// 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 (not `redis`/`disk`/custom `ty`). +/// **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 +/// 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 (not `redis`/`disk`/custom `ty`). +/// **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 +/// 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 +/// `Ok`). If there is no prior `Ok` for that key (e.g., the function has never succeeded or +/// 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` +/// 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. +/// Mutually exclusive with `cache_err`, `with_cached_flag`, `expires = true`, `redis = true`, +/// `disk = true`, and custom `ty`/`create`. +/// - `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 /// qualified, or imported from `cached` (`use cached::Return;`). A proc macro /// only sees tokens, not resolved types: an unrelated type that merely happens @@ -158,6 +258,9 @@ pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { /// The `ty`, `create`, `key`, and `convert` attributes must be in a `String` /// 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 +/// if you need to serialize concurrent first-call execution. #[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 4fc29079..44563adc 100644 --- a/cached_proc_macro/src/once.rs +++ b/cached_proc_macro/src/once.rs @@ -19,13 +19,18 @@ struct OnceMacroArgs { #[darling(default = "default_sync_writes_buckets")] sync_writes_buckets: usize, #[darling(default)] - result: bool, + cache_err: bool, #[darling(default)] - option: bool, + cache_none: bool, #[darling(default)] with_cached_flag: bool, #[darling(default)] expires: bool, + // Removed attributes intercepted to provide helpful error messages + #[darling(default)] + result: Option, + #[darling(default)] + option: Option, } fn default_sync_writes_buckets() -> usize { @@ -80,6 +85,28 @@ pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { .into(); } + if args.result.is_some() { + return syn::Error::new( + fn_ident.span(), + "the `result` attribute has been removed. `Result` returns now skip caching \ + `Err` by default. Remove `result = true` (or `result = false`), or use \ + `cache_err = true` to force-cache `Err` values.", + ) + .to_compile_error() + .into(); + } + + if args.option.is_some() { + return syn::Error::new( + fn_ident.span(), + "the `option` attribute has been removed. `Option` returns now skip caching \ + `None` by default. Remove `option = true` (or `option = false`), or use \ + `cache_none = true` to force-cache `None` values.", + ) + .to_compile_error() + .into(); + } + if args.expires && args.ttl.is_some() { return syn::Error::new( fn_ident.span(), @@ -118,7 +145,44 @@ pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { return with_cache_flag_error(output_span, output_type_display); } - let cache_value_ty = match find_value_type(args.result, args.option, &output, output_ty) { + let is_result_return = is_result_return_type(&output); + let is_option_return = is_option_return_type(&output); + + if args.cache_err && !is_result_return { + return syn::Error::new( + fn_ident.span(), + "`cache_err = true` requires the function to return `Result`", + ) + .to_compile_error() + .into(); + } + if args.cache_none && !is_option_return { + return syn::Error::new( + fn_ident.span(), + "`cache_none = true` requires the function to return `Option`", + ) + .to_compile_error() + .into(); + } + if args.cache_none && args.with_cached_flag { + return syn::Error::new( + 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 \ + 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).", + ) + .to_compile_error() + .into(); + } + + let is_smart_result = is_result_return && !args.cache_err; + let is_smart_option = is_option_return && !args.cache_none; + + let cache_value_ty = match find_value_type(is_smart_result, is_smart_option, &output, output_ty) + { Ok(value_ty) => value_ty, Err(e) => return e.to_compile_error().into(), }; @@ -152,7 +216,7 @@ pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { }; // make the set cache and return cache blocks - let (set_cache_block, return_cache_block) = match (&args.result, &args.option) { + 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() { quote! { @@ -221,14 +285,7 @@ pub fn once(args: TokenStream, input: TokenStream) -> TokenStream { gen_return_cache_block(args.ttl, args.expires, return_cache_block); (set_cache_block, return_cache_block) } - _ => { - return syn::Error::new( - fn_ident.span(), - "`result` and `option` attributes are mutually exclusive", - ) - .to_compile_error() - .into(); - } + (true, true) => unreachable!("return type cannot be both Result and Option"), }; let set_cache_and_return = quote! { diff --git a/docs/MIGRATION-1.0.md b/docs/migrations/0.x-to-1.0-human.md similarity index 95% rename from docs/MIGRATION-1.0.md rename to docs/migrations/0.x-to-1.0-human.md index 9f0fae0e..8d9f3a7e 100644 --- a/docs/MIGRATION-1.0.md +++ b/docs/migrations/0.x-to-1.0-human.md @@ -7,7 +7,7 @@ examples. If you want a terse, mechanical checklist optimized for automated find-and-replace (or for handing to an AI assistant), see -[`MIGRATION-1.0-AGENT.md`](./MIGRATION-1.0-AGENT.md). +[`0.x-to-1.0.md`](./0.x-to-1.0.md). > **TL;DR** — The biggest changes are: cache stores were renamed for clarity > (`SizedCache` → `LruCache`, `TimedCache` → `TtlCache`, …), the declarative @@ -591,9 +591,12 @@ or double-locking). Replacements: - **Memoized function over an in-memory store:** use `#[cached]` / `#[once]` (with `ty` + `create` for a custom store) — never went through the adapter anyway. -- **A shareable in-memory cache object behind the concurrent trait:** implement - `ConcurrentCached` directly on an `Arc>` - (~10 lines), or back the function with `RedisCache`/`DiskCache`. +- **A shareable in-memory cache object behind the concurrent trait:** use + `#[concurrent_cached]` — it now selects a built-in `ShardedCache` by default + (or `ShardedLruCache`, `ShardedTtlCache`, `ShardedLruTtlCache` with `size`/`ttl` + attributes). Alternatively, implement `ConcurrentCached` directly on an + `Arc>` (~10 lines), or back the function with + `RedisCache`/`DiskCache`. > Default Redis prefix note: the auto-generated Redis key prefix token changed > from `cached::macros::io_cached::` to @@ -611,13 +614,16 @@ or double-locking). Replacements: > wasn't. Fix: move the dropped settings into your `create` block, or remove > them. -> **`#[concurrent_cached]` return-type check is structural.** Non-`Result` -> returns (`Option`, `Vec`, bare `T`, …) now fail at attribute -> expansion with a clear spanned message instead of inside the generated body. -> A `Result` *type alias* renamed away from `Result` (e.g. -> `type MyResult = Result; -> MyResult`) is not recognized — the -> macro only sees tokens, the same limitation already documented for -> `with_cached_flag`/`Return`. Use a literal `Result<…, …>` return type. +> **`#[concurrent_cached]` return-type check is structural.** On the default +> in-memory sharded path, functions may return a plain `T`, `Option`, or +> `Result`; plain values and `Option` are cached as returned, while +> `Result` caches only `Ok` values. Redis, disk, and custom `ty`/`create` +> stores still require `Result`. Result detection is exact: only the +> bare identifier `Result` (including qualified paths such as +> `std::result::Result`) is matched. Type aliases are never resolved, so +> any alias — even one named `MyResult` or `ApiResult` — is treated as a +> plain value and may cache `Err` variants. The macro only sees tokens; use +> `Result` directly when you need Ok-only caching. --- diff --git a/docs/MIGRATION-1.0-AGENT.md b/docs/migrations/0.x-to-1.0.md similarity index 99% rename from docs/MIGRATION-1.0-AGENT.md rename to docs/migrations/0.x-to-1.0.md index 0952cc46..1354324b 100644 --- a/docs/MIGRATION-1.0-AGENT.md +++ b/docs/migrations/0.x-to-1.0.md @@ -3,7 +3,7 @@ Machine-oriented migration spec for an AI/automation agent. Apply the rules below to a codebase that depends on `cached < 1.0` to make it compile against `cached 1.0`. The human-readable companion is -[`MIGRATION-1.0.md`](./MIGRATION-1.0.md). +[`0.x-to-1.0-human.md`](./0.x-to-1.0-human.md). ## Execution model diff --git a/docs/migrations/1.0-to-1.1.md b/docs/migrations/1.0-to-1.1.md new file mode 100644 index 00000000..522360b1 --- /dev/null +++ b/docs/migrations/1.0-to-1.1.md @@ -0,0 +1,72 @@ +# cached 1.0 → 1.1 Migration (Agent Instructions) + +Versions: `cached 1.0` → `cached 1.1` / `cached_proc_macro 1.0` → `cached_proc_macro 1.1` + +## Breaking changes + +None. This release is purely additive. + +## Required Cargo.toml change + +```toml +# Before +cached = "1.0" + +# After +cached = "1.1" +``` + +If pinning the proc-macro crate directly: +```toml +cached_proc_macro = "1.1" +``` + +`cached_proc_macro_types` did not change; leave it at `"1.0"` if pinned. + +## New APIs (additive — no action required unless adopting) + +### `ExpiringCache` store + +New unbounded store where each value determines its own expiration via the +`Expires` trait. `ExpiringLruCache` (the LRU-bounded sibling) already +existed; `ExpiringCache` adds the unbounded variant. + +```rust +use cached::{ExpiringCache, Expires}; + +struct Session { expires_at: Instant } +impl Expires for Session { + fn is_expired(&self) -> bool { Instant::now() >= self.expires_at } +} + +let cache: ExpiringCache = ExpiringCache::new(); +``` + +### `expires = true` on `#[cached]` and `#[once]` + +New attribute selects the matching expiring store automatically: +- `#[cached(expires = true)]` — selects `ExpiringCache` (unbounded) or + `ExpiringLruCache` (when `size` is also set). +- `#[once(expires = true)]` — the single cached value controls its own TTL. + +Mutually exclusive with `ttl`, `ty`, `create`, `with_cached_flag`, `unsync_reads`, +`refresh`, and `unbound` on `#[cached]`. + +```rust +use cached::macros::cached; + +#[cached(expires = true)] +fn get_session(id: u64) -> Session { /* ... */ } + +#[cached(expires = true, size = 1000)] +fn get_bounded(id: u64) -> Session { /* ... */ } +``` + +### `TtlSortedCache` and `ExpiringCache` gained `Debug` and `Clone` + +If code previously special-cased the absence of these derives on those two +types, that guard can be removed. + +## VERIFY + +`cargo build` — no new compile errors expected from the version bump alone. diff --git a/docs/migrations/1.1-to-2.0-human.md b/docs/migrations/1.1-to-2.0-human.md new file mode 100644 index 00000000..a6a73f91 --- /dev/null +++ b/docs/migrations/1.1-to-2.0-human.md @@ -0,0 +1,250 @@ +# Upgrading from cached 1.1 to 2.0 + +cached 2.0 is a major release that bundles two sets of changes: a new set of sharded +concurrent stores and the `#[concurrent_cached]` macro rewrite (previously staged as 1.2), +plus a full unification of `Option`/`Result` implicit handling across all three macros. + +## Quick summary + +**Macro changes (most impactful):** +- Remove `result = true` and `option = true` everywhere — they are now the default +- `result_fallback = true` no longer needs `result = true` alongside it +- `#[concurrent_cached]` no longer needs `ty`/`create`/`map_error` for basic in-memory use + +**Store behavior changes (rare impact):** +- `cache_remove` on expiring stores returns `None` for expired-but-present entries +- `LruCache::retain` now fires `on_evict` for removed entries + +**Renames:** +- The size-bound builder setter `.size(n)` and the `with_size*` / `try_with_size*` constructors are renamed to `.max_size(n)` / `with_max_size*` / `try_with_max_size*`. The `#[cached(size = N)]` / `#[concurrent_cached(size = N)]` macro attribute is likewise renamed to `max_size = N` — the old `size = N` spelling still works but now emits a deprecation warning + +--- + +## Step-by-step upgrade + +### 1. Remove `result = true` and `option = true` + +These attributes are no longer accepted. The behavior they enabled is now the default. + +**Before:** +```rust +#[cached(result = true)] +fn load(id: u64) -> Result { ... } + +#[cached(option = true)] +fn find(id: u64) -> Option { ... } + +#[once(result = true)] +fn app_config() -> Result { ... } + +#[once(option = true, ttl = 60)] +fn feature_flag() -> Option { ... } + +#[cached(result = true, result_fallback = true, ttl = 60)] +fn fetch(id: u64) -> Result { ... } +``` + +**After:** +```rust +#[cached] +fn load(id: u64) -> Result { ... } + +#[cached] +fn find(id: u64) -> Option { ... } + +#[once] +fn app_config() -> Result { ... } + +#[once(ttl = 60)] +fn feature_flag() -> Option { ... } + +#[cached(result_fallback = true, ttl = 60)] +fn fetch(id: u64) -> Result { ... } +``` + +### 2. `#[concurrent_cached]` now accepts `Option` and plain return types + +In 1.x, `#[concurrent_cached]` only supported `Result` returns. In 2.0, `Option` returns +and plain `T: Clone` returns are supported on the default in-memory sharded path — `None` is not +cached by default. + +**Note:** `option = true` was never a recognized attribute on `#[concurrent_cached]` — it was +silently ignored by the macro parser in 1.x. There is nothing to migrate here. If you have +`option = true` on a `#[concurrent_cached]` annotation, remove it; it had no effect. + +**After (new, works in 2.0):** +```rust +#[concurrent_cached] +fn find(id: u64) -> Option { ... } // None is not cached by default +``` + +### 3. Opt-in to the old behavior (if needed) + +If you previously had a plain `#[cached]` or `#[once]` on a `Result` return and relied on `Err` being cached: +```rust +#[cached(cache_err = true)] +fn load(id: u64) -> Result { ... } + +#[once(cache_err = true)] +fn load_global() -> Result { ... } +``` + +If you previously had a plain `#[cached]` or `#[once]` on an `Option` return and relied on `None` being cached: +```rust +#[cached(cache_none = true)] +fn find(id: u64) -> Option { ... } + +#[once(cache_none = true)] +fn maybe_global() -> Option { ... } +``` + +### 4. Fix custom cache types that store `Option` or `Result` + +If your custom `ty` holds `Option` or `Result` as the value type, add the opt-in: + +```rust +// Before: +#[cached(ty = "TtlCache>", create = "{ ... }")] +fn fetch(key: String) -> Option { ... } + +// After: +#[cached(ty = "TtlCache>", create = "{ ... }", cache_none = true)] +fn fetch(key: String) -> Option { ... } +``` + +### 5. Update custom `Cached` / `ConcurrentCached` / `ConcurrentCachedAsync` implementations + +If you maintain a custom store that manually implements any of these traits, 2.0 adds a new **required method** — `cache_remove_entry` — that returns both the stored key and value when an entry is removed: + +- `Cached` gains `fn cache_remove_entry(&mut self, k: &Q) -> Option<(K, V)>` +- `ConcurrentCached` gains `fn cache_remove_entry(&self, k: &K) -> Result, Self::Error>` +- `ConcurrentCachedAsync` gains the async equivalent + +The default `cache_delete` method (new in 2.0) is implemented on top of `cache_remove_entry`, so you only need to add `cache_remove_entry` to your impl: + +```rust +impl Cached for MyStore { + // ... existing methods ... + + fn cache_remove_entry(&mut self, k: &Q) -> Option<(String, MyValue)> + where + String: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized, + { + self.inner.remove_entry(k) + // remove_entry returns the *stored* key, not a reconstruction from the lookup key + } +} +``` + +### 6. Rename `.size()` / `with_size*` to `.max_size()` / `with_max_size*` + +The size-bound builder setter and constructors were renamed to make clear they set the +**maximum** number of entries (the LRU/expiry bound), not a current size. This affects +`LruCache`, `LruTtlCache`, `ExpiringLruCache`, `TtlSortedCache`, and the sharded +`ShardedLruCache` / `ShardedLruTtlCache` / `ShardedExpiringLruCache`. + +```rust +// Before: +let c = LruCache::with_size(100); +let c = LruCache::builder().size(100).build(); +let c = LruTtlCache::with_size_and_ttl(100, ttl); + +// After: +let c = LruCache::with_max_size(100); +let c = LruCache::builder().max_size(100).build(); +let c = LruTtlCache::with_max_size_and_ttl(100, ttl); +``` + +The full set of renamed methods: `.size()` → `.max_size()`, `with_size` → `with_max_size`, +`try_with_size` → `try_with_max_size`, `with_size_and_ttl` → `with_max_size_and_ttl`, +`try_with_size_and_ttl` → `try_with_max_size_and_ttl`, `with_size_and_ttl_and_refresh` → +`with_max_size_and_ttl_and_refresh`, `with_size_and_shards` → `with_max_size_and_shards`, +`with_size_ttl_and_shards` → `with_max_size_and_ttl_and_shards`. + +The `#[cached(size = N)]` / `#[concurrent_cached(size = N)]` macro attribute is **also renamed +to `max_size = N`** (see "New in 2.0" below); the old `size = N` spelling keeps working but now +emits a deprecation warning. + +--- + +## New in 2.0 + +### `#[concurrent_cached]` defaults to in-memory sharded stores + +You no longer need `ty`/`create`/`map_error` for basic in-memory caching: + +```rust +#[concurrent_cached] // unbounded +#[concurrent_cached(max_size = 1000)] // LRU-bounded +#[concurrent_cached(ttl = 60)] // TTL-expiring (time_stores feature) +#[concurrent_cached(max_size = 500, ttl = 30)] // LRU + TTL +#[concurrent_cached(expires = true)] // per-value expiry (Expires trait) +``` + +### `result_fallback` on `#[concurrent_cached]` + +```rust +#[concurrent_cached(ttl = 60, result_fallback = true)] +fn load(id: u64) -> Result { + // On Err, last cached Ok for this key is returned instead +} +``` + +### New sharded stores available directly + +`ShardedCache`, `ShardedLruCache`, `ShardedTtlCache`, `ShardedLruTtlCache`, +`ShardedExpiringCache`, `ShardedExpiringLruCache` — all `Arc`-backed, `Send + Sync`, +with builder APIs, `on_evict` callbacks, and `metrics()`. + +### `capacity()` getter on the LRU-family stores + +`LruCache`, `LruTtlCache`, and `ExpiringLruCache` — and their sharded counterparts +`ShardedLruCache`, `ShardedLruTtlCache`, and `ShardedExpiringLruCache` — expose an inherent +`capacity()` returning the configured max-entry bound — distinct from `cache_size()`, which +returns the current live entry count. + +### `max_size = N` macro attribute (replaces `size = N`) + +`#[cached]` and `#[concurrent_cached]` now prefer `max_size = N`, matching the renamed `max_size` +builder methods. The old `size = N` spelling is a **deprecated alias** — it still compiles but +emits a deprecation warning steering you to `max_size`. Setting both on the same annotation is a +compile error. To migrate, rename the attribute: + +```rust +#[cached(max_size = 100)] +fn f(x: u64) -> u64 { x * 2 } + +#[concurrent_cached(max_size = 100)] +fn g(x: u64) -> u64 { x * 2 } +``` + +--- + +## Store behavior changes + +### `cache_remove` on expiring stores + +`ExpiringCache`, `ExpiringLruCache`, and sharded expiring stores now return `None` when removing +an entry that is present but already expired (previously returned `Some(value)`). The entry is +still removed; `on_evict` still fires. This is the same behavior as the non-expiring TTL stores. + +**Impact on `cache_delete`:** `cache_delete` now returns `true` whenever an entry is +physically removed, including expired entries. If you need to distinguish a live removal +from an expired one, use `cache_remove`; if you need to distinguish an expired-but-present +entry from an absent key, use `cache_remove_entry`. + +### `LruCache::retain` fires `on_evict` + +Each entry removed by `retain` now fires `on_evict` (if registered) and increments +`cache_evictions()`. Previously `retain` was side-effect-free. + +--- + +## Cargo.toml + +```toml +cached = "2.0" +# if pinning the proc-macro directly: +# cached_proc_macro = "2.0" +``` diff --git a/docs/migrations/1.1-to-2.0.md b/docs/migrations/1.1-to-2.0.md new file mode 100644 index 00000000..d77aa7d1 --- /dev/null +++ b/docs/migrations/1.1-to-2.0.md @@ -0,0 +1,200 @@ +# cached 1.1 → 2.0 Migration (Agent Instructions) + +Versions: `cached 1.1` → `cached 2.0` / `cached_proc_macro 1.1` → `cached_proc_macro 2.0` + +## Breaking changes + +--- + +### 1. `result = true` removed from `#[cached]` and `#[once]` + +**Old behavior:** `result = true` was required to skip caching `Err`. +**New behavior:** Any `Result` return type automatically skips caching `Err`. + +**Detection:** search for `result = true` on `#[cached]` and `#[once]` annotations. + +**Action (most users):** remove `result = true` entirely. The behavior is now the default. + +**Possible regression:** if a `#[cached]` or `#[once]` function returns `Result` and previously relied on caching `Err` values (no `result = true`), add `cache_err = true` to preserve that behavior. + +--- + +### 2. `option = true` removed from `#[cached]` and `#[once]` + +**Old behavior:** `option = true` was required to skip caching `None`. +**New behavior:** Any `Option` return type automatically skips caching `None`. + +**Detection:** search for `option = true` on `#[cached]` and `#[once]` annotations. + +**Action (most users):** remove `option = true` entirely. + +**Possible regression:** if a `#[cached]` or `#[once]` function returns `Option` and previously relied on caching `None` values (no `option = true`), add `cache_none = true` to preserve that behavior. + +--- + +### 3. `result_fallback = true` no longer requires `result = true` + +**Detection:** search for `result = true` on annotations that also have `result_fallback = true`. + +**Action:** remove `result = true`; keep `result_fallback = true`. + +--- + +### 4. Custom-`ty` users storing `Option` or `Result` directly + +**Detection:** search for `ty = "..."` annotations where the store value type is `Option<…>` or `Result<…>`. + +**Action:** add `cache_none = true` (for `Option` stores) or `cache_err = true` (for `Result` stores) to prevent the macro from extracting the inner type and mismatching the declared store type. + +--- + +### 5. `cache_remove` on expiring stores now returns `None` for expired-but-present entries + +**Affected stores:** `ExpiringCache`, `ExpiringLruCache`, `ShardedExpiringCache`, `ShardedExpiringLruCache`. + +**Old behavior:** `cache_remove(&k)` on an entry that was present but already expired returned `Some(value)`. +**New behavior:** returns `None`. The entry is still removed and `on_evict` still fires. + +**Detection:** search for `.cache_remove(` / `.remove(` / `.cache_delete(` / `.delete(` calls on an expiring store. + +**Action:** if the code relied on `Some(value)` for expired-but-present entries, add an explicit `is_expired()` check before removal, or ignore the return value. `cache_delete` / `delete` now return `true` whenever an entry is physically removed, including expired entries. If you need to distinguish a live removal from an expired one, use `cache_remove`; if you need to distinguish an expired-but-present entry from an absent key, use `cache_remove_entry`. + +--- + +### 6. `LruCache::retain` now fires `on_evict` and increments `cache_evictions()` + +**Old behavior:** `retain` was side-effect-free. +**New behavior:** each entry removed by `retain` fires `on_evict` (if set) and increments `cache_evictions()`. + +**Detection:** search for `.retain(` on an `LruCache` instance that has an `on_evict` callback. + +**Action:** if `retain` was used because it was side-effect-free, collect keys first and use a manual removal loop instead. + +--- + +### 7. All non-unbound stores now fire `on_evict` on explicit `cache_remove` + +**Old behavior:** only `UnboundCache` fired `on_evict` on explicit removes. +**New behavior:** every in-memory store fires `on_evict` on `cache_remove`. + +**Detection:** search for `.cache_remove(` / `.remove(` / `.delete(` on stores with an `on_evict` callback (excluding `UnboundCache`). + +**Action:** verify the `on_evict` callback is safe to call for explicitly removed entries. + +--- + +### 8. `BuildError::InvalidTtl { ttl }` — new variant + +All TTL-capable builders now return this variant for a zero TTL. + +**Detection:** exhaustive `match` on `BuildError`. + +**Action:** add an arm for `InvalidTtl { .. }`. + +--- + +### 9. Size-bound builder/constructor renames: `size` → `max_size` + +The size-bound builder setter and constructors are renamed to clarify they set the **maximum** entry count. Affects `LruCache`, `LruTtlCache`, `ExpiringLruCache`, `TtlSortedCache`, `ShardedLruCache`, `ShardedLruTtlCache`, `ShardedExpiringLruCache`. + +| Old | New | +| --- | --- | +| `.size(n)` (builder) | `.max_size(n)` | +| `with_size(n)` | `with_max_size(n)` | +| `try_with_size(n)` | `try_with_max_size(n)` | +| `with_size_and_ttl(n, ttl)` | `with_max_size_and_ttl(n, ttl)` | +| `try_with_size_and_ttl(n, ttl)` | `try_with_max_size_and_ttl(n, ttl)` | +| `with_size_and_ttl_and_refresh(n, ttl, r)` | `with_max_size_and_ttl_and_refresh(n, ttl, r)` | +| `with_size_and_shards(n, s)` | `with_max_size_and_shards(n, s)` | +| `with_size_ttl_and_shards(n, ttl, s)` | `with_max_size_and_ttl_and_shards(n, ttl, s)` | + +**Detection:** search for `.size(`, `with_size`, `try_with_size` on cache types and builders. + +**Action:** mechanical rename per the table. The `#[cached(size = N)]` / `#[concurrent_cached(size = N)]` macro attribute is **also renamed to `max_size = N`** — the old `size = N` spelling still compiles but now emits a deprecation warning (anchored at the `size` token). Rename macro-attribute `size = N` → `max_size = N` too; required if you build with `-D warnings` / `#![deny(deprecated)]`. + +--- + +## New APIs (additive — no action required unless adopting) + +### Macro attribute changes + +- `max_size = N` — the preferred LRU-bound attribute on `#[cached]` / `#[concurrent_cached]`, mirroring the renamed `max_size` builder methods. The old `size = N` spelling is now a **deprecated alias** (emits a deprecation warning at the `size` token); setting both on one annotation is a compile error. +- `cache_err = true` — opt-in to cache `Err` values (requires `Result`; mutually exclusive with `result_fallback`). +- `cache_none = true` — opt-in to cache `None` values (requires `Option`). +- `result_fallback = true` on `#[concurrent_cached]` — fallback to the last cached `Ok` on `Err`. Restricted to the default in-memory sharded path; **requires `ttl`** (compile error otherwise). The stale value is held in the primary cache slot and re-cached with a fresh TTL window on `Err`; no separate `_FALLBACK` store is created. + +### `#[concurrent_cached]` now defaults to an in-memory sharded store + +Before 2.0, `#[concurrent_cached]` required `ty`, `create`, and `map_error` for any in-memory use. +Starting in 2.0, omitting those selects a sharded store automatically: + +```rust +#[concurrent_cached] // → ShardedCache +#[concurrent_cached(max_size = 1000)] // → ShardedLruCache +#[concurrent_cached(ttl = 60)] // → ShardedTtlCache (time_stores) +#[concurrent_cached(max_size = 500, ttl = 30)] // → ShardedLruTtlCache (time_stores) +#[concurrent_cached(expires = true)] // → ShardedExpiringCache +#[concurrent_cached(expires = true, max_size=500)] // → ShardedExpiringLruCache +``` + +### Six new sharded concurrent in-memory stores + +``` +ShardedCache — unbounded, no TTL +ShardedLruCache — LRU-bounded +ShardedTtlCache — unbounded, global TTL (time_stores) +ShardedLruTtlCache — LRU + global TTL (time_stores) +ShardedExpiringCache — unbounded, per-value expiry +ShardedExpiringLruCache — LRU, per-value expiry +``` + +### `ConcurrentCached::get` / `set` / `remove` / `delete` short aliases + +Delegate to `cache_*` methods. Existing call sites need no change. + +### `capacity()` getter on the LRU-family stores + +Returns the configured max-entry bound (the value set via `max_size`), distinct from `cache_size()` (current live entry count). Available on `LruCache`, `LruTtlCache`, and `ExpiringLruCache`, as well as their sharded counterparts `ShardedLruCache`, `ShardedLruTtlCache`, and `ShardedExpiringLruCache`. + +--- + +## Required Cargo.toml change + +```toml +# Before +cached = "1.1" + +# After +cached = "2.0" +``` + +If pinning the proc-macro crate directly: +```toml +cached_proc_macro = "2.0" +``` + +`cached_proc_macro_types` did not change; leave it at `"1.0"` if pinned. + +## VERIFY + +``` +cargo build +cargo test +``` + +Expected new compile errors and their fixes: +- `Unknown field: result` / `Unknown field: option` on `#[cached]` / `#[once]` — remove those attributes. +- `E0004` on exhaustive `match BuildError` — add `InvalidTtl { .. }` arm. +- `E0004` on exhaustive `match DiskCacheBuildError` — add `InvalidTtl(..)` arm (a new `InvalidTtl(BuildError)` variant was added to `DiskCacheBuildError`). +- `E0004` on exhaustive `match RedisCacheBuildError` — add `InvalidTtl(..)` arm (a new `InvalidTtl(BuildError)` variant was added to `RedisCacheBuildError`). +- Type mismatch on a custom `ty` holding `Option` or `Result` — add `cache_none = true` / `cache_err = true`. +- `cache_none = true` alongside `with_cached_flag = true` (on `#[cached]`, `#[once]`, or `#[concurrent_cached]`) — now a compile error. If you mechanically replaced `option = true` with `cache_none = true` on an annotation that also had `with_cached_flag = true`, remove one of the two: keep `with_cached_flag = true` alone (`None` is not cached by default) or `cache_none = true` alone. +- `the trait bound K: Clone is not satisfied` on `DiskCache` / `RedisCache` / `AsyncRedisCache` — the `ConcurrentCached` / `ConcurrentCachedAsync` impls for these stores now require `K: Clone` (needed by `cache_remove_entry` to return the stored key). Add `#[derive(Clone)]` or implement `Clone` for the key type. +- `no method named size` / `no function ... named with_size` on an LRU-family cache or builder — rename to `max_size` / `with_max_size` per the table in breaking change #9. +- `use of deprecated constant cached::__DEPRECATED_SIZE_ATTR` (only under `-D warnings` / `#![deny(deprecated)]`) — a `#[cached(size = N)]` / `#[concurrent_cached(size = N)]` macro attribute. Rename the attribute `size = N` → `max_size = N`. + +Behavioral regressions to watch in tests: +- Tests asserting `cache_remove` returns `Some(v)` on an expired expiring-store entry — update to expect `None`. +- Tests asserting `on_evict` is NOT called after `retain` on `LruCache` — update to expect the callback to fire. +- Tests asserting `on_evict` is NOT called after explicit `cache_remove` on a non-`UnboundCache` — update to expect the callback. +- Tests asserting that `None` / `Err` returns from `#[cached]` functions are cached — add `cache_none = true` / `cache_err = true`. diff --git a/examples/async_std.rs b/examples/async_std.rs index c2ac1bc8..565c73b0 100644 --- a/examples/async_std.rs +++ b/examples/async_std.rs @@ -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 }"#, result = true)] +#[cached(ttl = 1, key = "bool", convert = r#"{ true }"#)] async fn only_cached_a_second( s: String, ) -> std::result::Result, &'static dyn std::error::Error> { @@ -33,7 +33,7 @@ async fn only_cached_a_second( /// should only cache the _first_ `Ok` returned. /// all arguments are ignored for subsequent calls. -#[once(result = true)] +#[once] async fn only_cached_result_once(s: String, error: bool) -> std::result::Result, u32> { if error { Err(1) @@ -45,7 +45,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(result = true, ttl = 1)] +#[once(ttl = 1)] async fn only_cached_result_once_per_second( s: String, error: bool, @@ -59,7 +59,7 @@ async fn only_cached_result_once_per_second( /// should only cache the _first_ `Some` returned . /// all arguments are ignored for subsequent calls -#[once(option = true)] +#[once] async fn only_cached_option_once(s: String, none: bool) -> Option> { if none { None @@ -71,7 +71,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(option = true, ttl = 1)] +#[once(ttl = 1)] async fn only_cached_option_once_per_second(s: String, none: bool) -> Option> { if none { None diff --git a/examples/basic.rs b/examples/basic.rs index d6fb986f..be55df14 100644 --- a/examples/basic.rs +++ b/examples/basic.rs @@ -1,5 +1,5 @@ /* -The basics: `#[cached(size = N)]` (LRU memoization) and `#[once(ttl = N)]` +The basics: `#[cached(max_size = N)]` (LRU memoization) and `#[once(ttl = N)]` (a single cached value that expires), plus reading the generated cache static through the `Cached` trait. @@ -12,7 +12,7 @@ use cached::macros::once; use cached::time::{Duration, Instant}; use std::thread::sleep; -#[cached(size = 50)] +#[cached(max_size = 50)] fn slow_fn(n: u32) -> String { if n == 0 { return "done".to_string(); diff --git a/examples/expires_per_key.rs b/examples/expires_per_key.rs index 6a74d429..fa365f0f 100644 --- a/examples/expires_per_key.rs +++ b/examples/expires_per_key.rs @@ -72,7 +72,7 @@ fn main() { // 1. ExpiringLruCache (Size-bounded cache with per-value expiration) // ========================================================================= println!("--- 1. ExpiringLruCache (Size-bounded) ---"); - let mut lru_cache = ExpiringLruCache::with_size(10); + let mut lru_cache = ExpiringLruCache::with_max_size(10); let quick_expiry = MyValue { data: "Short-lived LRU response".to_string(), diff --git a/examples/kitchen_sink.rs b/examples/kitchen_sink.rs index 83b916c0..b92b34d1 100644 --- a/examples/kitchen_sink.rs +++ b/examples/kitchen_sink.rs @@ -1,6 +1,6 @@ /* "Kitchen sink": the default `UnboundCache`, an explicit `ty` + `create` store, -and `#[cached(size = N)]` (LRU), with direct `Cached`-trait access to the +and `#[cached(max_size = N)]` (LRU), with direct `Cached`-trait access to the generated cache statics. Run: @@ -40,7 +40,7 @@ fn fib_specific(n: u32) -> u32 { // Specify a specific cache type // Note that the cache key type is a tuple of function argument types. -#[cached(size = 100)] +#[cached(max_size = 100)] fn slow(a: u32, b: u32) -> u32 { sleep(Duration::new(2, 0)); a * b @@ -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::with_size(100) }", + create = "{ LruCache::with_max_size(100) }", convert = r#"{ format!("{a}{b}") }"# )] fn keyed(a: &str, b: &str) -> usize { @@ -113,6 +113,13 @@ impl Cached for MyCache { { self.store.remove(k) } + fn cache_remove_entry(&mut self, k: &Q) -> Option<(K, V)> + where + K: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized, + { + self.store.remove_entry(k) + } fn cache_clear(&mut self) { self.store.clear(); } @@ -142,12 +149,12 @@ fn expires(a: i32) -> i32 { a } -#[cached(ttl = 1, result = true)] +#[cached(ttl = 1)] fn expires_result(a: i32) -> Result { Ok(a) } -#[cached(ttl = 1, option = true)] +#[cached(ttl = 1)] fn expires_option(a: i32) -> Option { Some(a) } diff --git a/examples/sharded.rs b/examples/sharded.rs new file mode 100644 index 00000000..a8b808be --- /dev/null +++ b/examples/sharded.rs @@ -0,0 +1,171 @@ +/* +In-memory concurrent memoization with zero boilerplate. + +`#[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: + + (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) + +For per-value expiry (`expires = true`), see `examples/sharded_expiring.rs`. + +All four are fully concurrent: multiple threads can share the same cache and +call get/set concurrently without any external locking. + +Return types: + - Plain `T`: the return value is always cached. + - `Option`: only `Some` is cached; `None` is returned without being stored + (use `cache_none = true` to also cache `None`). + - `Result`: only `Ok` values are cached; `Err` is returned without + being stored, so the function will be retried on the next call. + +Run: + cargo run --example sharded --features "time_stores,proc_macro" +*/ + +use cached::macros::concurrent_cached; +use cached::{ShardedCache, ShardedLruCache}; +use std::thread; + +// Bare default: ShardedCache (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 +// 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)] +fn compute_lru(x: u64) -> u64 { + x * x +} + +// TTL: ShardedTtlCache (expires after 60 s) +#[concurrent_cached(ttl = 60)] +fn compute_ttl(x: u64) -> u64 { + x * x +} + +// LRU + TTL: ShardedLruTtlCache +#[concurrent_cached(max_size = 64, ttl = 30)] +fn compute_lru_ttl(x: u64) -> u64 { + x * x +} + +// Explicit shard count (overrides the default of cpu-count × 4, rounded up) +#[concurrent_cached(shards = 32)] +fn compute_shards(x: u64) -> u64 { + x * x +} + +// 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 +// cached, so find_record(0) will re-execute on every call. +#[concurrent_cached] +fn find_record(id: u64) -> Option { + if id == 0 { + None + } else { + Some(format!("record_{id}")) + } +} + +fn main() { + // Basic memoization check + let v1 = compute(7); + let v2 = compute(7); + assert_eq!(v1, v2); + assert_eq!(v1, 49); + println!("compute(7) = {v1} (both calls agree)"); + + // 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"); + println!("load_record(42) = {:?} (cached)", r1); + + // Option: None is NOT cached by default; the function re-executes each time + assert_eq!(find_record(0), None); + assert_eq!(find_record(0), None); // re-executes, not a cache hit + assert_eq!(find_record(1), Some("record_1".to_string())); + println!( + "find_record(0) = None (not cached), find_record(1) = {:?}", + find_record(1) + ); + + // Exercise the other cached variants to confirm they work + let v = compute_lru(7); + assert_eq!(v, 49); + println!("compute_lru(7) = {v}"); + let v = compute_ttl(7); + assert_eq!(v, 49); + println!("compute_ttl(7) = {v}"); + let v = compute_lru_ttl(7); + assert_eq!(v, 49); + println!("compute_lru_ttl(7) = {v}"); + let v = compute_shards(7); + assert_eq!(v, 49); + println!("compute_shards(7) = {v}"); + + // Demonstrate that concurrent callers share the same cache + let handles: Vec<_> = (0..8) + .map(|_| { + thread::spawn(|| { + for i in 0u64..100 { + let _ = compute(i % 10); + } + }) + }) + .collect(); + for h in handles { + h.join().expect("thread panicked"); + } + + // Inspect the cache directly. Direct method-call syntax on a sharded store resolves + // to the inherent sync helper, not the ConcurrentCached/ConcurrentCachedAsync trait + // method. For the async trait, use UFCS: `ConcurrentCachedAsync::cache_get(&*COMPUTE, &7)`. + { + let val = COMPUTE.cache_get(&7).expect("infallible"); + assert_eq!(val, Some(49)); + println!("cache_get(7) = {val:?}"); + } + + // Build a ShardedCache manually and use it without a macro + let cache: ShardedCache = ShardedCache::new(); + 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") + ); + + // ShardedLruCache with explicit shard count + let lru: ShardedLruCache = ShardedLruCache::builder() + .max_size(256) + .shards(8) + .try_build() + .expect("valid config"); + for i in 0..256u32 { + lru.cache_set(i, i * 2).expect("infallible"); + } + println!("ShardedLruCache len = {}", lru.len()); + println!("ShardedLruCache shard_sizes = {:?}", lru.shard_sizes()); + + println!("\ndone!"); +} diff --git a/examples/sharded_expiring.rs b/examples/sharded_expiring.rs new file mode 100644 index 00000000..8e45f020 --- /dev/null +++ b/examples/sharded_expiring.rs @@ -0,0 +1,143 @@ +/* +In-memory concurrent expiring memoization with zero boilerplate. + +When using the `#[concurrent_cached]` procedural macro, the `expires = true` attribute +selects a sharded in-memory expiring store: +- `expires = true` → ShardedExpiringCache (unbounded, expiring) +- `expires = true, max_size = N` → ShardedExpiringLruCache (LRU-bounded, expiring) + +Expired entries are checked on lookup and evicted on access or during explicit sweeps. +These stores wrap an `Arc` (Send + Sync) and are fully concurrent. + +Run: + cargo run --example sharded_expiring --features "proc_macro" +*/ + +use cached::macros::concurrent_cached; +use cached::{Expires, ShardedExpiringCache, ShardedExpiringLruCache}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread; + +// Values stored in expiring caches must implement the `Expires` trait. +#[derive(Clone, Debug)] +struct Session { + user_id: u32, + expired: Arc, +} + +impl Expires for Session { + fn is_expired(&self) -> bool { + self.expired.load(Ordering::Relaxed) + } +} + +// Unbounded expiring sharded cache +#[concurrent_cached(expires = true, key = "u32", convert = r#"{ user_id }"#)] +fn get_session(user_id: u32, expired_flag: Arc) -> Session { + Session { + user_id, + expired: expired_flag, + } +} + +// LRU-bounded expiring sharded cache +#[concurrent_cached( + expires = true, + max_size = 128, + key = "u32", + convert = r#"{ user_id }"# +)] +fn get_session_bounded(user_id: u32, expired_flag: Arc) -> Session { + Session { + user_id, + expired: expired_flag, + } +} + +fn main() { + println!("--- Sharded Expiring Cache Example ---"); + + // The AtomicBool models `is_expired`: false = live, true = expired. + let expiry = Arc::new(AtomicBool::new(false)); // starts live + let already_expired = Arc::new(AtomicBool::new(true)); // starts expired + + // 1. Unbounded expiring cache lookup + let s1 = get_session(1, expiry.clone()); + let s2 = get_session(1, expiry.clone()); + println!("Call 1 (live session): {:?}", s1); + println!("Call 2 (live session cached): {:?}", s2); + + // Mark the cached entry as expired; next call will re-execute the function. + expiry.store(true, Ordering::Relaxed); + let s3 = get_session(1, expiry.clone()); + println!( + "Call 3 (after flag changed to expired -> recalculated): {:?}", + s3 + ); + + // 2. Bounded expiring LRU cache + let bounded_flag = Arc::new(AtomicBool::new(false)); + let b1 = get_session_bounded(10, bounded_flag.clone()); + let b2 = get_session_bounded(10, bounded_flag.clone()); + println!("LRU call 1 (live): {:?}", b1); + println!("LRU call 2 (live): {:?}", b2); + + // 3. Multi-threaded concurrent usage + let live_shared = Arc::new(AtomicBool::new(false)); + let handles: Vec<_> = (0..8) + .map(|id| { + let flag = live_shared.clone(); + thread::spawn(move || { + for i in 0..50 { + let _ = get_session(i % 5, flag.clone()); + } + println!("Thread {id} finished lookups."); + }) + }) + .collect(); + + for h in handles { + h.join().expect("thread panicked"); + } + + // 4. Direct Manual construction and usage (without macro) + println!("\n--- Manual Store Construction ---"); + let cache: ShardedExpiringCache = ShardedExpiringCache::new(); + let s_manual = Session { + user_id: 100, + expired: already_expired.clone(), // starts expired + }; + cached::ConcurrentCached::cache_set(&cache, 100, s_manual).expect("infallible"); + + let val = cached::ConcurrentCached::cache_get(&cache, &100).expect("infallible"); + assert!( + val.is_none(), + "Expired manual entry should be filtered out on get" + ); + println!( + "Manual ShardedExpiringCache lookup for expired entry: {:?}", + val + ); + + let lru: ShardedExpiringLruCache = ShardedExpiringLruCache::builder() + .max_size(64) + .shards(4) + .try_build() + .expect("valid config"); + + let live_manual = Session { + user_id: 200, + 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"); + assert!(val_lru.is_some(), "Live manual entry should be present"); + println!( + "Manual ShardedExpiringLruCache lookup for live entry: {:?}", + val_lru + ); + + println!("Example complete."); +} diff --git a/examples/tokio.rs b/examples/tokio.rs index 267304be..64a2db32 100644 --- a/examples/tokio.rs +++ b/examples/tokio.rs @@ -1,6 +1,6 @@ /* Async memoization on the tokio runtime: `#[cached]` on `async fn`s, including -`result = true, with_cached_flag = true` returning `cached::Return`. +`with_cached_flag = true` returning `cached::Return` for a fallible function. Run: cargo run --example tokio --features "async_tokio_rt_multi_thread,proc_macro" @@ -19,7 +19,7 @@ async fn cached_sleep_secs(secs: u64) { sleep(Duration::from_secs(secs)).await; } -#[cached(result = true, with_cached_flag = true)] +#[cached(with_cached_flag = true)] async fn cached_was_cached(count: u32) -> Result, ()> { Ok(cached::Return::new( (0..count).map(|_| "a").collect::>().join(""), diff --git a/examples/wasm/src/main.rs b/examples/wasm/src/main.rs index 412a0cae..e52f9049 100644 --- a/examples/wasm/src/main.rs +++ b/examples/wasm/src/main.rs @@ -57,7 +57,8 @@ fn app() -> Html { #[cached( ty = "TtlCache>", - create = "{ TtlCache::with_ttl(cached::time::Duration::from_secs(5)) }" + create = "{ TtlCache::with_ttl(cached::time::Duration::from_secs(5)) }", + cache_none = true )] async fn fetch(body: String) -> Option { Request::post(URL) diff --git a/src/lib.rs b/src/lib.rs index c7972641..7074a5bb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,15 +2,16 @@ [![Build Status](https://github.com/jaemk/cached/actions/workflows/build.yml/badge.svg)](https://github.com/jaemk/cached/actions/workflows/build.yml) [![crates.io](https://img.shields.io/crates/v/cached.svg)](https://crates.io/crates/cached) [![docs](https://docs.rs/cached/badge.svg)](https://docs.rs/cached) -[![CodSpeed Badge](https://img.shields.io/endpoint?url=https://codspeed.io/badge.json)](https://codspeed.io/jaemk/cached?utm_source=badge) > Caching structures and simplified function memoization `cached` provides implementations of several caching structures as well as macros for defining memoized functions. -Memoized functions defined using `#[cached]`/`#[once]`/`#[concurrent_cached]` macros are thread-safe with the backing -function-cache wrapped in a mutex/rwlock, or externally synchronized in the case of `#[concurrent_cached]`. +Memoized functions defined using `#[cached]`/`#[once]` macros are thread-safe with the backing +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 @@ -21,12 +22,18 @@ of un-cached arguments, specify `#[cached(sync_writes = true)]` / `#[once(sync_w - 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 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 +> [2.0 migration guide](https://github.com/jaemk/cached/blob/master/docs/migrations/1.1-to-2.0-human.md) +> for a step-by-step walkthrough. +> > **Upgrading from a pre-1.0 release?** 1.0 contains breaking changes (store > renames, removed declarative macros, renamed macro/builder attributes, and a > changed Redis key format). See the -> [1.0 migration guide](https://github.com/jaemk/cached/blob/master/docs/MIGRATION-1.0.md) +> [1.0 migration guide](https://github.com/jaemk/cached/blob/master/docs/migrations/0.x-to-1.0-human.md) > for a step-by-step walkthrough, or the -> [agent-oriented guide](https://github.com/jaemk/cached/blob/master/docs/MIGRATION-1.0-AGENT.md) +> [agent-oriented guide](https://github.com/jaemk/cached/blob/master/docs/migrations/0.x-to-1.0.md) > for automated migration tooling. **Features** @@ -49,7 +56,8 @@ of un-cached arguments, specify `#[cached(sync_writes = true)]` / `#[once(sync_w - `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`) -- `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), and [`TtlSortedCache`](https://docs.rs/cached/latest/cached/struct.TtlSortedCache.html)). +- `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. 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. @@ -61,38 +69,128 @@ help output stays in sync with supported Makefile targets. Any custom cache that implements `cached::Cached`/`cached::CachedAsync` can be used with the `#[cached]`/`#[once]` macros in place of the built-ins. Any custom cache that implements `cached::ConcurrentCached`/`cached::ConcurrentCachedAsync` can be used with the `#[concurrent_cached]` macro. -**Store comparison** +**Macro quick reference** + +| Use case | Annotated signature | +|---|---| +| **`#[cached]`** | | +| Unbounded memoize (default) | `#[cached] 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` | +| 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` | +| Per-value expiry (value carries its own TTL) | `#[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` | +| 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` | +| Optional global — skip caching if `None` (implicit) | `#[once] fn feature_flag() -> Option` | +| **`#[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` | +| 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` | + +On `#[cached]` and `#[concurrent_cached]`, `max_size = N` is accepted as an alias for `size = N` (mirroring the `max_size` builder/constructor methods on the stores). Either spelling works; setting both on one annotation is a compile error. + +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`. -| Store | Eviction policy | Size limit | TTL | Refresh on hit | `on_evict` | Async | -|---|---|---|---|---|---|---| -| [`UnboundCache`](https://docs.rs/cached/latest/cached/struct.UnboundCache.html) | None (unbounded) | No | No | N/A | On explicit remove | Yes | -| [`LruCache`](https://docs.rs/cached/latest/cached/struct.LruCache.html) | LRU | Yes | No | N/A | Yes | Yes | -| [`TtlCache`](https://docs.rs/cached/latest/cached/struct.TtlCache.html) | TTL (insert time) | No | Global | Optional | Yes | Yes | -| [`LruTtlCache`](https://docs.rs/cached/latest/cached/struct.LruTtlCache.html) | LRU + TTL | Yes | Global | Optional | Yes | Yes | -| [`TtlSortedCache`](https://docs.rs/cached/latest/cached/struct.TtlSortedCache.html) | TTL (expiry-ordered) | Optional | Global | No | Yes | Yes | -| [`ExpiringLruCache`](https://docs.rs/cached/latest/cached/struct.ExpiringLruCache.html) | LRU + value-defined | Yes | Per-value | N/A | Yes | Yes | -| [`ExpiringCache`](https://docs.rs/cached/latest/cached/struct.ExpiringCache.html) | Value-defined | No | Per-value | N/A | Yes | Yes | +**Store comparison** -`TtlCache`/`LruTtlCache`/`TtlSortedCache` require the `time_stores` feature. +| Store | Eviction policy | Size limit | TTL | Refresh on hit | `on_evict` | Concurrent | Async | +|---|---|---|---|---|---|---|---| +| [`UnboundCache`](https://docs.rs/cached/latest/cached/struct.UnboundCache.html) | None (unbounded) | No | No | N/A | On explicit remove | No | Yes | +| [`LruCache`](https://docs.rs/cached/latest/cached/struct.LruCache.html) | LRU | Yes | No | N/A | Yes | No | Yes | +| [`TtlCache`](https://docs.rs/cached/latest/cached/struct.TtlCache.html) | TTL (insert time) | No | Global | Optional | Yes | No | Yes | +| [`LruTtlCache`](https://docs.rs/cached/latest/cached/struct.LruTtlCache.html) | LRU + TTL | Yes | Global | Optional | Yes | No | Yes | +| [`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 | +| [`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 | +| [`ShardedExpiringCache`](https://docs.rs/cached/latest/cached/type.ShardedExpiringCache.html) | Value-defined | No | Per-value | N/A | Yes | Yes (`Arc`) | Yes | +| [`ShardedExpiringLruCache`](https://docs.rs/cached/latest/cached/type.ShardedExpiringLruCache.html) | LRU + value-defined | Yes | Per-value | N/A | Yes | Yes (`Arc`) | Yes | + +> "On explicit remove" — `on_evict` fires only on `cache_remove`; there is no capacity eviction or TTL expiry trigger for these stores. +> † `ShardedLruTtlCacheBuilder::on_evict` requires `K: 'static + V: 'static`; see the builder docs for details. + +`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]`. +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. + +> **`*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. **Behavioral guarantees** -- In-memory cache stores are not internally synchronized. Macro-defined functions wrap their - backing stores in generated locks; users managing stores directly should add synchronization - at the call site when sharing across threads. +- Non-sharded in-memory stores (`UnboundCache`, `LruCache`, `TtlCache`, etc.) are not internally + synchronized. Macro-generated `#[cached]`/`#[once]` functions wrap them in locks; users + 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. + Direct sharded-store method syntax is synchronous because these stores expose inherent + `cache_get` / `cache_set` / `cache_remove` helpers. Use Universal Function Call Syntax (UFCS) + for async trait calls (e.g., `cached::ConcurrentCachedAsync::cache_get(&*STORE, &key).await.expect("ShardedCache is infallible")`), where `&*STORE` dereferences a `LazyLock` or `OnceCell` static to obtain a `&Store` reference. - `Cached::get` (and its legacy alias `cache_get`) requires mutable access because some stores update recency, expiration timestamps, or metrics during reads. - 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. +- `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 + 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 + expiry, removing a present-but-already-expired entry still evicts and fires `on_evict`, + but `cache_remove` returns `None`; use `cache_delete` or `cache_remove_entry` when you + need to know whether an entry was physically removed. +- `cache_clear()` is fast and side-effect-free: it does **not** fire `on_evict` and does + not increment the evictions counter. Use `cache_clear_with_on_evict()` when you need the + callback to fire for every removed entry (e.g., to release resources tracked via `on_evict`). + Note: neither `clear()` nor `cache_clear_with_on_evict()` is part of `ConcurrentCached` + or its async counterpart — `clear()` is exposed as an inherent method on each concrete + sharded store type, and `cache_clear_with_on_evict()` is inherent-only as well; generic code + parameterized over `ConcurrentCached` cannot call either. - Bounded caches enforce capacity on insertion. Time-bounded caches enforce freshness on lookup. -- Redis and disk stores serialize values and return owned values; in-memory stores return - references from direct store APIs and macro-generated functions clone cached return values. -- Macro-generated cache statics use `RwLock` by default. Named cache - statics should be inspected with `.read()` or `.write()` unless `sync_lock = "mutex"` is set. +- Redis and disk stores serialize values and return owned values. Non-sharded in-memory stores + return references from direct store APIs; sharded stores return owned `Option` values + (cloned under a shard lock). Macro-generated functions clone cached return values in all cases. +- Macro-generated `#[cached]` / `#[once]` cache statics use `RwLock` by default. Named cache + statics for those macros should be inspected with `.read()` or `.write()` unless + `sync_lock = "mutex"` is set. Named `#[concurrent_cached]` statics hold a self-synchronizing + store directly: sync functions use `LazyLock`, and async functions use + `OnceCell`. - `CachedPeek` provides non-mutating lookups that do not update recency, refresh TTLs, or record metrics. `CachedRead` is narrower and is only implemented where shared-lock lookups can preserve normal read-side semantics without recency or refresh mutation. +- Sharded stores implement `ConcurrentCached`/`ConcurrentCachedAsync` instead of + `Cached`/`CachedAsync`. Generic code parameterized over `Cached` cannot accept sharded + stores; use a `ConcurrentCached` bound or a concrete type instead. + Sharded stores also do not implement `CachedIter` or `CachedPeek`. Code that is generic over + `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. **Per-Value Expiry via the `Expires` Trait** @@ -100,12 +198,17 @@ While standard timed stores (`TtlCache`, `LruTtlCache`, `TtlSortedCache`) enforc This approach is highly useful when caching payloads like OAuth tokens, HTTP responses with varying `Cache-Control` headers, or database records that contain their own absolute expiration timestamps. -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 `size` is also specified. For `#[once]`, this stores a single value whose expiry is polled on each call. +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. -> **Memory note:** `ExpiringCache` is unbounded and only removes expired entries when the same -> key is accessed again. `CachedIter::iter()` filters expired entries from the iterator but does -> not remove them from the map. For high-cardinality workloads, call `evict()` periodically or -> prefer `ExpiringLruCache` with a `size` bound. +For concurrent (multi-thread, no external lock) use, the sharded equivalents [`ShardedExpiringCache`] and [`ShardedExpiringLruCache`] provide the same per-value expiry with internally-synchronized sharded storage. Use `#[concurrent_cached(expires = true)]` to select them automatically. + +> **Memory note:** `ExpiringCache` and `ShardedExpiringCache` are unbounded and only remove +> expired entries when the same key is accessed again. `CachedIter::iter()` (implemented on the +> non-sharded `ExpiringCache` / `ExpiringLruCache` only, not on the sharded variants) filters +> expired entries from the iterator but does not remove them from the map. For high-cardinality workloads, +> call `evict()` periodically (bring [`CacheEvict`] into scope: `use cached::CacheEvict;`; note +> that `evict()` on sharded TTL and expiring stores requires `K: Clone`) or +> prefer `ExpiringLruCache` / `ShardedExpiringLruCache` with a `max_size` bound. ```rust use cached::{Cached, Expires, ExpiringCache, ExpiringLruCache}; @@ -136,8 +239,8 @@ cache.cache_set("key2", Response { expires_at: now + Duration::from_secs(3600), }); -// ExpiringLruCache — LRU-bounded, used with `#[cached(expires = true, size = N)]` -let mut lru = ExpiringLruCache::with_size(10); +// ExpiringLruCache — LRU-bounded, used with `#[cached(expires = true, max_size = N)]` +let mut lru = ExpiringLruCache::with_max_size(10); lru.cache_set("key1", Response { payload: "a".to_string(), expires_at: now + Duration::from_secs(1), @@ -173,7 +276,7 @@ use cached::LruCache; /// Use an explicit cache-type with a custom creation block and custom cache-key generating block #[cached( ty = "LruCache", - create = "{ LruCache::with_size(100) }", + create = "{ LruCache::with_max_size(100) }", convert = r#"{ format!("{}{}", a, b) }"# )] fn keyed(a: &str, b: &str) -> usize { @@ -196,7 +299,7 @@ use cached::macros::once; /// will synchronize (`sync_writes`) so the function /// is only executed once. # #[cfg(feature = "time_stores")] -#[once(ttl =10, option = true, sync_writes = true)] +#[once(ttl =10, sync_writes = true)] fn keyed(a: String) -> Option { if a == "a" { Some(a.len()) @@ -214,7 +317,6 @@ use cached::macros::cached; /// Cannot use sync_writes and result_fallback together #[cached( - result = true, ttl = 1, sync_writes = "default", result_fallback = true @@ -239,16 +341,15 @@ enum ExampleError { /// Cache the results of an async function in redis. Cache /// keys will be prefixed with `cache_redis_prefix`. -/// A `map_error` closure must be specified to convert any -/// redis cache errors into the same type of error returned -/// by your function. All `concurrent_cached` functions must return `Result`s. +/// 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::new("cached_redis_prefix", Duration::from_secs(1)) + AsyncRedisCache::builder("cached_redis_prefix", Duration::from_secs(1)) .refresh(true) - .build() + .try_build() .await .expect("error building example redis cache") } "## @@ -275,9 +376,8 @@ enum ExampleError { /// Cache the results of a function on disk. /// Cache files will be stored under the system cache dir /// unless otherwise specified with `disk_dir` or the `create` argument. -/// A `map_error` closure must be specified to convert any -/// disk cache errors into the same type of error returned -/// by your function. All `concurrent_cached` functions must return `Result`s. +/// Disk stores require `Result`; supply a `map_error` closure +/// to convert store errors into your error type. #[concurrent_cached( map_error = r##"|e| ExampleError::DiskError(format!("{:?}", e))"##, disk = true @@ -288,6 +388,41 @@ fn cached_sleep_secs(secs: u64) -> Result { } ``` +---- + +```rust,no_run,ignore +use cached::macros::concurrent_cached; + +/// Memoize with the default in-memory sharded store — no `map_error`, `ty`, +/// or `create` needed. Add `max_size` for LRU eviction or `ttl` for time-based +/// expiry (requires the `time_stores` feature). +/// +/// `#[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` +/// 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. +#[concurrent_cached] +fn slow_double(x: u64) -> u64 { + std::thread::sleep(cached::time::Duration::from_millis(10)); + x * 2 +} + +/// LRU capacity of 1 000 entries spread across shards. +#[concurrent_cached(max_size = 1000)] +fn slow_triple(x: u64) -> u64 { + x * 3 +} + +/// Only cache successful lookups — `Err` is returned but not stored. +#[concurrent_cached] +fn load_user(id: u64) -> Result { + Ok(format!("user_{id}")) +} +``` + Functions defined via macros will have their results cached using the function's arguments as a key, or a `convert` expression specified on the macro. @@ -299,14 +434,26 @@ Due to the requirements of storing arguments and return values in a global cache - Function return types: - For in-memory stores (`#[cached]` / `#[once]`), must be owned and implement `Clone` - - For I/O-backed stores used by `#[concurrent_cached]` (Redis and disk), must be owned, implement - `Clone` (the generated code clones the successful value), and additionally implement - `serde::Serialize + serde::DeserializeOwned` (the store serializes it) + - For in-memory `#[concurrent_cached]` (sharded stores — the default), must implement `Clone`. + Any return type is accepted: plain `T`, `Option`, or `Result`. `Option` skips + caching `None` by default; use `cache_none = true` to also cache `None`. When the + return type is `Result`, only `Ok(v)` is stored — `Err` values are returned but not cached. + Use `cache_err = true` to also cache `Err` values. + - For I/O-backed stores used by `#[concurrent_cached]` (Redis and disk), must be `Result` + where `T: Clone + serde::Serialize + serde::DeserializeOwned` (the store serializes it). + `map_error` must be supplied to convert the store's error into `E`. - Function arguments: - For in-memory stores (`#[cached]` / `#[once]`), must either be owned and implement `Hash + Eq + Clone`, or a `convert` expression must be specified on the macro to produce a key of a `Hash + Eq + Clone` type. + - For in-memory `#[concurrent_cached]` (sharded stores), must implement `Hash + Eq + Clone`. The + macro's default key construction always clones function arguments, so `K: Clone` is required on + every in-memory path. (When using `convert` to supply an already-owned key, only the store's + own bounds apply: `K: Hash + Eq` for unbounded/TTL-only variants, `K: Hash + Eq + Clone` for LRU + variants — except when `result_fallback = true` is also set, which always requires `K: Clone` + regardless of store variant because the generated code clones the key into the fallback store.) - For I/O-backed stores used by `#[concurrent_cached]` (Redis and disk), must either be owned and - implement `Display`, or a `convert` expression must be used to produce a key of a `Display` type. + 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. - 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! @@ -331,8 +478,12 @@ use std::future::Future; #[cfg_attr(docsrs, doc(cfg(any(feature = "redis_smol", feature = "redis_tokio"))))] pub use stores::{AsyncRedisCache, AsyncRedisCacheBuilder}; pub use stores::{ - BuildError, CacheEvict, Expires, ExpiringCache, ExpiringCacheBuilder, ExpiringLruCache, - ExpiringLruCacheBuilder, LruCache, LruCacheBuilder, UnboundCache, UnboundCacheBuilder, + BuildError, CacheEvict, DefaultShardHasher, Expires, ExpiringCache, ExpiringCacheBuilder, + ExpiringLruCache, ExpiringLruCacheBuilder, LruCache, LruCacheBuilder, ShardHasher, + ShardedCache, ShardedCacheBase, ShardedCacheBuilder, ShardedExpiringCache, + ShardedExpiringCacheBase, ShardedExpiringCacheBuilder, ShardedExpiringLruCache, + ShardedExpiringLruCacheBase, ShardedExpiringLruCacheBuilder, ShardedLruCache, + ShardedLruCacheBase, ShardedLruCacheBuilder, UnboundCache, UnboundCacheBuilder, }; #[cfg(feature = "disk_store")] #[cfg_attr(docsrs, doc(cfg(feature = "disk_store")))] @@ -340,8 +491,9 @@ pub use stores::{DiskCache, DiskCacheBuildError, DiskCacheBuilder, DiskCacheErro #[cfg(feature = "time_stores")] #[cfg_attr(docsrs, doc(cfg(feature = "time_stores")))] pub use stores::{ - HasEvict, LruTtlCache, LruTtlCacheBuilder, NoEvict, TtlCache, TtlCacheBuilder, TtlSortedCache, - TtlSortedCacheBuilder, TtlSortedCacheError, + HasEvict, LruTtlCache, LruTtlCacheBuilder, NoEvict, ShardedLruTtlCache, ShardedLruTtlCacheBase, + ShardedLruTtlCacheBuilder, ShardedTtlCache, ShardedTtlCacheBase, ShardedTtlCacheBuilder, + TtlCache, TtlCacheBuilder, TtlSortedCache, TtlSortedCacheBuilder, TtlSortedCacheError, }; #[cfg(feature = "redis_store")] #[cfg_attr(docsrs, doc(cfg(feature = "redis_store")))] @@ -372,6 +524,16 @@ pub mod sync_sync { pub use parking_lot::RwLock; } +/// Internal marker referenced by `#[cached(size = N)]` / `#[concurrent_cached(size = N)]` +/// expansions to surface a deprecation warning steering users to the `max_size` spelling. +/// Not part of the public API. +#[doc(hidden)] +#[deprecated( + since = "2.0.0", + note = "the `size` macro attribute is deprecated; use `max_size` instead (same meaning)" +)] +pub const __DEPRECATED_SIZE_ATTR: () = (); + /// Cache operations /// /// ```rust @@ -431,7 +593,18 @@ pub trait Cached { f: F, ) -> Result<&mut V, E>; - /// Remove a cached value. + /// 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, + /// for stores that track evictions, increments the `evictions` metric consistent + /// with automatic eviction. For stores with expiry, an entry that is present but + /// already expired is still removed (and still fires `on_evict` / counts as an + /// eviction when the store tracks evictions), but `None` is returned because the + /// value is no longer valid. + /// + /// Use [`cache_remove_entry`](Cached::cache_remove_entry) when you need to + /// distinguish "key absent" from "key present but expired", or when you need + /// the stored key back (relevant when `K`'s `Eq` ignores some fields). /// /// ```rust /// # use cached::{Cached, UnboundCache}; @@ -448,6 +621,39 @@ pub trait Cached { K: std::borrow::Borrow, Q: std::hash::Hash + Eq + ?Sized; + /// Remove a cached entry, returning the stored key and value whenever an entry + /// was physically deleted — including entries that were present but already expired. + /// + /// This is the key difference from [`cache_remove`](Cached::cache_remove): + /// - `cache_remove` returns `None` for both "key absent" and "key present but expired" + /// - `cache_remove_entry` returns `Some((stored_key, value))` whenever anything was + /// physically deleted, and `None` only when the key was not in the store at all + /// + /// This lets callers distinguish between the two cases, and also returns the *stored* + /// key rather than the lookup key — relevant when `K`'s `Eq`/`Hash` ignores some + /// fields and the stored and lookup instances may differ. + /// + /// Removing any present entry fires the store's `on_evict` callback (if set) and, + /// for stores that track evictions, increments the `evictions` metric. + /// + /// ```rust + /// use cached::{Cached, UnboundCache}; + /// + /// let mut cache: UnboundCache = UnboundCache::new(); + /// cache.cache_set("key".to_string(), 42); + /// + /// // cache_remove_entry returns Some even for the key that was just inserted. + /// let entry = cache.cache_remove_entry("key"); + /// assert_eq!(entry, Some(("key".to_string(), 42))); + /// + /// // Returns None only when the key was never present. + /// assert_eq!(cache.cache_remove_entry("missing"), None); + /// ``` + fn cache_remove_entry(&mut self, k: &Q) -> Option<(K, V)> + where + K: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized; + /// Remove all cached entries but preserve capacity allocation and metrics. /// To also reset metrics, call [`cache_reset_metrics`](Cached::cache_reset_metrics) afterward, /// or use [`cache_reset`](Cached::cache_reset) to do both at once. @@ -537,6 +743,50 @@ 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, + Q: std::hash::Hash + Eq + ?Sized, + { + 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::new(); + /// 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, + Q: std::hash::Hash + Eq + ?Sized, + { + 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 @@ -727,10 +977,60 @@ pub trait CloneCached { } } +/// Concurrent analogue of [`CloneCached`] for the internally-synchronized sharded stores. +/// +/// Like [`CloneCached`], [`cache_get_with_expiry_status`](ConcurrentCloneCached::cache_get_with_expiry_status) +/// returns a present-but-expired entry **without removing it**, so callers (e.g. +/// [`result_fallback`](macro@crate::macros::concurrent_cached)) can fall back to the stale +/// value if a refresh fails. Takes `&self` instead of `&mut self` because sharded stores are +/// internally synchronized and never need exclusive ownership from the caller. +/// +/// Implemented by the four expiry-capable sharded stores: +/// [`ShardedTtlCache`], [`ShardedLruTtlCache`], [`ShardedExpiringCache`], and +/// [`ShardedExpiringLruCache`]. +/// Non-expiry stores ([`ShardedCache`], [`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 +/// concurrent cache trait family includes external stores that must serialize the key, so +/// the trait takes `&K` directly rather than a generic `Borrow` that carries no +/// serialization guarantee. +/// +/// **Race window**: between the expiry check and the subsequent re-cache on `Err`, another +/// thread may have stored a fresh `Ok`. The re-cache will overwrite that fresh value, but +/// the outcome is only a redundant function call on the next expiry — not data corruption. +/// +/// # Examples +/// +/// ```rust +/// # #[cfg(feature = "time_stores")] +/// # { +/// use cached::{ConcurrentCloneCached, ShardedTtlCache}; +/// use cached::time::Duration; +/// +/// let c = ShardedTtlCache::with_ttl(Duration::from_secs(60)); +/// c.cache_set("k".to_string(), 1_i32).expect("infallible ShardedTtlCache set"); +/// 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) +/// # } +/// ``` +pub trait ConcurrentCloneCached { + /// Look up a cached value and report whether the found entry is expired. + /// + /// Returns `(value, expired)` where: + /// - `(None, false)` — key not present + /// - `(Some(v), false)` — key present and live (hits counter incremented) + /// - `(Some(v), true)` — key present but expired; the stale value is returned so callers + /// can fall back to it if a refresh fails. The entry is **not** removed from the cache + /// and eviction counters are **not** incremented (misses counter incremented). + fn cache_get_with_expiry_status(&self, key: &K) -> (Option, bool); +} + /// TTL management for time-bounded cache stores. /// /// Implemented by [`TtlCache`], [`LruTtlCache`], [`TtlSortedCache`], -/// [`RedisCache`], and [`DiskCache`]. +/// [`ShardedTtlCache`], [`ShardedLruTtlCache`], [`RedisCache`], and [`DiskCache`]. /// /// This trait requires the `time_stores` feature. `DiskCache` implements it only when /// both `disk_store` and `time_stores` features are enabled. @@ -747,6 +1047,11 @@ pub trait CacheTtl { fn ttl(&self) -> Option; /// Set the TTL for newly inserted entries, returning the previous value. + /// + /// # Panics + /// + /// Some implementations (e.g., sharded TTL stores) panic if `ttl.is_zero()` — use + /// [`unset_ttl`](Self::unset_ttl) to disable expiry instead. fn set_ttl(&mut self, ttl: Duration) -> Option; /// Remove the TTL so entries are retained indefinitely. @@ -874,15 +1179,38 @@ pub trait CachedAsync { /// 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(&mut self, _refresh: bool) -> bool { false } /// } /// /// let store = MyStore(Mutex::new(HashMap::new())); -/// assert_eq!(store.cache_get(&"k".to_string()).unwrap(), None); -/// assert_eq!(store.cache_set("k".to_string(), 7).unwrap(), None); -/// assert_eq!(store.cache_get(&"k".to_string()).unwrap(), Some(7)); -/// assert_eq!(store.cache_remove(&"k".to_string()).unwrap(), Some(7)); +/// 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)); +/// ``` +/// **Direct-call syntax warning**: +/// +/// Both `ConcurrentCached` and `ConcurrentCachedAsync` define identical method names for their core +/// operations (`cache_get`, `cache_set`, `cache_remove`, `cache_delete`). Sharded in-memory stores +/// also expose synchronous inherent helpers with those names, so method-call syntax on a sharded +/// store resolves to the sync helper even if only `ConcurrentCachedAsync` is imported. +/// +/// To resolve this, use Universal Function Call Syntax (UFCS) when calling these methods manually: +/// ```rust,ignore +/// ::cached::ConcurrentCached::cache_get(&cache, &key) /// ``` +/// +/// **Why key-lookup methods take `&K` instead of `&Q` (`Borrow`)**: +/// +/// [`Cached`] uses `Borrow` for all key-lookup methods (e.g. look up a `String` key with a +/// `&str`). `ConcurrentCached` cannot follow the same pattern because its implementors include +/// external stores (`DiskCache`, `RedisCache`) that must *serialize* the key in order to perform +/// 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; @@ -894,34 +1222,129 @@ pub trait ConcurrentCached { fn cache_get(&self, k: &K) -> Result, Self::Error>; /// Insert a key, value pair and return the previous value at the key, if any, - /// without checking TTL expiry. + /// without checking expiry. For TTL-based stores the returned value may have + /// elapsed its TTL; for per-value expiry stores (implementing [`Expires`]) the + /// returned value may report `is_expired() == true`. Check expiry on the returned + /// value if you need to distinguish a live previous entry from an expired one. /// /// # Errors /// /// Should return `Self::Error` if the operation fails fn cache_set(&self, k: K, v: V) -> Result, Self::Error>; - /// Remove a cached value + /// Remove a cached value, returning it if it was both present and still live. + /// + /// For stores with expiry, an entry that is present but already expired is still + /// removed (and fires `on_evict` / counts an eviction), but `None` is returned. + /// Use [`cache_remove_entry`](ConcurrentCached::cache_remove_entry) when you need + /// to distinguish "key absent" from "key present but expired". /// /// # Errors /// /// Should return `Self::Error` if the operation fails fn cache_remove(&self, k: &K) -> Result, Self::Error>; + /// Remove a cached entry, returning the stored key and value whenever an entry + /// was physically deleted — including entries that were present but already expired. + /// + /// This is the key difference from [`cache_remove`](ConcurrentCached::cache_remove): + /// - `cache_remove` returns `None` for both "key absent" and "key present but expired" + /// - `cache_remove_entry` returns `Some((stored_key, value))` whenever anything was + /// physically deleted, and `None` only when the key was not in the store at all + /// + /// Removing any present entry fires the store's `on_evict` callback (if set) and, + /// for stores that track evictions, increments the `evictions` metric. + /// + /// # Note on `K: Clone` + /// + /// Implementations that reconstruct the stored key from the lookup key — such as + /// `DiskCache` and `RedisCache` — require `K: Clone` to produce the stored-key half + /// of the returned tuple. Sharded in-memory stores return the physically stored key + /// and do not impose this bound. + /// + /// # Note on Redis and external stores + /// + /// Stores that enforce TTL server-side (e.g. `RedisCache`, `AsyncRedisCache`) cannot + /// retrieve the value of a TTL-expired key — `GET` returns nil once the TTL elapses, + /// even before the background expiry sweep removes the key. For such stores, + /// `cache_remove_entry` behaves identically to `cache_remove` and returns `None` for + /// server-side-expired entries. Use [`cache_delete`](ConcurrentCached::cache_delete) + /// (which issues `DEL` directly) to reliably confirm whether any physical entry was removed. + /// + /// # Errors + /// + /// Should return `Self::Error` if the operation fails + /// + /// # Example + /// + /// ```rust + /// use cached::{ConcurrentCached, ShardedCache}; + /// + /// let cache: ShardedCache = ShardedCache::new(); + /// cache.cache_set("key".to_string(), 42).expect("ShardedCache is infallible"); + /// + /// // cache_remove_entry always returns Some when the key was present. + /// let entry = cache.cache_remove_entry(&"key".to_string()).expect("ShardedCache is infallible"); + /// 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); + /// ``` + fn cache_remove_entry(&self, k: &K) -> Result, Self::Error>; + /// Delete a cached value without returning or decoding the stored value. /// - /// This is useful when callers do not need the previous value, or when an - /// IO-backed store may contain corrupted serialized data that should be - /// removed directly. + /// Returns `true` if an entry (live or expired) was physically removed from the + /// store, `false` if the key was not present. This differs from the 1.x + /// behaviour where `cache_delete` returned `false` for expired entries — use + /// [`cache_remove`](ConcurrentCached::cache_remove) if you need to distinguish + /// a live removal from an expired one. /// /// # Errors /// /// Should return `Self::Error` if the operation fails fn cache_delete(&self, k: &K) -> Result { - self.cache_remove(k).map(|removed| removed.is_some()) + 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) } /// Set whether cache hits refresh the ttl of cached values, returning the previous flag value. + /// + /// **Note for sharded in-memory stores**: the concrete types expose an inherent + /// `set_refresh_on_hit(&self, ...)` method (using `AtomicBool` internally) that does not + /// require `&mut self`. When you hold only a shared reference (e.g., via `Arc`), call the + /// inherent method directly instead of going through this trait. Because sharded stores are + /// `Arc`-backed (cloning shares the backing store), an exclusive `&mut` reference is + /// generally unavailable — use the inherent `&self` method instead. fn set_refresh_on_hit(&mut self, refresh: bool) -> bool; /// Return the ttl of cached values (time to eviction). @@ -930,6 +1353,12 @@ pub trait ConcurrentCached { } /// Set the ttl of cached values, returning the previous value. + /// + /// **Note for sharded in-memory stores**: the concrete types expose an inherent + /// `set_ttl(&self, ...)` method that does not require `&mut self`. When you hold only a + /// shared reference (e.g., via `Arc`), call the inherent method directly instead of going + /// through this trait. Because sharded stores are `Arc`-backed, an exclusive `&mut` + /// reference is generally unavailable — use the inherent `&self` method instead. fn set_ttl(&mut self, _ttl: Duration) -> Option { None } @@ -938,11 +1367,34 @@ pub trait ConcurrentCached { /// /// For cache implementations that don't support retaining values indefinitely, this method is /// a no-op. + /// + /// **Note for sharded in-memory stores**: the concrete types expose an inherent + /// `unset_ttl(&self)` method that does not require `&mut self`. When you hold only a shared + /// reference (e.g., via `Arc`), call the inherent method directly instead of going through + /// this trait. Because sharded stores are `Arc`-backed, an exclusive `&mut` reference is + /// generally unavailable — use the inherent `&self` method instead. fn unset_ttl(&mut self) -> Option { None } } +/// **Direct-call syntax warning**: +/// +/// Both `ConcurrentCached` and `ConcurrentCachedAsync` define identical method names for their core +/// operations (`cache_get`, `cache_set`, `cache_remove`, `cache_delete`). Sharded in-memory stores +/// also expose synchronous inherent helpers with those names, so method-call syntax on a sharded +/// store resolves to the sync helper even if only `ConcurrentCachedAsync` is imported. +/// +/// To resolve this, use Universal Function Call Syntax (UFCS) when calling these methods manually: +/// ```rust,ignore +/// ::cached::ConcurrentCachedAsync::cache_get(&cache, &key).await +/// ``` +/// +/// **Short aliases not provided**: Unlike [`ConcurrentCached`], this trait intentionally does +/// **not** expose `get`/`set`/`remove`/`delete` short aliases. Adding them would worsen the +/// method-resolution ambiguity described above: a sharded store that implements both sync and +/// async concurrent traits would then have colliding inherent helpers and two sets of alias +/// defaults, making UFCS mandatory even for the aliases. #[cfg(feature = "async_core")] #[cfg_attr(docsrs, doc(cfg(feature = "async_core")))] pub trait ConcurrentCachedAsync { @@ -951,19 +1403,35 @@ pub trait ConcurrentCachedAsync { fn cache_set(&self, k: K, v: V) -> impl Future, Self::Error>> + Send; - /// Remove a cached value + /// Remove a cached value, returning it if it was both present and still live. fn cache_remove(&self, k: &K) -> impl Future, Self::Error>> + Send; + /// Remove a cached entry, returning the stored key and value whenever an entry + /// was physically deleted — including entries that were present but already expired. + fn cache_remove_entry( + &self, + k: &K, + ) -> impl Future, Self::Error>> + Send; + /// Delete a cached value without returning or decoding the stored value. + /// Returns `true` if an entry (live or expired) was physically removed from the + /// store, `false` if the key was not present. fn cache_delete(&self, k: &K) -> impl Future> + Send where Self: Sync, K: Sync, { - async move { self.cache_remove(k).await.map(|removed| removed.is_some()) } + async move { self.cache_remove_entry(k).await.map(|r| r.is_some()) } } /// Set whether cache hits refresh the ttl of cached values, returning the previous flag value. + /// + /// **Note for sharded in-memory stores**: the concrete types expose an inherent + /// `set_refresh_on_hit(&self, ...)` method (using `AtomicBool` internally) that does not + /// require `&mut self`. When you hold only a shared reference (e.g., via `Arc`), call the + /// inherent method directly instead of going through this trait. Because sharded stores are + /// `Arc`-backed (cloning shares the backing store), an exclusive `&mut` reference is + /// generally unavailable — use the inherent `&self` method instead. fn set_refresh_on_hit(&mut self, refresh: bool) -> bool; /// Return the ttl of cached values (time to eviction). @@ -972,6 +1440,12 @@ pub trait ConcurrentCachedAsync { } /// Set the ttl of cached values, returning the previous value. + /// + /// **Note for sharded in-memory stores**: the concrete types expose an inherent + /// `set_ttl(&self, ...)` method that does not require `&mut self`. When you hold only a + /// shared reference (e.g., via `Arc`), call the inherent method directly instead of going + /// through this trait. Because sharded stores are `Arc`-backed, an exclusive `&mut` + /// reference is generally unavailable — use the inherent `&self` method instead. fn set_ttl(&mut self, _ttl: Duration) -> Option { None } @@ -980,6 +1454,12 @@ pub trait ConcurrentCachedAsync { /// /// For cache implementations that don't support retaining values indefinitely, this method is /// a no-op. + /// + /// **Note for sharded in-memory stores**: the concrete types expose an inherent + /// `unset_ttl(&self)` method that does not require `&mut self`. When you hold only a shared + /// reference (e.g., via `Arc`), call the inherent method directly instead of going through + /// this trait. Because sharded stores are `Arc`-backed, an exclusive `&mut` reference is + /// generally unavailable — use the inherent `&self` method instead. fn unset_ttl(&mut self) -> Option { None } diff --git a/src/lru_list.rs b/src/lru_list.rs index d769672c..adb13189 100644 --- a/src/lru_list.rs +++ b/src/lru_list.rs @@ -40,13 +40,13 @@ impl LRUList { let capacity = capacity .checked_add(2) .ok_or(crate::stores::BuildError::InvalidValue { - field: "size", + field: "max_size", reason: "capacity overflow", })?; let mut values = Vec::new(); values.try_reserve_exact(capacity).map_err(|_| { crate::stores::BuildError::InvalidValue { - field: "size", + field: "max_size", reason: "allocation failed", } })?; diff --git a/src/macros.rs b/src/macros.rs index 3f9f19d9..8e516158 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -97,7 +97,7 @@ use cached::macros::cached; # } /// Cache a fallible function. Only `Ok` results are cached. -#[cached(size=1, result = true)] +#[cached(size=1)] fn keyed(a: String) -> Result { do_something_fallible()?; Ok(a.len()) @@ -111,7 +111,7 @@ fn keyed(a: String) -> Result { use cached::macros::cached; /// Cache an optional function. Only `Some` results are cached. -#[cached(size=1, option = true)] +#[cached(size=1)] fn keyed(a: String) -> Option { if a == "a" { Some(a.len()) @@ -131,7 +131,7 @@ use cached::macros::cached; /// When called concurrently, duplicate argument-calls will be /// synchronized so as to only run once - the remaining concurrent /// calls return a cached value. -#[cached(size=1, option = true, sync_writes = true)] +#[cached(size=1, sync_writes = true)] fn keyed(a: String) -> Option { if a == "a" { Some(a.len()) @@ -177,7 +177,7 @@ use cached::Return; # } /// Same as the previous, but returning a Result -#[cached(size=1, result = true, with_cached_flag = true)] +#[cached(size=1, with_cached_flag = true)] fn calculate(a: String) -> Result, ()> { do_something_fallible()?; Ok(Return::new(a.len())) @@ -200,7 +200,7 @@ use cached::macros::cached; use cached::Return; /// Same as the previous, but returning an Option -#[cached(size=1, option = true, with_cached_flag = true)] +#[cached(size=1, with_cached_flag = true)] fn calculate(a: String) -> Option> { if a == "a" { Some(Return::new(a.len())) @@ -227,7 +227,7 @@ use cached::LruCache; /// Use an explicit cache-type with a custom creation block and custom cache-key generating block #[cached( ty = "LruCache", - create = "{ LruCache::with_size(100) }", + create = "{ LruCache::with_max_size(100) }", convert = r#"{ format!("{}{}", a, b) }"# )] fn keyed(a: &str, b: &str) -> usize { @@ -251,7 +251,7 @@ use cached::time::Duration; /// will synchronize (`sync_writes`) so the function /// is only executed once. # #[cfg(feature = "time_stores")] -#[once(ttl =10, option = true, sync_writes = true)] +#[once(ttl =10, sync_writes = true)] fn keyed(a: String) -> Option { if a == "a" { Some(a.len()) diff --git a/src/stores/disk.rs b/src/stores/disk.rs index 7d90bd5f..a24ee0d6 100644 --- a/src/stores/disk.rs +++ b/src/stores/disk.rs @@ -30,6 +30,8 @@ use thiserror::Error; pub enum DiskCacheBuildError { #[error("Storage connection error")] ConnectionError(#[from] sled::Error), + #[error(transparent)] + InvalidTtl(#[from] super::BuildError), #[error("Connection string not specified or invalid in env var {env_key:?}: {error:?}")] MissingDiskPath { env_key: String, @@ -160,6 +162,9 @@ where } pub fn build(self) -> Result, DiskCacheBuildError> { + if let Some(ttl) = self.ttl { + super::validate_ttl(ttl)?; + } let cache_dir_name = format!("{}_v{}", self.cache_name, DISK_FILE_VERSION); let (disk_path, connection) = if let Some(disk_dir) = self.disk_dir { @@ -182,6 +187,11 @@ where _phantom: self._phantom, }) } + + /// Alias for [`build`](Self::build), matching in-memory store builder naming. + pub fn try_build(self) -> Result, DiskCacheBuildError> { + self.build() + } } /// Cache store backed by disk @@ -213,6 +223,11 @@ where DiskCacheBuilder::new(cache_name) } + /// Initialize a `DiskCacheBuilder`. + pub fn builder(cache_name: &str) -> DiskCacheBuilder { + DiskCacheBuilder::new(cache_name) + } + pub fn remove_expired_entries(&self) -> Result<(), DiskCacheError> { let now = SystemTime::now(); @@ -401,6 +416,28 @@ where result } +fn disk_cache_remove_entry( + connection: &Db, + key: &str, + sync_to_disk_on_cache_change: bool, +) -> Result, DiskCacheError> +where + V: DeserializeOwned, +{ + let result = if let Some(data) = connection.remove(key)? { + let cached = rmp_serde::from_slice::>(&data)?; + Ok(Some(cached.value)) + } else { + Ok(None) + }; + + if sync_to_disk_on_cache_change { + connection.flush()?; + } + + result +} + fn disk_cache_delete( connection: &Db, key: &str, @@ -417,7 +454,7 @@ fn disk_cache_delete( impl ConcurrentCached for DiskCache where - K: ToString, + K: ToString + Clone, V: Serialize + DeserializeOwned, { type Error = DiskCacheError; @@ -451,6 +488,15 @@ where ) } + fn cache_remove_entry(&self, key: &K) -> Result, Self::Error> { + disk_cache_remove_entry( + &self.connection, + &key.to_string(), + self.sync_to_disk_on_cache_change, + ) + .map(|opt| opt.map(|v| (key.clone(), v))) + } + fn cache_delete(&self, key: &K) -> Result { disk_cache_delete( &self.connection, @@ -507,7 +553,7 @@ where #[cfg_attr(docsrs, doc(cfg(feature = "async")))] impl crate::ConcurrentCachedAsync for DiskCache where - K: ToString + Send + Sync, + K: ToString + Clone + Send + Sync, V: Serialize + DeserializeOwned + Send + 'static, { type Error = DiskCacheError; @@ -544,6 +590,18 @@ where .map_err(|_| DiskCacheError::BackgroundTaskFailed)? } + async fn cache_remove_entry(&self, key: &K) -> Result, Self::Error> { + let connection = self.connection.clone(); + let key_str = key.to_string(); + let sync = self.sync_to_disk_on_cache_change; + let v: Option = tokio::task::spawn_blocking(move || { + disk_cache_remove_entry::(&connection, &key_str, sync) + }) + .await + .map_err(|_| DiskCacheError::BackgroundTaskFailed)??; + Ok(v.map(|v| (key.clone(), v))) + } + async fn cache_delete(&self, key: &K) -> Result { let connection = self.connection.clone(); let key = key.to_string(); diff --git a/src/stores/expiring.rs b/src/stores/expiring.rs index a291d6d2..38f935bd 100644 --- a/src/stores/expiring.rs +++ b/src/stores/expiring.rs @@ -82,6 +82,12 @@ 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 +/// single global TTL applied to every entry, use [`TtlCache`](crate::stores::TtlCache) or +/// [`LruTtlCache`](crate::stores::LruTtlCache) instead. +#[doc(alias = "ttl")] pub struct ExpiringCacheBuilder { capacity: Option, on_evict: Option>, @@ -104,13 +110,17 @@ impl ExpiringCacheBuilder { self } - /// Set a callback to be invoked when an expired entry is removed from the cache. + /// 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`, - /// their async equivalents, or an explicit `evict()` sweep. - /// It does **not** fire on `cache_remove` or `cache_clear` (consistent with + /// 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 /// [`ExpiringLruCache`](crate::ExpiringLruCache)). + /// Use [`cache_clear_with_on_evict`](ExpiringCache::cache_clear_with_on_evict) + /// instead of [`cache_clear`](crate::Cached::cache_clear) to opt into callback + /// firing and eviction counter increments when clearing all entries. #[must_use] pub fn on_evict(mut self, on_evict: impl Fn(&K, &V) + Send + Sync + 'static) -> Self { self.on_evict = Some(Arc::new(on_evict)); @@ -201,6 +211,29 @@ impl ExpiringCache { }); removed } + + /// Remove all entries and fire the `on_evict` callback for each one, incrementing the + /// evictions counter. + /// + /// Unlike [`cache_clear`](crate::Cached::cache_clear) (which removes entries silently), + /// this method invokes `on_evict` for every removed entry (whether or not they had expired) + /// and increments `evictions`. If no `on_evict` callback was configured, it falls back to + /// the plain `cache_clear`. + pub fn cache_clear_with_on_evict(&mut self) { + if self.on_evict.is_none() { + return self.cache_clear(); + } + let entries: Vec<(K, V)> = self.store.store.drain().collect(); + let count = entries.len() as u64; + if count > 0 { + self.evictions.fetch_add(count, Ordering::Relaxed); + } + if let Some(on_evict) = &self.on_evict { + for (k, v) in &entries { + on_evict(k, v); + } + } + } } impl Default for ExpiringCache { @@ -330,7 +363,24 @@ impl Cached for ExpiringCache { K: std::borrow::Borrow, Q: std::hash::Hash + Eq + ?Sized, { - self.store.cache_remove(k) + self.cache_remove_entry(k) + .and_then(|(_, v)| if v.is_expired() { None } else { Some(v) }) + } + + fn cache_remove_entry(&mut self, k: &Q) -> Option<(K, V)> + where + K: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized, + { + if let Some((stored_k, v)) = self.store.store.remove_entry(k) { + if let Some(on_evict) = &self.on_evict { + on_evict(&stored_k, &v); + } + self.evictions.fetch_add(1, Ordering::Relaxed); + Some((stored_k, v)) + } else { + None + } } fn cache_clear(&mut self) { @@ -514,6 +564,7 @@ impl CacheEvict for ExpiringCache { #[cfg(test)] mod tests { use super::*; + use crate::Cached; #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct ExpiredU8(pub u8); @@ -586,7 +637,7 @@ mod tests { } #[test] - fn expiring_cache_remove_does_not_fire_on_evict() { + fn expiring_cache_remove_fires_on_evict() { use std::sync::{ atomic::{AtomicUsize, Ordering as AOrdering}, Arc, @@ -599,13 +650,24 @@ mod tests { }) .build(); c.set(1, ExpiredU8(5)); // live + // Removing a live entry returns Some and fires on_evict. assert_eq!(c.cache_remove(&1), Some(ExpiredU8(5))); assert_eq!( count.load(AOrdering::Relaxed), - 0, - "on_evict must not fire on cache_remove" + 1, + "on_evict must fire on cache_remove" ); - assert_eq!(c.cache_evictions(), Some(0)); + assert_eq!(c.cache_evictions(), Some(1)); + + c.set(2, ExpiredU8(15)); // expired + // Removing an expired entry fires on_evict but returns None. + assert_eq!(c.cache_remove(&2), None); + assert_eq!( + count.load(AOrdering::Relaxed), + 2, + "on_evict fires even for expired entries" + ); + assert_eq!(c.cache_evictions(), Some(2)); } #[test] @@ -692,6 +754,31 @@ mod tests { assert_eq!(fired.lock().unwrap().clone(), vec![1]); } + #[test] + fn cache_clear_with_on_evict_fires_for_all_entries() { + use std::sync::{ + atomic::{AtomicUsize, Ordering as AOrdering}, + Arc, + }; + let count = Arc::new(AtomicUsize::new(0)); + let count2 = count.clone(); + let mut c: ExpiringCache = ExpiringCache::builder() + .on_evict(move |_k: &u8, _v: &ExpiredU8| { + count2.fetch_add(1, AOrdering::Relaxed); + }) + .build(); + c.set(1, ExpiredU8(5)); // live + c.set(2, ExpiredU8(15)); // expired (value > 10) + c.cache_clear_with_on_evict(); + assert_eq!(c.cache_size(), 0); + assert_eq!( + count.load(AOrdering::Relaxed), + 2, + "on_evict fires for all entries including expired" + ); + assert_eq!(c.cache_evictions(), Some(2)); + } + #[test] fn expiring_cache_clear_no_on_evict() { use std::sync::{ @@ -843,4 +930,86 @@ mod tests { let c = result.unwrap(); assert_eq!(c.cache_size(), 0); } + + #[test] + fn cache_remove_entry_returns_some_for_live_entry() { + let mut c: ExpiringCache = ExpiringCache::new(); + c.cache_set(1, ExpiredU8(5)); // not expired: 5 <= 10 + let removed = c.cache_remove_entry(&1u8); + assert_eq!(removed, Some((1u8, ExpiredU8(5)))); + assert_eq!(c.cache_size(), 0); + } + + #[test] + fn cache_remove_entry_returns_some_for_expired_entry() { + let mut c: ExpiringCache = ExpiringCache::new(); + c.cache_set(1, ExpiredU8(20)); // expired: 20 > 10 + + // cache_remove returns None for an expired entry. + assert_eq!(c.cache_remove(&2u8), None); + c.cache_set(2, ExpiredU8(20)); + assert_eq!(c.cache_remove(&2u8), None); + + // cache_remove_entry returns Some even for an expired entry. + let removed = c.cache_remove_entry(&1u8); + assert_eq!( + removed.expect("cache_remove_entry must return Some for expired entry"), + (1u8, ExpiredU8(20)) + ); + } + + #[test] + fn cache_delete_returns_true_for_expired_entry() { + let mut c: ExpiringCache = ExpiringCache::new(); + c.cache_set(1, ExpiredU8(20)); // expired + assert!( + c.cache_delete(&1u8), + "cache_delete must return true even for expired entry" + ); + assert!(!c.cache_delete(&1u8), "cache_delete false when absent"); + } + + #[test] + fn cache_remove_entry_fires_on_evict_for_expired() { + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::Arc; + let count = Arc::new(AtomicU32::new(0)); + let count2 = count.clone(); + let mut c = ExpiringCache::builder() + .on_evict(move |_k: &u8, _v: &ExpiredU8| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + c.cache_set(1u8, ExpiredU8(20)); // expired + + c.cache_remove_entry(&1u8); + assert_eq!( + count.load(Ordering::Relaxed), + 1, + "on_evict fires for expired entries" + ); + + c.cache_remove_entry(&99u8); + assert_eq!(count.load(Ordering::Relaxed), 1, "no fire for absent key"); + } + + #[test] + fn cache_remove_entry_absent_returns_none() { + let mut c: ExpiringCache = ExpiringCache::new(); + assert_eq!(c.cache_remove_entry(&42u8), None); + } + + #[test] + fn cache_remove_entry_increments_eviction_counter() { + let mut c: ExpiringCache = ExpiringCache::new(); + 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 + assert_eq!( + c.cache_evictions().expect("evictions are always tracked") - before, + 1, + "cache_remove_entry must increment evictions for present key only" + ); + } } diff --git a/src/stores/expiring_lru.rs b/src/stores/expiring_lru.rs index 62fd8cc9..33ee3e54 100644 --- a/src/stores/expiring_lru.rs +++ b/src/stores/expiring_lru.rs @@ -33,7 +33,7 @@ use {super::CachedAsync, std::future::Future}; /// assert!(cache.cache_get(&2).is_none()); // expired -> not returned /// /// // LRU-bounded store (`#[cached(expires = true, size = N)]`) -/// let mut lru: ExpiringLruCache = ExpiringLruCache::with_size(8); +/// let mut lru: ExpiringLruCache = ExpiringLruCache::with_max_size(8); /// lru.cache_set(3, Token { value: "live".into(), expired: false }); /// assert!(lru.cache_get(&3).is_some()); /// ``` @@ -94,6 +94,12 @@ 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 +/// `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 { size: Option, on_evict: Option>, @@ -101,13 +107,19 @@ pub struct ExpiringLruCacheBuilder { impl ExpiringLruCacheBuilder { /// Set the maximum number of entries. + #[doc(alias = "size")] + #[doc(alias = "capacity")] #[must_use] - pub fn size(mut self, size: usize) -> Self { - self.size = Some(size); + pub fn max_size(mut self, max_size: usize) -> Self { + self.size = Some(max_size); self } /// Set a callback to be invoked when an entry is evicted. + /// + /// Use [`cache_clear_with_on_evict`](ExpiringLruCache::cache_clear_with_on_evict) + /// instead of [`cache_clear`](crate::Cached::cache_clear) to opt into callback + /// firing and eviction counter increments when clearing all entries. #[must_use] pub fn on_evict(mut self, on_evict: impl Fn(&K, &V) + Send + Sync + 'static) -> Self { self.on_evict = Some(Arc::new(on_evict)); @@ -118,7 +130,7 @@ impl ExpiringLruCacheBuilder { /// /// # Panics /// - /// Panics if `size` was not set or is `0`. + /// Panics if `max_size` was not set or is `0`. #[must_use] pub fn build(self) -> ExpiringLruCache where @@ -126,8 +138,8 @@ impl ExpiringLruCacheBuilder { { let size = self .size - .expect("`ExpiringLruCacheBuilder` requires `size` to be set"); - let mut cache = ExpiringLruCache::with_size(size); + .expect("`ExpiringLruCacheBuilder` requires `max_size` to be set"); + let mut cache = ExpiringLruCache::with_max_size(size); // 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 @@ -143,16 +155,18 @@ impl ExpiringLruCacheBuilder { /// /// # Errors /// - /// Returns [`BuildError`](super::BuildError) if `size` was not set or is `0`. + /// Returns [`BuildError`](super::BuildError) if `max_size` was not set or is `0`. pub fn try_build(self) -> Result, super::BuildError> where K: Hash + Eq + Clone, { let size = self .size - .ok_or(super::BuildError::MissingRequired("size"))?; + .ok_or(super::BuildError::MissingRequired("max_size"))?; + let mut store = LruCache::try_with_max_size(size)?; + store.disable_hit_miss_tracking(); let mut cache = ExpiringLruCache { - store: LruCache::try_with_size(size)?, + store, hits: AtomicU64::new(0), misses: AtomicU64::new(0), evictions: AtomicU64::new(0), @@ -178,9 +192,11 @@ impl ExpiringLruCache { /// Creates a new `ExpiringLruCache` with a given size limit and /// pre-allocated backing data. #[must_use] - pub fn with_size(size: usize) -> ExpiringLruCache { + pub fn with_max_size(size: usize) -> ExpiringLruCache { + let mut store = LruCache::with_max_size(size); + store.disable_hit_miss_tracking(); ExpiringLruCache { - store: LruCache::with_size(size), + store, hits: AtomicU64::new(0), misses: AtomicU64::new(0), evictions: AtomicU64::new(0), @@ -188,6 +204,36 @@ impl ExpiringLruCache { } } + /// Creates a new `ExpiringLruCache` with a given size limit and + /// pre-allocated backing data. + /// + /// # Errors + /// + /// Returns [`BuildError`](super::BuildError) if size is 0, capacity + /// overflows, or pre-allocation fails. + pub fn try_with_max_size(size: usize) -> Result, super::BuildError> { + let mut store = LruCache::try_with_max_size(size)?; + store.disable_hit_miss_tracking(); + Ok(ExpiringLruCache { + store, + hits: AtomicU64::new(0), + misses: AtomicU64::new(0), + evictions: AtomicU64::new(0), + on_evict: None, + }) + } + + /// Returns the maximum number of entries this cache will hold before evicting. + /// + /// This is the bound set via [`ExpiringLruCacheBuilder::max_size`] / [`with_max_size`](Self::with_max_size), + /// not the current number of entries — use [`cache_size`](crate::Cached::cache_size) for that. + #[doc(alias = "size")] + #[doc(alias = "max_size")] + #[must_use] + pub fn capacity(&self) -> usize { + self.store.capacity() + } + /// Returns a reference to the inner [`LruCache`]. #[must_use] pub fn store(&self) -> &LruCache { @@ -199,7 +245,7 @@ impl ExpiringLruCache { let on_evict = &self.on_evict; let evictions = &self.evictions; let mut removed = 0; - self.store.retain(|key, value| { + self.store.retain_silent(|key, value| { if value.is_expired() { if let Some(on_evict) = on_evict { on_evict(key, value); @@ -213,6 +259,35 @@ impl ExpiringLruCache { }); removed } + + /// Remove all entries and fire the `on_evict` callback for each one, incrementing the + /// evictions counter. + /// + /// Unlike [`cache_clear`](crate::Cached::cache_clear) (which removes entries silently), + /// this method invokes `on_evict` for every removed entry (whether or not they had expired) + /// and increments `evictions`. If no `on_evict` callback was configured, it falls back to + /// the plain `cache_clear`. + pub fn cache_clear_with_on_evict(&mut self) { + if self.on_evict.is_none() { + return self.cache_clear(); + } + let keys = self.store.key_order(); + let mut removed = Vec::with_capacity(keys.len()); + for k in &keys { + if let Some(pair) = self.store.pop_raw(k) { + removed.push(pair); + } + } + let count = removed.len() as u64; + if count > 0 { + self.evictions.fetch_add(count, Ordering::Relaxed); + } + if let Some(on_evict) = &self.on_evict { + for (k, v) in &removed { + on_evict(k, v); + } + } + } } // https://docs.rs/cached/latest/cached/trait.Cached.html @@ -231,7 +306,7 @@ impl Cached for ExpiringLruCache { Some(&self.store.order.get(index).1) } else { self.misses.fetch_add(1, Ordering::Relaxed); - if let Some((key, old)) = self.store.cache_remove_entry(k) { + if let Some((key, old)) = self.store.pop_raw(k) { if let Some(on_evict) = &self.on_evict { on_evict(&key, &old); } @@ -259,7 +334,7 @@ impl Cached for ExpiringLruCache { Some(&mut self.store.order.get_mut(index).1) } else { self.misses.fetch_add(1, Ordering::Relaxed); - if let Some((k, old)) = self.store.cache_remove_entry(key) { + if let Some((k, old)) = self.store.pop_raw(key) { if let Some(on_evict) = &self.on_evict { on_evict(&k, &old); } @@ -322,8 +397,26 @@ impl Cached for ExpiringLruCache { K: std::borrow::Borrow, Q: std::hash::Hash + Eq + ?Sized, { - self.store.remove(k) + self.cache_remove_entry(k) + .and_then(|(_, v)| if v.is_expired() { None } else { Some(v) }) } + + fn cache_remove_entry(&mut self, k: &Q) -> Option<(K, V)> + where + K: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized, + { + if let Some((stored_k, v)) = self.store.pop_raw(k) { + if let Some(on_evict) = &self.on_evict { + on_evict(&stored_k, &v); + } + self.evictions.fetch_add(1, Ordering::Relaxed); + Some((stored_k, v)) + } else { + None + } + } + fn cache_clear(&mut self) { self.store.clear(); } @@ -331,7 +424,7 @@ impl Cached for ExpiringLruCache { // 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::with_size(capacity); + self.store = LruCache::with_max_size(capacity); self.store.on_evict = on_evict; self.cache_reset_metrics(); } @@ -498,6 +591,8 @@ impl CacheEvict for ExpiringLruCach /// Expiring Value Cache tests mod tests { use super::*; + use crate::Cached; + use std::sync::atomic::{AtomicU64, Ordering}; type ExpiredU8 = u8; @@ -509,7 +604,7 @@ mod tests { #[test] fn expiring_value_cache_get_miss() { - let mut c: ExpiringLruCache = ExpiringLruCache::with_size(3); + let mut c: ExpiringLruCache = ExpiringLruCache::with_max_size(3); // Getting a non-existent cache key. assert!(c.get(&1).is_none()); @@ -522,14 +617,38 @@ mod tests { // Regression: `ExpiringLruCache` is size-bounded, so it must report a // capacity like the other bounded stores (was falling through to the // `Cached` default `None`, making `metrics().capacity` inaccurate). - let c: ExpiringLruCache = ExpiringLruCache::with_size(7); + let c: ExpiringLruCache = ExpiringLruCache::with_max_size(7); assert_eq!(c.cache_capacity(), Some(7)); assert_eq!(c.metrics().capacity, Some(7)); } + #[test] + fn capacity_returns_bound_not_live_size() { + let mut c: ExpiringLruCache = ExpiringLruCache::with_max_size(3); + assert_eq!(c.capacity(), 3); + assert_eq!(c.cache_size(), 0); + + c.cache_set(1, 5); + c.cache_set(2, 6); + assert_eq!(c.capacity(), 3); + assert_eq!(c.cache_size(), 2); + + // Eviction past the bound keeps capacity fixed while live count stays capped. + c.cache_set(3, 7); + c.cache_set(4, 8); + assert_eq!(c.capacity(), 3); + assert_eq!(c.cache_size(), 3); + } + + #[test] + fn try_with_max_size_rejects_zero() { + let result = ExpiringLruCache::::try_with_max_size(0); + assert!(result.is_err()); + } + #[test] fn expiring_value_cache_get_hit() { - let mut c: ExpiringLruCache = ExpiringLruCache::with_size(3); + let mut c: ExpiringLruCache = ExpiringLruCache::with_max_size(3); // Getting a cached value. assert!(c.set(1, 2).is_none()); @@ -540,7 +659,7 @@ mod tests { #[test] fn expiring_value_cache_get_expired() { - let mut c: ExpiringLruCache = ExpiringLruCache::with_size(3); + let mut c: ExpiringLruCache = ExpiringLruCache::with_max_size(3); assert!(c.set(2, 12).is_none()); @@ -551,7 +670,7 @@ mod tests { #[test] fn expiring_value_cache_get_mut_miss() { - let mut c: ExpiringLruCache = ExpiringLruCache::with_size(3); + let mut c: ExpiringLruCache = ExpiringLruCache::with_max_size(3); // Getting a non-existent cache key. assert!(c.cache_get_mut(&1).is_none()); @@ -561,7 +680,7 @@ mod tests { #[test] fn expiring_value_cache_get_mut_hit() { - let mut c: ExpiringLruCache = ExpiringLruCache::with_size(3); + let mut c: ExpiringLruCache = ExpiringLruCache::with_max_size(3); // Getting a cached value. assert!(c.set(1, 2).is_none()); @@ -572,7 +691,7 @@ mod tests { #[test] fn expiring_value_cache_get_mut_expired() { - let mut c: ExpiringLruCache = ExpiringLruCache::with_size(3); + let mut c: ExpiringLruCache = ExpiringLruCache::with_max_size(3); assert!(c.set(2, 12).is_none()); @@ -583,7 +702,7 @@ mod tests { #[test] fn expiring_value_cache_get_or_set_with_missing() { - let mut c: ExpiringLruCache = ExpiringLruCache::with_size(3); + let mut c: ExpiringLruCache = ExpiringLruCache::with_max_size(3); assert_eq!(c.cache_get_or_set_with(1, || 1), &1); assert_eq!(c.cache_hits(), Some(0)); @@ -592,7 +711,7 @@ mod tests { #[test] fn expiring_value_cache_get_or_set_with_present() { - let mut c: ExpiringLruCache = ExpiringLruCache::with_size(3); + let mut c: ExpiringLruCache = ExpiringLruCache::with_max_size(3); assert!(c.set(1, 5).is_none()); // Existing value is returned rather than setting new value. @@ -603,7 +722,7 @@ mod tests { #[test] fn expiring_value_cache_get_or_set_with_expired() { - let mut c: ExpiringLruCache = ExpiringLruCache::with_size(3); + let mut c: ExpiringLruCache = ExpiringLruCache::with_max_size(3); assert!(c.set(1, 11).is_none()); // New value is returned as existing had expired. @@ -614,7 +733,7 @@ mod tests { #[test] fn expiring_value_cache_try_get_or_set_with_missing() { - let mut c: ExpiringLruCache = ExpiringLruCache::with_size(3); + let mut c: ExpiringLruCache = ExpiringLruCache::with_max_size(3); assert_eq!( c.cache_try_get_or_set_with(1, || Ok::<_, ()>(1)), @@ -637,7 +756,7 @@ mod tests { #[test] fn evict_expired() { - let mut c: ExpiringLruCache = ExpiringLruCache::with_size(3); + let mut c: ExpiringLruCache = ExpiringLruCache::with_max_size(3); assert_eq!(c.set(1, 100), None); assert_eq!(c.set(1, 200), Some(100)); @@ -655,7 +774,7 @@ mod tests { let evicted = Arc::new(AtomicU64::new(0)); let evicted_for_callback = evicted.clone(); let mut c: ExpiringLruCache = ExpiringLruCache::builder() - .size(1) + .max_size(1) .on_evict(move |_key: &u8, _value: &ExpiredU8| { evicted_for_callback.fetch_add(1, Ordering::Relaxed); }) @@ -678,7 +797,7 @@ mod tests { #[test] fn cache_get_with_expiry_status_does_not_promote_expired_entry() { // Build a capacity-2 cache. Insert A then B, making B the MRU entry. - let mut c: ExpiringLruCache = ExpiringLruCache::with_size(2); + let mut c: ExpiringLruCache = ExpiringLruCache::with_max_size(2); c.set(1, 100); // A — value 100 > 10, so it is expired c.set(2, 100); // B — also expired @@ -704,6 +823,54 @@ mod tests { assert!(c.get(&3u8).is_some(), "key 3 (C) should be live"); } + #[test] + fn cache_clear_with_on_evict_fires_for_all_entries() { + use std::sync::atomic::{AtomicUsize, Ordering as AOrdering}; + use std::sync::Arc; + let count = Arc::new(AtomicUsize::new(0)); + let count2 = count.clone(); + let mut c: ExpiringLruCache = ExpiringLruCache::builder() + .max_size(5) + .on_evict(move |_k: &u8, _v: &ExpiredU8| { + count2.fetch_add(1, AOrdering::Relaxed); + }) + .build(); + c.cache_set(1, 5); // live (value <= 10) + c.cache_set(2, 12); // expired (value > 10) + c.cache_set(3, 8); // live + c.cache_clear_with_on_evict(); + assert_eq!(c.cache_size(), 0); + assert_eq!( + count.load(AOrdering::Relaxed), + 3, + "on_evict fires for all entries including expired" + ); + assert_eq!(c.evictions.load(AOrdering::Relaxed), 3); + } + + #[test] + fn cache_clear_does_not_fire_on_evict() { + use std::sync::atomic::{AtomicUsize, Ordering as AOrdering}; + use std::sync::Arc; + let count = Arc::new(AtomicUsize::new(0)); + let count2 = count.clone(); + let mut c: ExpiringLruCache = ExpiringLruCache::builder() + .max_size(5) + .on_evict(move |_k: &u8, _v: &ExpiredU8| { + count2.fetch_add(1, AOrdering::Relaxed); + }) + .build(); + c.cache_set(1, 5); + c.cache_set(2, 8); + c.cache_clear(); + assert_eq!(c.cache_size(), 0); + assert_eq!( + count.load(AOrdering::Relaxed), + 0, + "cache_clear must not fire on_evict" + ); + } + #[test] fn cache_reset_does_not_fire_on_evict() { use std::sync::atomic::{AtomicUsize, Ordering}; @@ -711,7 +878,7 @@ mod tests { let evict_count = Arc::new(AtomicUsize::new(0)); let evict_count2 = evict_count.clone(); let mut c: ExpiringLruCache = ExpiringLruCache::builder() - .size(4) + .max_size(4) .on_evict(move |_k, _v| { evict_count2.fetch_add(1, Ordering::Relaxed); }) @@ -730,7 +897,7 @@ mod tests { #[test] fn test_expiring_value_cache_iter_excludes_expired() { - let mut c: ExpiringLruCache = ExpiringLruCache::with_size(3); + let mut c: ExpiringLruCache = ExpiringLruCache::with_max_size(3); c.cache_set(1, 5); // live c.cache_set(2, 12); // expired (value > 10) c.cache_set(3, 8); // live @@ -742,7 +909,7 @@ mod tests { #[test] fn test_expiring_value_cache_clone() { - let mut c: ExpiringLruCache = ExpiringLruCache::with_size(3); + let mut c: ExpiringLruCache = ExpiringLruCache::with_max_size(3); c.cache_set(1, 5); c.cache_set(2, 6); @@ -754,7 +921,7 @@ mod tests { #[test] fn test_expiring_value_cache_debug() { - let c: ExpiringLruCache = ExpiringLruCache::with_size(3); + let c: ExpiringLruCache = ExpiringLruCache::with_max_size(3); let debug_str = format!("{:?}", c); assert!(debug_str.contains("ExpiringLruCache")); assert!(debug_str.contains("hits")); @@ -764,7 +931,7 @@ mod tests { #[test] fn test_expiring_value_cache_remove_and_clear() { - let mut c: ExpiringLruCache = ExpiringLruCache::with_size(3); + let mut c: ExpiringLruCache = ExpiringLruCache::with_max_size(3); c.cache_set(1, 5); c.cache_set(2, 6); @@ -775,4 +942,84 @@ mod tests { c.cache_clear(); assert_eq!(c.cache_size(), 0); } + + #[test] + fn cache_remove_entry_returns_some_for_live_entry() { + let mut c: ExpiringLruCache = ExpiringLruCache::with_max_size(4); + c.cache_set(1, 5); // not expired: 5 <= 10 + let removed = c.cache_remove_entry(&1u8); + assert_eq!(removed, Some((1u8, 5u8))); + assert_eq!(c.cache_size(), 0); + } + + #[test] + fn cache_remove_entry_returns_some_for_expired_entry() { + let mut c: ExpiringLruCache = ExpiringLruCache::with_max_size(4); + c.cache_set(1, 20u8); // expired: 20 > 10 + + // cache_remove returns None for an expired entry. + c.cache_set(2, 20u8); + assert_eq!(c.cache_remove(&2u8), None); // expired + + // cache_remove_entry returns Some even for an expired entry. + let removed = c.cache_remove_entry(&1u8); + assert_eq!( + removed.expect("cache_remove_entry must return Some for expired entry"), + (1u8, 20u8) + ); + } + + #[test] + fn cache_delete_returns_true_for_expired_entry() { + let mut c: ExpiringLruCache = ExpiringLruCache::with_max_size(4); + c.cache_set(1, 20u8); // expired + assert!( + c.cache_delete(&1u8), + "cache_delete must return true for expired entry" + ); + assert!(!c.cache_delete(&1u8), "cache_delete false when absent"); + } + + #[test] + fn cache_remove_entry_fires_on_evict_for_expired() { + let count = std::sync::Arc::new(AtomicU64::new(0)); + let count2 = count.clone(); + let mut c = ExpiringLruCache::builder() + .max_size(4) + .on_evict(move |_k: &u8, _v: &ExpiredU8| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + c.cache_set(1u8, 20u8); // expired + + c.cache_remove_entry(&1u8); + assert_eq!( + count.load(Ordering::Relaxed), + 1, + "on_evict fires for expired entries" + ); + + c.cache_remove_entry(&99u8); + assert_eq!(count.load(Ordering::Relaxed), 1, "no fire for absent key"); + } + + #[test] + fn cache_remove_entry_absent_returns_none() { + let mut c: ExpiringLruCache = ExpiringLruCache::with_max_size(4); + assert_eq!(c.cache_remove_entry(&42u8), None); + } + + #[test] + fn cache_remove_entry_increments_eviction_counter() { + let mut c: ExpiringLruCache = ExpiringLruCache::with_max_size(4); + 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 + assert_eq!( + c.cache_evictions().expect("evictions are always tracked") - before, + 1, + "cache_remove_entry must increment evictions for present key only" + ); + } } diff --git a/src/stores/lru.rs b/src/stores/lru.rs index 8d26f32d..66614660 100644 --- a/src/stores/lru.rs +++ b/src/stores/lru.rs @@ -35,6 +35,10 @@ pub struct LruCache { pub(super) misses: AtomicU64, pub(super) evictions: AtomicU64, pub(super) on_evict: Option>, + /// When false, `get_if` / `get_mut_if` / `get_or_set_with_if` skip incrementing `hits` and + /// `misses`. Used by wrapper stores that maintain their own counters and delegate to this + /// cache solely for LRU ordering / storage — avoids a redundant atomic op per access. + pub(crate) track_hit_miss: bool, } impl Clone for LruCache @@ -52,6 +56,7 @@ where misses: AtomicU64::new(self.misses.load(Ordering::Relaxed)), evictions: AtomicU64::new(self.evictions.load(Ordering::Relaxed)), on_evict: self.on_evict.clone(), + track_hit_miss: self.track_hit_miss, } } } @@ -100,15 +105,19 @@ pub struct LruCacheBuilder { impl LruCacheBuilder { /// Set the maximum number of entries. Required — panics at build time if not set. - #[doc(alias = "max_size")] + #[doc(alias = "size")] #[doc(alias = "capacity")] #[must_use] - pub fn size(mut self, size: usize) -> Self { - self.size = Some(size); + pub fn max_size(mut self, max_size: usize) -> Self { + self.size = Some(max_size); self } /// Set a callback to be invoked when an entry is evicted. + /// + /// Use [`cache_clear_with_on_evict`](LruCache::cache_clear_with_on_evict) + /// instead of [`cache_clear`](crate::Cached::cache_clear) to opt into callback + /// firing and eviction counter increments when clearing all entries. #[must_use] pub fn on_evict(mut self, on_evict: impl Fn(&K, &V) + Send + Sync + 'static) -> Self { self.on_evict = Some(Arc::new(on_evict)); @@ -119,7 +128,7 @@ impl LruCacheBuilder { /// /// # Panics /// - /// Panics if `size` was not set or is `0`. + /// Panics if `max_size` was not set or is `0`. #[must_use] pub fn build(self) -> LruCache where @@ -127,8 +136,8 @@ impl LruCacheBuilder { { let size = self .size - .expect("`LruCacheBuilder` requires `size` to be set"); - let mut cache = LruCache::with_size(size); + .expect("`LruCacheBuilder` requires `max_size` to be set"); + let mut cache = LruCache::with_max_size(size); cache.on_evict = self.on_evict; cache } @@ -137,15 +146,15 @@ impl LruCacheBuilder { /// /// # Errors /// - /// Returns [`BuildError`](super::BuildError) if `size` was not set or is `0`. + /// Returns [`BuildError`](super::BuildError) if `max_size` was not set or is `0`. pub fn try_build(self) -> Result, super::BuildError> where K: Hash + Eq + Clone, { let size = self .size - .ok_or(super::BuildError::MissingRequired("size"))?; - let mut cache = LruCache::try_with_size(size)?; + .ok_or(super::BuildError::MissingRequired("max_size"))?; + let mut cache = LruCache::try_with_max_size(size)?; cache.on_evict = self.on_evict; Ok(cache) } @@ -167,9 +176,9 @@ impl LruCache { /// /// Will panic if size is 0 #[must_use] - pub fn with_size(size: usize) -> LruCache { + pub fn with_max_size(size: usize) -> LruCache { if size == 0 { - panic!("`size` of `LruCache` must be greater than zero."); + panic!("`max_size` of `LruCache` must be greater than zero."); } LruCache { store: HashTable::with_capacity(size), @@ -180,6 +189,7 @@ impl LruCache { misses: AtomicU64::new(0), evictions: AtomicU64::new(0), on_evict: None, + track_hit_miss: true, } } @@ -189,10 +199,10 @@ impl LruCache { /// /// Will return a [`BuildError`](super::BuildError) if size is 0, capacity /// overflows, or pre-allocation fails. - pub fn try_with_size(size: usize) -> Result, super::BuildError> { + pub fn try_with_max_size(size: usize) -> Result, super::BuildError> { if size == 0 { return Err(super::BuildError::InvalidValue { - field: "size", + field: "max_size", reason: "must be greater than zero", }); } @@ -204,7 +214,7 @@ impl LruCache { hasher.finish() }) { return Err(super::BuildError::InvalidValue { - field: "size", + field: "max_size", reason: "allocation failed", }); } @@ -218,9 +228,29 @@ impl LruCache { misses: AtomicU64::new(0), evictions: AtomicU64::new(0), on_evict: None, + track_hit_miss: true, }) } + /// Disable hit/miss counter increments on this cache. + /// + /// Called by wrapper stores (`LruTtlCache`, `ExpiringLruCache`, and the sharded equivalents) + /// that maintain their own counters and use this cache solely for LRU ordering / storage. + pub(crate) fn disable_hit_miss_tracking(&mut self) { + self.track_hit_miss = false; + } + + /// Returns the maximum number of entries this cache will hold before evicting. + /// + /// This is the bound set via [`LruCacheBuilder::max_size`] / [`with_max_size`](Self::with_max_size), + /// not the current number of entries — use [`cache_size`](crate::Cached::cache_size) for that. + #[doc(alias = "size")] + #[doc(alias = "max_size")] + #[must_use] + pub fn capacity(&self) -> usize { + self.capacity + } + /// 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 @@ -248,7 +278,7 @@ impl LruCache { self.order.iter().map(|(_k, v)| v.clone()).collect() } - pub(super) fn cache_remove_entry(&mut self, k: &Q) -> Option<(K, V)> + pub(super) fn pop_raw(&mut self, k: &Q) -> Option<(K, V)> where K: Borrow, Q: Hash + Eq + ?Sized, @@ -333,11 +363,15 @@ impl LruCache { if let Some(index) = self.get_index(self.hash(key), key) { if is_valid(&self.order.get(index).1) { self.order.move_to_front(index); - self.hits.fetch_add(1, Ordering::Relaxed); + if self.track_hit_miss { + self.hits.fetch_add(1, Ordering::Relaxed); + } return Some(&self.order.get(index).1); } } - self.misses.fetch_add(1, Ordering::Relaxed); + if self.track_hit_miss { + self.misses.fetch_add(1, Ordering::Relaxed); + } None } @@ -353,11 +387,15 @@ impl LruCache { if let Some(index) = self.get_index(self.hash(key), key) { if is_valid(&self.order.get(index).1) { self.order.move_to_front(index); - self.hits.fetch_add(1, Ordering::Relaxed); + if self.track_hit_miss { + self.hits.fetch_add(1, Ordering::Relaxed); + } return Some(&mut self.order.get_mut(index).1); } } - self.misses.fetch_add(1, Ordering::Relaxed); + if self.track_hit_miss { + self.misses.fetch_add(1, Ordering::Relaxed); + } None } @@ -374,10 +412,12 @@ impl LruCache { let v = &self.order.get(index).1; !is_valid(v) }; - if replace_existing { - self.misses.fetch_add(1, Ordering::Relaxed); - } else { - self.hits.fetch_add(1, Ordering::Relaxed); + if self.track_hit_miss { + if replace_existing { + self.misses.fetch_add(1, Ordering::Relaxed); + } else { + self.hits.fetch_add(1, Ordering::Relaxed); + } } let old_val = if replace_existing { self.order.set(index, (key, f())).map(|(_, v)| v) @@ -392,7 +432,9 @@ impl LruCache { &mut self.order.get_mut(index).1, ) } else { - self.misses.fetch_add(1, Ordering::Relaxed); + if self.track_hit_miss { + self.misses.fetch_add(1, Ordering::Relaxed); + } let index = self.order.push_front((key, f())); self.insert_index(hash, index); self.check_capacity(); @@ -413,10 +455,12 @@ impl LruCache { let v = &self.order.get(index).1; !is_valid(v) }; - if replace_existing { - self.misses.fetch_add(1, Ordering::Relaxed); - } else { - self.hits.fetch_add(1, Ordering::Relaxed); + if self.track_hit_miss { + if replace_existing { + self.misses.fetch_add(1, Ordering::Relaxed); + } else { + self.hits.fetch_add(1, Ordering::Relaxed); + } } let old_val = if replace_existing { let new_val = f()?; @@ -432,7 +476,9 @@ impl LruCache { &mut self.order.get_mut(index).1, )) } else { - self.misses.fetch_add(1, Ordering::Relaxed); + if self.track_hit_miss { + self.misses.fetch_add(1, Ordering::Relaxed); + } let index = self.order.push_front((key, f()?)); self.insert_index(hash, index); self.check_capacity(); @@ -440,6 +486,9 @@ impl LruCache { } } + /// Removes entries for which `keep` returns `false`. + /// Each removed entry fires the configured `on_evict` callback and is counted in `evictions`, + /// matching [`Cached::cache_remove`] semantics. pub fn retain bool>(&mut self, mut keep: F) { let remove_keys = { self.order @@ -448,7 +497,50 @@ impl LruCache { .collect::>() }; for k in remove_keys { - self.remove(&k); + self.cache_remove(&k); + } + } + + /// Removes entries for which `keep` returns `false` without firing `on_evict` or + /// incrementing `evictions`. Used internally by TTL/expiring wrapper stores to avoid + /// double-counting when those wrappers handle eviction side effects themselves. + pub(super) fn retain_silent bool>(&mut self, mut keep: F) { + let remove_keys = { + self.order + .iter() + .filter_map(|(k, v)| if keep(k, v) { None } else { Some(k.clone()) }) + .collect::>() + }; + for k in remove_keys { + self.pop_raw(&k); + } + } + + /// Remove all entries and fire the `on_evict` callback for each one, incrementing the + /// evictions counter. + /// + /// Unlike [`cache_clear`](crate::Cached::cache_clear) (which removes entries silently), + /// this method invokes `on_evict` for every removed entry and increments `evictions`. If no + /// `on_evict` callback was configured, it falls back to the plain `cache_clear`. + pub fn cache_clear_with_on_evict(&mut self) { + if self.on_evict.is_none() { + return self.cache_clear(); + } + let keys = self.key_order(); + let mut removed = Vec::with_capacity(keys.len()); + for k in &keys { + if let Some(pair) = self.pop_raw(k) { + removed.push(pair); + } + } + if !removed.is_empty() { + self.evictions + .fetch_add(removed.len() as u64, Ordering::Relaxed); + } + if let Some(on_evict) = &self.on_evict { + for (k, v) in &removed { + on_evict(k, v); + } } } } @@ -474,10 +566,12 @@ where let index = self.get_index(hash, &key); if let Some(index) = index { let replace_existing = { !is_valid(&self.order.get(index).1) }; - if replace_existing { - self.misses.fetch_add(1, Ordering::Relaxed); - } else { - self.hits.fetch_add(1, Ordering::Relaxed); + if self.track_hit_miss { + if replace_existing { + self.misses.fetch_add(1, Ordering::Relaxed); + } else { + self.hits.fetch_add(1, Ordering::Relaxed); + } } let old_val = if replace_existing { let new_val = f().await; @@ -493,7 +587,9 @@ where &mut self.order.get_mut(index).1, ) } else { - self.misses.fetch_add(1, Ordering::Relaxed); + if self.track_hit_miss { + self.misses.fetch_add(1, Ordering::Relaxed); + } let new_val = f().await; let index = self.order.push_front((key, new_val)); self.insert_index(hash, index); @@ -518,10 +614,12 @@ where let index = self.get_index(hash, &key); if let Some(index) = index { let replace_existing = { !is_valid(&self.order.get(index).1) }; - if replace_existing { - self.misses.fetch_add(1, Ordering::Relaxed); - } else { - self.hits.fetch_add(1, Ordering::Relaxed); + if self.track_hit_miss { + if replace_existing { + self.misses.fetch_add(1, Ordering::Relaxed); + } else { + self.hits.fetch_add(1, Ordering::Relaxed); + } } let old_val = if replace_existing { let new_val = f().await?; @@ -537,7 +635,9 @@ where &mut self.order.get_mut(index).1, )) } else { - self.misses.fetch_add(1, Ordering::Relaxed); + if self.track_hit_miss { + self.misses.fetch_add(1, Ordering::Relaxed); + } let new_val = f().await?; let index = self.order.push_front((key, new_val)); self.insert_index(hash, index); @@ -596,8 +696,24 @@ impl Cached for LruCache { K: Borrow, Q: Hash + Eq + ?Sized, { - self.cache_remove_entry(k).map(|(_key, value)| value) + >::cache_remove_entry(self, k).map(|(_, v)| v) + } + + fn cache_remove_entry(&mut self, k: &Q) -> Option<(K, V)> + where + K: Borrow, + Q: Hash + Eq + ?Sized, + { + let removed = self.pop_raw(k); + if let Some((ref key, ref value)) = removed { + if let Some(on_evict) = &self.on_evict { + on_evict(key, value); + } + self.evictions.fetch_add(1, Ordering::Relaxed); + } + removed } + fn cache_clear(&mut self) { self.store.clear(); self.order.clear(); @@ -701,7 +817,7 @@ mod tests { #[test] fn sized_cache() { - let mut c = LruCache::with_size(5); + let mut c = LruCache::with_max_size(5); assert!(c.get(&1).is_none()); assert_eq!(1, c.cache_misses().unwrap()); @@ -741,7 +857,7 @@ mod tests { struct MyKey { v: String, } - let mut c = LruCache::with_size(5); + let mut c = LruCache::with_max_size(5); assert_eq!( c.cache_set( MyKey { @@ -782,7 +898,7 @@ mod tests { #[test] fn peek_does_not_update_recency_or_metrics() { - let mut c = LruCache::with_size(2); + let mut c = LruCache::with_max_size(2); c.set(1, 10); c.set(2, 20); c.cache_reset_metrics(); @@ -800,23 +916,29 @@ mod tests { #[test] fn try_new() { - let c = LruCache::::try_with_size(0); + let c = LruCache::::try_with_max_size(0); assert!(matches!( c.unwrap_err(), - super::super::BuildError::InvalidValue { field: "size", .. } + super::super::BuildError::InvalidValue { + field: "max_size", + .. + } )); - let c = LruCache::::try_with_size(usize::MAX); + let c = LruCache::::try_with_max_size(usize::MAX); assert!(matches!( c.unwrap_err(), - super::super::BuildError::InvalidValue { field: "size", .. } + super::super::BuildError::InvalidValue { + field: "max_size", + .. + } )); } #[test] fn size_cache_racing_keys_eviction_regression() { // Regression: duplicate keys in the internal `order` caused wrong eviction. See issue #7. - let mut c = LruCache::with_size(2); + let mut c = LruCache::with_max_size(2); assert_eq!(c.set(1, 100), None); assert_eq!(c.set(1, 100), Some(100)); // size would be 1, but internal order would be [1, 1] before the fix @@ -828,7 +950,7 @@ mod tests { #[test] fn clear() { - let mut c = LruCache::with_size(3); + let mut c = LruCache::with_max_size(3); assert_eq!(c.set(1, 100), None); assert_eq!(c.set(2, 200), None); assert_eq!(c.set(3, 300), None); @@ -836,10 +958,29 @@ mod tests { assert_eq!(0, c.cache_size()); } + #[test] + fn capacity_returns_bound_not_live_size() { + let mut c = LruCache::with_max_size(3); + // The bound is fixed at construction and independent of live count. + assert_eq!(c.capacity(), 3); + assert_eq!(c.cache_size(), 0); + + c.set(1, 100); + c.set(2, 200); + assert_eq!(c.capacity(), 3); + assert_eq!(c.cache_size(), 2); + + // Eviction past the bound keeps capacity fixed while live count stays capped. + c.set(3, 300); + c.set(4, 400); + assert_eq!(c.capacity(), 3); + assert_eq!(c.cache_size(), 3); + } + #[test] fn reset() { let init_capacity = 2; - let mut c = LruCache::with_size(init_capacity); + let mut c = LruCache::with_max_size(init_capacity); for i in 0..128 { assert_eq!(c.set(i, i), None); } @@ -850,7 +991,7 @@ mod tests { #[test] fn remove() { - let mut c = LruCache::with_size(3); + let mut c = LruCache::with_max_size(3); assert_eq!(c.set(1, 100), None); assert_eq!(c.set(2, 200), None); assert_eq!(c.set(3, 300), None); @@ -870,7 +1011,7 @@ mod tests { #[test] fn sized_cache_get_mut() { - let mut c = LruCache::with_size(5); + let mut c = LruCache::with_max_size(5); assert!(c.cache_get_mut(&1).is_none()); assert_eq!(1, c.cache_misses().unwrap()); @@ -888,7 +1029,7 @@ mod tests { #[test] fn sized_cache_eviction_fix() { - let mut cache = LruCache::::with_size(3); + let mut cache = LruCache::::with_max_size(3); cache.set(1, ()); cache.set(2, ()); cache.set(3, ()); @@ -912,7 +1053,7 @@ mod tests { #[test] fn get_or_set_with() { - let mut c = LruCache::with_size(5); + let mut c = LruCache::with_max_size(5); for i in 0..=5usize { assert_eq!(c.cache_get_or_set_with(i, || i), &i); } @@ -947,7 +1088,7 @@ mod tests { #[test] fn retain() { - let mut c = LruCache::with_size(5); + let mut c = LruCache::with_max_size(5); for i in 0i32..5 { c.set(i, i * 10); } @@ -963,7 +1104,7 @@ mod tests { #[test] fn key_order_and_value_order() { - let mut c = LruCache::with_size(3); + let mut c = LruCache::with_max_size(3); c.set(1, 10); c.set(2, 20); c.set(3, 30); @@ -977,7 +1118,7 @@ mod tests { #[test] fn sized_cache_clone_is_independent() { - let mut c = LruCache::with_size(3); + let mut c = LruCache::with_max_size(3); c.set(1, 100); c.set(2, 200); let mut c2 = c.clone(); @@ -991,7 +1132,7 @@ mod tests { #[tokio::test] async fn test_async_trait() { use crate::CachedAsync; - let mut c = LruCache::with_size(5); + let mut c = LruCache::with_max_size(5); async fn _get(n: usize) -> usize { n @@ -1069,6 +1210,50 @@ mod tests { assert_eq!(res.unwrap(), &1); } + #[test] + fn cache_clear_with_on_evict_fires_for_all_entries() { + use std::sync::atomic::{AtomicUsize, Ordering as AOrdering}; + use std::sync::Arc; + let count = Arc::new(AtomicUsize::new(0)); + let count2 = count.clone(); + let mut c = LruCache::builder() + .max_size(5) + .on_evict(move |_k: &u32, _v: &u32| { + count2.fetch_add(1, AOrdering::Relaxed); + }) + .build(); + c.cache_set(1, 10); + c.cache_set(2, 20); + c.cache_set(3, 30); + c.cache_clear_with_on_evict(); + assert_eq!(c.cache_size(), 0); + assert_eq!(count.load(AOrdering::Relaxed), 3); + assert_eq!(c.cache_evictions(), Some(3)); + } + + #[test] + fn cache_clear_does_not_fire_on_evict() { + use std::sync::atomic::{AtomicUsize, Ordering as AOrdering}; + use std::sync::Arc; + let count = Arc::new(AtomicUsize::new(0)); + let count2 = count.clone(); + let mut c = LruCache::builder() + .max_size(5) + .on_evict(move |_k: &u32, _v: &u32| { + count2.fetch_add(1, AOrdering::Relaxed); + }) + .build(); + c.cache_set(1, 10); + c.cache_set(2, 20); + c.cache_clear(); + assert_eq!(c.cache_size(), 0); + assert_eq!( + count.load(AOrdering::Relaxed), + 0, + "cache_clear must not fire on_evict" + ); + } + #[test] fn cache_reset_does_not_fire_on_evict() { use std::sync::atomic::{AtomicUsize, Ordering}; @@ -1076,7 +1261,7 @@ mod tests { let evict_count = Arc::new(AtomicUsize::new(0)); let evict_count2 = evict_count.clone(); let mut c = LruCache::builder() - .size(4) + .max_size(4) .on_evict(move |_k, _v| { evict_count2.fetch_add(1, Ordering::Relaxed); }) @@ -1095,7 +1280,7 @@ mod tests { #[test] fn test_diagnostics_and_traits() { - let mut cache = LruCache::builder().size(3).build(); + let mut cache = LruCache::builder().max_size(3).build(); cache.cache_set(1, 100); cache.cache_set(2, 200); @@ -1121,12 +1306,72 @@ mod tests { let try_built = builder.try_build(); assert!(try_built.is_err()); // Missing required size - let builder = LruCache::::builder().size(0); + let builder = LruCache::::builder().max_size(0); let try_built = builder.try_build(); assert!(try_built.is_err()); // Size 0 is invalid - // try_with_size errors - let try_built_store = LruCache::::try_with_size(0); + // try_with_max_size errors + let try_built_store = LruCache::::try_with_max_size(0); assert!(try_built_store.is_err()); } + + #[test] + fn cache_remove_entry_basic() { + let mut c = LruCache::try_with_max_size(4).expect("size 4 is valid"); + c.cache_set(1u32, 100u32); + c.cache_set(2u32, 200u32); + + // Returns None for absent key. + assert_eq!(c.cache_remove_entry(&999u32), None); + + // Returns stored key and value. + assert_eq!(c.cache_remove_entry(&1u32), Some((1u32, 100u32))); + + // Entry is gone. + assert_eq!(c.cache_get(&1u32), None); + assert_eq!(c.cache_size(), 1); + } + + #[test] + fn cache_remove_entry_fires_on_evict() { + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::Arc; + let count = Arc::new(AtomicU32::new(0)); + let count2 = count.clone(); + let mut c = LruCache::builder() + .max_size(4) + .on_evict(move |_, _| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + c.cache_set(1u32, 10u32); + c.cache_remove_entry(&1u32); + assert_eq!(count.load(Ordering::Relaxed), 1); + + // No fire for absent key. + c.cache_remove_entry(&999u32); + assert_eq!(count.load(Ordering::Relaxed), 1); + } + + #[test] + fn cache_remove_entry_increments_eviction_counter() { + let mut c = LruCache::try_with_max_size(4).expect("size 4 is valid"); + 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 + assert_eq!( + c.cache_evictions().expect("evictions are always tracked") - before, + 1, + "cache_remove_entry must increment evictions for present key only" + ); + } + + #[test] + fn cache_delete_returns_true_for_present_entry() { + let mut c = LruCache::try_with_max_size(4).expect("size 4 is valid"); + c.cache_set(1u32, 10u32); + assert!(c.cache_delete(&1u32)); + assert!(!c.cache_delete(&1u32)); + } } diff --git a/src/stores/lru_ttl.rs b/src/stores/lru_ttl.rs index 2e0788ae..e47d6bad 100644 --- a/src/stores/lru_ttl.rs +++ b/src/stores/lru_ttl.rs @@ -98,11 +98,11 @@ pub struct LruTtlCacheBuilder { // size / ttl / refresh work regardless of eviction state impl LruTtlCacheBuilder { /// Set the maximum number of entries. Required. - #[doc(alias = "max_size")] + #[doc(alias = "size")] #[doc(alias = "capacity")] #[must_use] - pub fn size(mut self, size: usize) -> Self { - self.size = Some(size); + pub fn max_size(mut self, max_size: usize) -> Self { + self.size = Some(max_size); self } @@ -115,10 +115,16 @@ impl LruTtlCacheBuilder { /// Set whether cache hits refresh the TTL of the accessed entry. #[must_use] - pub fn refresh(mut self, refresh: bool) -> Self { + pub fn refresh_on_hit(mut self, refresh: bool) -> Self { self.refresh = refresh; self } + + /// Alias for [`refresh_on_hit`](Self::refresh_on_hit). + #[must_use] + pub fn refresh(self, refresh: bool) -> Self { + self.refresh_on_hit(refresh) + } } // on_evict transitions the builder from NoEvict → HasEvict @@ -129,6 +135,10 @@ impl LruTtlCacheBuilder { /// `LruTtlCacheBuilder`, which requires `K: 'static` /// and `V: 'static` at [`build`](LruTtlCacheBuilder::build) time so the /// callback can be wired into the inner LRU eviction path. + /// + /// Use [`cache_clear_with_on_evict`](LruTtlCache::cache_clear_with_on_evict) + /// instead of [`cache_clear`](crate::Cached::cache_clear) to opt into callback + /// firing and eviction counter increments when clearing all entries. #[must_use] pub fn on_evict( self, @@ -150,34 +160,30 @@ impl LruTtlCacheBuilder { /// /// # Panics /// - /// Panics if `size` or `ttl` was not set, or if `size` is `0`. + /// Panics if `max_size` or `ttl` was not set, if `ttl` is zero, or if `max_size` is `0`. #[must_use] pub fn build(self) -> LruTtlCache where K: Hash + Eq + Clone, { - let size = self - .size - .expect("`LruTtlCacheBuilder` requires `size` to be set"); - let ttl = self - .ttl - .expect("`LruTtlCacheBuilder` requires `ttl` to be set"); - LruTtlCache::with_size_and_ttl_and_refresh(size, ttl, self.refresh) + self.try_build() + .unwrap_or_else(|e| panic!("LruTtlCache build failed: {e}")) } /// Build the cache, returning an error instead of panicking. /// /// # Errors /// - /// Returns [`BuildError`](super::BuildError) if `size` or `ttl` was not set, or `size` is `0`. + /// Returns [`BuildError`](super::BuildError) if `max_size` or `ttl` was not set, if `ttl` is zero, or if `max_size` is `0`. pub fn try_build(self) -> Result, super::BuildError> where K: Hash + Eq + Clone, { let size = self .size - .ok_or(super::BuildError::MissingRequired("size"))?; + .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) } } @@ -188,30 +194,22 @@ impl LruTtlCacheBuilder { /// /// # Panics /// - /// Panics if `size` or `ttl` was not set, or if `size` is `0`. + /// Panics if `max_size` or `ttl` was not set, if `ttl` is zero, or if `max_size` is `0`. #[must_use] pub fn build(self) -> LruTtlCache where K: Hash + Eq + Clone + 'static, V: 'static, { - let size = self - .size - .expect("`LruTtlCacheBuilder` requires `size` to be set"); - let ttl = self - .ttl - .expect("`LruTtlCacheBuilder` requires `ttl` to be set"); - let mut cache = LruTtlCache::with_size_and_ttl_and_refresh(size, ttl, self.refresh); - cache.on_evict = self.on_evict; - cache.sync_on_evict(); - cache + self.try_build() + .unwrap_or_else(|e| panic!("LruTtlCache build failed: {e}")) } /// Build the cache, returning an error instead of panicking. /// /// # Errors /// - /// Returns [`BuildError`](super::BuildError) if `size` or `ttl` was not set, or `size` is `0`. + /// Returns [`BuildError`](super::BuildError) if `max_size` or `ttl` was not set, if `ttl` is zero, or if `max_size` is `0`. pub fn try_build(self) -> Result, super::BuildError> where K: Hash + Eq + Clone + 'static, @@ -219,8 +217,9 @@ impl LruTtlCacheBuilder { { let size = self .size - .ok_or(super::BuildError::MissingRequired("size"))?; + .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)?; cache.on_evict = self.on_evict; cache.sync_on_evict(); @@ -257,7 +256,8 @@ impl LruTtlCache { } fn new_internal(size: usize, ttl: Duration, refresh: bool) -> Result { - let store = LruCache::try_with_size(size)?; + let mut store = LruCache::try_with_max_size(size)?; + store.disable_hit_miss_tracking(); Ok(LruTtlCache { store, size, @@ -272,8 +272,8 @@ impl LruTtlCache { /// Creates a new `LruTtlCache` with a given size limit and TTL #[must_use] - pub fn with_size_and_ttl(size: usize, ttl: Duration) -> LruTtlCache { - Self::with_size_and_ttl_and_refresh(size, ttl, false) + pub fn with_max_size_and_ttl(size: usize, ttl: Duration) -> LruTtlCache { + Self::with_max_size_and_ttl_and_refresh(size, ttl, false) } /// Creates a new `LruTtlCache` with a given size limit, TTL, and refresh-on-hit flag. @@ -282,7 +282,7 @@ impl LruTtlCache { /// /// Will panic if size is 0 #[must_use] - pub fn with_size_and_ttl_and_refresh( + pub fn with_max_size_and_ttl_and_refresh( size: usize, ttl: Duration, refresh: bool, @@ -295,7 +295,7 @@ impl LruTtlCache { /// # Errors /// /// Will return a [`BuildError`](super::BuildError) if size is 0 or memory allocation fails. - pub fn try_with_size_and_ttl( + pub fn try_with_max_size_and_ttl( size: usize, ttl: Duration, ) -> Result, super::BuildError> { @@ -365,6 +365,17 @@ impl LruTtlCache { .collect() } + /// Returns the maximum number of entries this cache will hold before evicting. + /// + /// This is the bound set via [`LruTtlCacheBuilder::max_size`], not the current number + /// of entries — use [`cache_size`](crate::Cached::cache_size) for that. + #[doc(alias = "size")] + #[doc(alias = "max_size")] + #[must_use] + pub fn capacity(&self) -> usize { + self.size + } + /// Returns whether the ttl is refreshed when the value is retrieved. #[must_use] pub fn refresh_on_hit(&self) -> bool { @@ -388,7 +399,7 @@ impl LruTtlCache { let on_evict = &self.on_evict; let evictions = &self.evictions; let mut removed = 0; - self.store.retain(|key, entry| { + self.store.retain_silent(|key, entry| { if entry.instant.elapsed() < ttl { true } else { @@ -412,7 +423,7 @@ impl LruTtlCache { let ttl = self.ttl; let on_evict = &self.on_evict; let evictions = &self.evictions; - self.store.retain(|key, entry| { + self.store.retain_silent(|key, entry| { let expired = entry.instant.elapsed() >= ttl; if expired || !keep(key, &entry.value) { if let Some(on_evict) = on_evict { @@ -425,6 +436,35 @@ impl LruTtlCache { } }); } + + /// Remove all entries and fire the `on_evict` callback for each one, incrementing the + /// evictions counter. + /// + /// Unlike [`cache_clear`](crate::Cached::cache_clear) (which removes entries silently), + /// this method invokes `on_evict` for every removed entry (whether or not they had expired) + /// and increments `evictions`. If no `on_evict` callback was configured, it falls back to + /// the plain `cache_clear`. + pub fn cache_clear_with_on_evict(&mut self) { + if self.on_evict.is_none() { + return self.cache_clear(); + } + let keys = self.store.key_order(); + let mut removed = Vec::with_capacity(keys.len()); + for k in &keys { + if let Some(pair) = self.store.pop_raw(k) { + removed.push(pair); + } + } + let count = removed.len() as u64; + if count > 0 { + self.evictions.fetch_add(count, Ordering::Relaxed); + } + if let Some(on_evict) = &self.on_evict { + for (k, entry) in &removed { + on_evict(k, &entry.value); + } + } + } } impl Cached for LruTtlCache { @@ -445,7 +485,7 @@ impl Cached for LruTtlCache { Some(&self.store.order.get(index).1.value) } else { self.misses.fetch_add(1, Ordering::Relaxed); - if let Some((k, entry)) = self.store.cache_remove_entry(key) { + if let Some((k, entry)) = self.store.pop_raw(key) { if let Some(on_evict) = &self.on_evict { on_evict(&k, &entry.value); } @@ -476,7 +516,7 @@ impl Cached for LruTtlCache { Some(&mut self.store.order.get_mut(index).1.value) } else { self.misses.fetch_add(1, Ordering::Relaxed); - if let Some((k, entry)) = self.store.cache_remove_entry(key) { + if let Some((k, entry)) = self.store.pop_raw(key) { if let Some(on_evict) = &self.on_evict { on_evict(&k, &entry.value); } @@ -572,22 +612,44 @@ impl Cached for LruTtlCache { K: std::borrow::Borrow, Q: std::hash::Hash + Eq + ?Sized, { - let stamped = self.store.remove(k); - stamped.and_then(|entry| { + if let Some((stored_k, entry)) = self.store.pop_raw(k) { + if let Some(on_evict) = &self.on_evict { + on_evict(&stored_k, &entry.value); + } + self.evictions.fetch_add(1, Ordering::Relaxed); if entry.instant.elapsed() < self.ttl { Some(entry.value) } else { None } - }) + } else { + None + } } + + fn cache_remove_entry(&mut self, k: &Q) -> Option<(K, V)> + where + K: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized, + { + if let Some((stored_k, entry)) = self.store.pop_raw(k) { + if let Some(on_evict) = &self.on_evict { + on_evict(&stored_k, &entry.value); + } + self.evictions.fetch_add(1, Ordering::Relaxed); + Some((stored_k, entry.value)) + } else { + None + } + } + fn cache_clear(&mut self) { self.store.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::with_size(self.size); + self.store = LruCache::with_max_size(self.size); self.store.on_evict = on_evict; self.cache_reset_metrics(); } @@ -805,7 +867,7 @@ mod tests { #[test] fn status_does_not_inflate_inner_store_hits() { - let mut cache = LruTtlCache::with_size_and_ttl(4, Duration::from_secs(60)); + let mut cache = LruTtlCache::with_max_size_and_ttl(4, Duration::from_secs(60)); cache.cache_set(1, 10); cache.cache_set(2, 20); cache.store.cache_reset_metrics(); @@ -824,12 +886,30 @@ mod tests { ); } + #[test] + fn capacity_returns_bound_not_live_size() { + let mut cache = LruTtlCache::with_max_size_and_ttl(3, Duration::from_secs(60)); + assert_eq!(cache.capacity(), 3); + assert_eq!(cache.cache_size(), 0); + + cache.cache_set(1, 10); + cache.cache_set(2, 20); + assert_eq!(cache.capacity(), 3); + assert_eq!(cache.cache_size(), 2); + + // Eviction past the bound keeps capacity fixed while live count stays capped. + cache.cache_set(3, 30); + cache.cache_set(4, 40); + assert_eq!(cache.capacity(), 3); + assert_eq!(cache.cache_size(), 3); + } + #[test] fn reset_rebuilds_store_and_preserves_on_evict() { let evicted = Arc::new(AtomicUsize::new(0)); let evicted_for_callback = evicted.clone(); let mut cache = LruTtlCache::builder() - .size(1) + .max_size(1) .ttl(Duration::from_secs(60)) .on_evict(move |_key: &u8, _value: &u8| { evicted_for_callback.fetch_add(1, AtomicOrdering::Relaxed); @@ -847,19 +927,68 @@ mod tests { #[test] fn try_new() { - let c = LruTtlCache::::try_with_size_and_ttl(0, Duration::from_secs(1)); + let c = LruTtlCache::::try_with_max_size_and_ttl(0, Duration::from_secs(1)); assert!(matches!( c.unwrap_err(), - super::super::BuildError::InvalidValue { field: "size", .. } + super::super::BuildError::InvalidValue { + field: "max_size", + .. + } )); - let c = LruTtlCache::::try_with_size_and_ttl(usize::MAX, Duration::from_secs(1)); + let c = + LruTtlCache::::try_with_max_size_and_ttl(usize::MAX, Duration::from_secs(1)); assert!(matches!( c.unwrap_err(), - super::super::BuildError::InvalidValue { field: "size", .. } + super::super::BuildError::InvalidValue { + field: "max_size", + .. + } )); } + #[test] + fn cache_clear_with_on_evict_fires_for_all_entries() { + let count = Arc::new(AtomicUsize::new(0)); + let count2 = count.clone(); + let mut c = LruTtlCache::builder() + .max_size(5) + .ttl(Duration::from_secs(60)) + .on_evict(move |_k: &u32, _v: &u32| { + count2.fetch_add(1, AtomicOrdering::Relaxed); + }) + .build(); + c.cache_set(1, 10); + c.cache_set(2, 20); + c.cache_set(3, 30); + c.cache_clear_with_on_evict(); + assert_eq!(c.cache_size(), 0); + assert_eq!(count.load(AtomicOrdering::Relaxed), 3); + assert_eq!(c.evictions.load(AtomicOrdering::Relaxed), 3); + } + + #[test] + fn cache_clear_does_not_fire_on_evict() { + let fired = Arc::new(AtomicUsize::new(0)); + let fired2 = fired.clone(); + let mut c = LruTtlCache::builder() + .max_size(5) + .ttl(Duration::from_secs(60)) + .on_evict(move |_k: &u32, _v: &u32| { + fired2.fetch_add(1, AtomicOrdering::Relaxed); + }) + .build(); + c.cache_set(1, 10); + c.cache_set(2, 20); + c.cache_clear(); + assert_eq!(c.cache_size(), 0); + assert_eq!( + fired.load(AtomicOrdering::Relaxed), + 0, + "cache_clear must not fire on_evict" + ); + } + #[test] fn cache_reset_does_not_fire_on_evict() { use std::sync::atomic::{AtomicUsize, Ordering}; @@ -867,7 +996,7 @@ mod tests { let evict_count = Arc::new(AtomicUsize::new(0)); let evict_count2 = evict_count.clone(); let mut c = LruTtlCache::builder() - .size(4) + .max_size(4) .ttl(Duration::from_secs(60)) .on_evict(move |_k, _v| { evict_count2.fetch_add(1, Ordering::Relaxed); @@ -891,7 +1020,7 @@ mod tests { // when no on_evict callback is configured. fn build_with_borrowed<'a>(_k: &'a str, _v: &'a str) -> LruTtlCache<&'a str, &'a str> { LruTtlCache::builder() - .size(4) + .max_size(4) .ttl(Duration::from_secs(60)) .build() } @@ -904,7 +1033,7 @@ mod tests { #[tokio::test] async fn test_async_trait() { use crate::CachedAsync; - let mut c = LruTtlCache::with_size_and_ttl(4, Duration::from_secs(60)); + let mut c = LruTtlCache::with_max_size_and_ttl(4, Duration::from_secs(60)); async fn _get(n: usize) -> usize { n @@ -927,7 +1056,7 @@ mod tests { #[test] fn test_diagnostics_and_traits() { let mut cache = LruTtlCache::builder() - .size(3) + .max_size(3) .ttl(Duration::from_secs(60)) .build(); cache.cache_set(1, 100); @@ -951,7 +1080,7 @@ mod tests { let try_built = builder.try_build(); assert!(try_built.is_err()); // Missing both size and ttl - let builder = LruTtlCache::::builder().size(3); + let builder = LruTtlCache::::builder().max_size(3); let try_built = builder.try_build(); assert!(try_built.is_err()); // Missing ttl @@ -960,9 +1089,110 @@ mod tests { assert!(try_built.is_err()); // Missing size let builder = LruTtlCache::::builder() - .size(0) + .max_size(0) .ttl(Duration::from_secs(60)); let try_built = builder.try_build(); assert!(try_built.is_err()); // Size 0 is invalid + + let builder = LruTtlCache::::builder() + .max_size(3) + .ttl(Duration::ZERO); + let try_built = builder.try_build(); + assert!(try_built.is_err()); // Zero ttl is invalid + } + + #[test] + fn cache_remove_entry_returns_some_for_live_entry() { + let mut c = LruTtlCache::builder() + .max_size(4) + .ttl(Duration::from_secs(60)) + .build(); + c.cache_set(1u32, 100u32); + assert_eq!(c.cache_remove_entry(&999u32), None); // absent + assert_eq!(c.cache_remove_entry(&1u32), Some((1u32, 100u32))); + assert_eq!(c.cache_get(&1u32), None); + } + + #[test] + fn cache_remove_entry_returns_some_for_expired_entry() { + let mut c = LruTtlCache::builder() + .max_size(4) + .ttl(Duration::from_millis(50)) + .build(); + c.cache_set(1u32, 100u32); + std::thread::sleep(std::time::Duration::from_millis(100)); + + // cache_remove returns None for expired. + assert_eq!(c.cache_remove(&1u32), None); + + // cache_remove_entry returns Some even for expired. + c.cache_set(2u32, 200u32); + std::thread::sleep(std::time::Duration::from_millis(100)); + let removed = c.cache_remove_entry(&2u32); + assert!(removed.is_some()); + assert_eq!( + removed.expect("cache_remove_entry returns Some for expired"), + (2u32, 200u32) + ); + } + + #[test] + fn cache_delete_returns_true_for_expired_entry() { + let mut c = LruTtlCache::builder() + .max_size(4) + .ttl(Duration::from_millis(50)) + .build(); + c.cache_set(1u32, 100u32); + std::thread::sleep(std::time::Duration::from_millis(100)); + assert!( + c.cache_delete(&1u32), + "cache_delete must be true even for expired entry" + ); + assert!(!c.cache_delete(&1u32), "cache_delete false when absent"); + } + + #[test] + fn cache_remove_entry_fires_on_evict_for_expired() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + let count = Arc::new(AtomicUsize::new(0)); + let count2 = count.clone(); + let mut c = LruTtlCache::builder() + .max_size(4) + .ttl(Duration::from_millis(50)) + .on_evict(move |_k: &u32, _v: &u32| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + c.cache_set(1u32, 10u32); + std::thread::sleep(std::time::Duration::from_millis(100)); + + c.cache_remove_entry(&1u32); + assert_eq!( + count.load(Ordering::Relaxed), + 1, + "on_evict fires for expired entries" + ); + + c.cache_remove_entry(&999u32); + assert_eq!(count.load(Ordering::Relaxed), 1, "no fire for absent key"); + } + + #[test] + fn cache_remove_entry_increments_eviction_counter() { + let mut c = LruTtlCache::builder() + .max_size(4) + .ttl(Duration::from_millis(10)) + .build(); + 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 + assert_eq!( + c.cache_evictions().expect("evictions are always tracked") - before, + 1, + "cache_remove_entry must increment evictions for present key only" + ); } } diff --git a/src/stores/mod.rs b/src/stores/mod.rs index ebcae59f..85879244 100644 --- a/src/stores/mod.rs +++ b/src/stores/mod.rs @@ -3,6 +3,75 @@ use std::cmp::Eq; use std::collections::hash_map::Entry; use std::collections::HashMap; use std::hash::Hash; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; + +const STRIPE_COUNT: usize = 16; + +#[repr(align(128))] +struct Slot(AtomicU64); + +/// A hit/miss counter distributed across [`STRIPE_COUNT`] cache-line-padded +/// slots to reduce cross-core cache-line bouncing under concurrent increments. +/// +/// Each thread is assigned a stable slot on first use via a thread-local index; +/// [`load`](StripedCounter::load) sums all slots for the aggregate value. +/// +/// Used only by stores that implement [`CachedRead`], which allow concurrent +/// shared-lock reads. Stores that are always accessed under an exclusive write +/// lock use plain [`AtomicU64`] instead. +pub(super) struct StripedCounter { + slots: Box<[Slot]>, +} + +impl StripedCounter { + pub(super) fn new() -> Self { + let slots = (0..STRIPE_COUNT) + .map(|_| Slot(AtomicU64::new(0))) + .collect::>() + .into_boxed_slice(); + Self { slots } + } + + /// Increment the current thread's stripe by one. + #[inline] + pub(super) fn increment(&self) { + self.slots[thread_stripe()] + .0 + .fetch_add(1, Ordering::Relaxed); + } + + /// Sum across all stripes. + pub(super) fn load(&self) -> u64 { + self.slots.iter().map(|s| s.0.load(Ordering::Relaxed)).sum() + } + + /// Zero all stripes (used by `cache_reset`). + pub(super) fn reset(&self) { + for slot in self.slots.iter() { + slot.0.store(0, Ordering::Relaxed); + } + } + + /// Return a new `StripedCounter` whose slot 0 holds the current aggregate. + /// Used by manual `Clone` impls that carry counter state across the copy. + pub(super) fn snapshot(&self) -> Self { + let total = self.load(); + let new = Self::new(); + new.slots[0].0.store(total, Ordering::Relaxed); + new + } +} + +#[inline] +fn thread_stripe() -> usize { + thread_local! { + static SLOT: usize = { + static NEXT: AtomicUsize = AtomicUsize::new(0); + NEXT.fetch_add(1, Ordering::Relaxed) % STRIPE_COUNT + }; + } + SLOT.with(|&s| s) +} #[cfg(feature = "async_core")] use {super::CachedAsync, std::future::Future}; @@ -16,13 +85,14 @@ mod lru; mod lru_ttl; #[cfg(feature = "redis_store")] mod redis; +pub mod sharded; #[cfg(feature = "time_stores")] mod ttl; #[cfg(feature = "time_stores")] mod ttl_sorted; mod unbound; -use crate::time::Instant; +use crate::time::{Duration, Instant}; pub(super) type OnEvict = std::sync::Arc; @@ -38,6 +108,11 @@ 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 { @@ -47,12 +122,24 @@ 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 {} +/// Validate that `ttl` is non-zero; used by all TTL-capable store builders. +pub(crate) fn validate_ttl(ttl: Duration) -> Result<(), BuildError> { + if ttl.is_zero() { + Err(BuildError::InvalidTtl { ttl }) + } else { + Ok(()) + } +} + /// A cached value paired with its insertion timestamp for TTL tracking. /// /// Exposed through `TtlCache::store` and `LruTtlCache::store` for @@ -95,6 +182,19 @@ pub use ttl::{TtlCache, TtlCacheBuilder}; pub use ttl_sorted::{TtlSortedCache, TtlSortedCacheBuilder, TtlSortedCacheError}; pub use unbound::{UnboundCache, UnboundCacheBuilder}; +pub use sharded::{ + DefaultShardHasher, ShardHasher, ShardedCache, ShardedCacheBase, ShardedCacheBuilder, + ShardedExpiringCache, ShardedExpiringCacheBase, ShardedExpiringCacheBuilder, + ShardedExpiringLruCache, ShardedExpiringLruCacheBase, ShardedExpiringLruCacheBuilder, + ShardedLruCache, ShardedLruCacheBase, ShardedLruCacheBuilder, +}; +#[cfg(feature = "time_stores")] +#[cfg_attr(docsrs, doc(cfg(feature = "time_stores")))] +pub use sharded::{ + ShardedLruTtlCache, ShardedLruTtlCacheBase, ShardedLruTtlCacheBuilder, ShardedTtlCache, + ShardedTtlCacheBase, ShardedTtlCacheBuilder, +}; + #[cfg(all( feature = "async_core", feature = "redis_store", @@ -154,6 +254,13 @@ where { HashMap::remove(self, k) } + fn cache_remove_entry(&mut self, k: &Q) -> Option<(K, V)> + where + K: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized, + { + HashMap::remove_entry(self, k) + } fn cache_clear(&mut self) { HashMap::clear(self); } @@ -262,6 +369,10 @@ pub trait CacheEvict { /// 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**: the concrete types expose an inherent `evict(&self)` + /// method that does not require `&mut self`. When you hold only a shared reference (e.g., via + /// `Arc`), call the inherent method directly instead of going through this trait. fn evict(&mut self) -> usize; } @@ -288,12 +399,12 @@ mod tests { assert_eq!(err1.to_string(), "required field `ttl` was not set"); let err2 = BuildError::InvalidValue { - field: "size", + field: "max_size", reason: "must be greater than zero", }; assert_eq!( err2.to_string(), - "invalid value for field `size`: must be greater than zero" + "invalid value for field `max_size`: must be greater than zero" ); } } diff --git a/src/stores/redis.rs b/src/stores/redis.rs index 7ab5d92e..800acd3c 100644 --- a/src/stores/redis.rs +++ b/src/stores/redis.rs @@ -28,7 +28,7 @@ fn ttl_seconds(ttl: Duration) -> Result { if ttl.is_zero() { return Err(redis::RedisError::from(( redis::ErrorKind::InvalidClientConfig, - "TTL must be greater than zero for Redis", + "invalid ttl: must be greater than zero", format!("got {ttl:?}"), )) .into()); @@ -199,6 +199,8 @@ pub enum RedisCacheBuildError { Connection(#[from] redis::RedisError), #[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:?}")] MissingConnectionString { env_key: String, @@ -357,6 +359,7 @@ where /// /// Will return a `RedisCacheBuildError`, depending on the error pub fn build(self) -> Result, RedisCacheBuildError> { + super::validate_ttl(self.ttl)?; Ok(RedisCache { ttl: self.ttl, refresh: self.refresh, @@ -367,6 +370,11 @@ where _phantom: PhantomData, }) } + + /// Alias for [`build`](Self::build), matching in-memory store builder naming. + pub fn try_build(self) -> Result, RedisCacheBuildError> { + self.build() + } } /// Cache store backed by redis @@ -402,6 +410,11 @@ where RedisCacheBuilder::new(prefix, ttl) } + /// Initialize a `RedisCacheBuilder`. + pub fn builder>(prefix: S, ttl: Duration) -> RedisCacheBuilder { + RedisCacheBuilder::new(prefix, ttl) + } + fn generate_key(&self, key: &K) -> String { // Format `{namespace}:{prefix}:{key}` — see `generate_redis_key`. // Changed from raw concatenation in 1.0 (migration §8): existing @@ -450,7 +463,7 @@ impl CachedRedisValue { impl ConcurrentCached for RedisCache where - K: Display, + K: Display + Clone, V: Serialize + DeserializeOwned, { type Error = RedisCacheError; @@ -534,6 +547,17 @@ where } } + /// Remove an entry and return the stored key and value. + /// + /// **Note:** Unlike in-memory stores, Redis manages TTL expiry server-side. A `GET` on a + /// TTL-expired key returns nil, so this method returns `None` for expired entries even + /// though the key may still be physically present in Redis. Use [`cache_delete`](ConcurrentCached::cache_delete) + /// (which uses `DEL` directly) to reliably detect whether any physical entry was removed. + fn cache_remove_entry(&self, key: &K) -> Result, Self::Error> { + self.cache_remove(key) + .map(|opt| opt.map(|v| (key.clone(), v))) + } + fn cache_delete(&self, key: &K) -> Result { let mut conn = self.pool.get()?; let key = self.generate_key(key); @@ -780,6 +804,7 @@ mod async_redis { /// /// Will return a `RedisCacheBuildError`, depending on the error pub async fn build(self) -> Result, RedisCacheBuildError> { + super::super::validate_ttl(self.ttl)?; Ok(AsyncRedisCache { ttl: self.ttl, refresh: self.refresh, @@ -793,6 +818,11 @@ mod async_redis { _phantom: PhantomData, }) } + + /// Alias for [`build`](Self::build), matching in-memory store builder naming. + pub async fn try_build(self) -> Result, RedisCacheBuildError> { + self.build().await + } } /// Cache store backed by redis @@ -832,6 +862,11 @@ mod async_redis { AsyncRedisCacheBuilder::new(prefix, ttl) } + /// Initialize an `AsyncRedisCacheBuilder`. + pub fn builder>(prefix: S, ttl: Duration) -> AsyncRedisCacheBuilder { + AsyncRedisCacheBuilder::new(prefix, ttl) + } + fn generate_key(&self, key: &K) -> String { // Same format as the sync store — see `super::generate_redis_key`. super::generate_redis_key(&self.namespace, &self.prefix, &key.to_string()) @@ -851,7 +886,7 @@ mod async_redis { where // `V: Sync` not needed — values cross the async boundary by value, never // by shared reference. Matches the async `DiskCache` impl. - K: Display + Send + Sync, + K: Display + Clone + Send + Sync, V: Serialize + DeserializeOwned + Send, { type Error = RedisCacheError; @@ -937,6 +972,18 @@ mod async_redis { } } + /// Remove an entry and return the stored key and value. + /// + /// **Note:** Unlike in-memory stores, Redis manages TTL expiry server-side. A `GET` on a + /// TTL-expired key returns nil, so this method returns `None` for expired entries even + /// though the key may still be physically present in Redis. Use [`cache_delete`](ConcurrentCachedAsync::cache_delete) + /// (which uses `DEL` directly) to reliably detect whether any physical entry was removed. + async fn cache_remove_entry(&self, key: &K) -> Result, Self::Error> { + self.cache_remove(key) + .await + .map(|opt| opt.map(|v| (key.clone(), v))) + } + async fn cache_delete(&self, key: &K) -> Result { let mut conn = self.connection.clone(); let key = self.generate_key(key); diff --git a/src/stores/sharded/expiring.rs b/src/stores/sharded/expiring.rs new file mode 100644 index 00000000..0bceb977 --- /dev/null +++ b/src/stores/sharded/expiring.rs @@ -0,0 +1,1096 @@ +use std::hash::Hash; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +#[cfg(feature = "ahash")] +use ahash::RandomState; +#[cfg(not(feature = "ahash"))] +use std::collections::hash_map::RandomState; + +use std::collections::HashMap; + +#[cfg(feature = "async_core")] +use crate::ConcurrentCachedAsync; +use crate::{CacheMetrics, ConcurrentCached, ConcurrentCloneCached, Expires}; + +use super::{ + checked_shard_count, default_shard_count, shard_index, CachePadded, DefaultShardHasher, Shard, + ShardHasher, +}; +use crate::stores::BuildError; +use crate::CacheEvict; + +type OnEvict = Arc; + +#[allow(clippy::type_complexity)] +struct ExpiringInner { + shards: Box<[CachePadded>>]>, + shard_mask: usize, + hasher: H, + on_evict: Option>, + evictions: AtomicU64, +} + +/// A fully-concurrent, partitioned, unbounded in-memory cache with per-value expiry. +/// +/// Each value controls its own expiration by implementing [`Expires`]. Expired entries +/// are checked on lookup and evicted on access or during explicit [`evict`](CacheEvict::evict) sweeps. +/// +/// **Memory note:** This store is unbounded. Expired entries are only removed on access or +/// when [`evict`](CacheEvict::evict) is called explicitly. For high-cardinality workloads, +/// call `evict()` periodically or use [`ShardedExpiringLruCache`](crate::ShardedExpiringLruCache) with a `size` bound. +/// +/// Wraps an `Arc` — `clone()` is an Arc-share (shared state), not a deep copy. +/// Use [`deep_clone`](ShardedExpiringCacheBase::deep_clone) to get an independent copy. +/// +/// **Note**: reads return owned values cloned from under the shard lock, so `V` must +/// implement `Clone` (in addition to `Expires`). +/// +/// This is a type alias for `ShardedExpiringCacheBase`. +/// To use a custom shard hasher, construct a [`ShardedExpiringCacheBase`] directly via +/// [`ShardedExpiringCacheBase::builder()`]. +pub type ShardedExpiringCache = ShardedExpiringCacheBase; + +/// Backing type for [`ShardedExpiringCache`] with a generic shard hasher `H`. +pub struct ShardedExpiringCacheBase { + inner: Arc>, +} + +impl Clone for ShardedExpiringCacheBase { + /// Arc-share clone — both handles point to the same underlying cache. + fn clone(&self) -> Self { + Self { + inner: Arc::clone(&self.inner), + } + } +} + +impl std::fmt::Debug for ShardedExpiringCacheBase { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ShardedExpiringCache") + .field("shards", &self.inner.shards.len()) + .field("evictions", &self.inner.evictions.load(Ordering::Relaxed)) + .finish_non_exhaustive() + } +} + +impl ShardedExpiringCacheBase +where + K: Hash + Eq, + V: Expires, + H: ShardHasher, +{ + /// Return a builder for constructing a [`ShardedExpiringCacheBase`]. + /// + /// 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. + pub fn builder() -> ShardedExpiringCacheBuilder { + ShardedExpiringCacheBuilder::default() + } + + #[inline] + fn shard_of(&self, k: &K) -> &CachePadded>> { + let h = self.inner.hasher.shard_hash(k); + &self.inner.shards[shard_index(h, self.inner.shard_mask)] + } +} + +impl ShardedExpiringCache +where + K: Hash + Eq, + V: Expires, +{ + /// Create a new unbounded expiring sharded cache with the default shard count. + #[must_use] + pub fn new() -> Self { + Self::with_shards(default_shard_count()) + } + + /// Create a new unbounded expiring sharded cache with the given shard count + /// (rounded up to the next power of two). + #[must_use] + pub fn with_shards(shards: usize) -> Self { + ShardedExpiringCacheBuilder::default() + .shards(shards) + .build() + } +} + +impl Default for ShardedExpiringCache +where + K: Hash + Eq, + V: Expires, +{ + fn default() -> Self { + Self::new() + } +} + +impl + Clone> + ShardedExpiringCacheBase +{ + /// 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. + #[must_use] + pub fn deep_clone(&self) -> Self { + let n = self.inner.shards.len(); + let shards = (0..n) + .map(|i| { + let guard = self.inner.shards[i].lock.read(); + let store_copy = guard.clone(); + drop(guard); + let hits = self.inner.shards[i].hits.load(Ordering::Relaxed); + let misses = self.inner.shards[i].misses.load(Ordering::Relaxed); + let shard = Shard { + lock: parking_lot::RwLock::new(store_copy), + hits: AtomicU64::new(hits), + misses: AtomicU64::new(misses), + }; + CachePadded(shard) + }) + .collect::>() + .into_boxed_slice(); + Self { + inner: Arc::new(ExpiringInner { + shards, + shard_mask: self.inner.shard_mask, + hasher: self.inner.hasher.clone(), + on_evict: self.inner.on_evict.clone(), + evictions: AtomicU64::new(self.inner.evictions.load(Ordering::Relaxed)), + }), + } + } +} + +impl> ShardedExpiringCacheBase +where + K: Hash + Eq, + V: Expires, +{ + /// Return aggregate metrics across all shards. + /// + /// `size` counts all stored entries, including expired ones that have not yet been + /// swept by a call to [`evict`](ShardedExpiringCacheBase::evict). + #[must_use] + pub fn metrics(&self) -> CacheMetrics { + let mut hits = 0u64; + let mut misses = 0u64; + let mut size = 0usize; + for shard in self.inner.shards.iter() { + hits += shard.hits.load(Ordering::Relaxed); + misses += shard.misses.load(Ordering::Relaxed); + size += shard.lock.read().len(); + } + CacheMetrics { + hits: Some(hits), + misses: Some(misses), + evictions: Some(self.inner.evictions.load(Ordering::Relaxed)), + size, + capacity: None, + } + } + + /// Number of shards. + #[must_use] + pub fn shards(&self) -> usize { + self.inner.shards.len() + } + + /// Per-shard live entry counts (including expired-but-not-yet-swept entries). + #[must_use] + pub fn shard_sizes(&self) -> Vec { + self.inner + .shards + .iter() + .map(|s| s.lock.read().len()) + .collect() + } + + /// Total number of entries across all shards (including not-yet-swept expired entries). + #[must_use] + pub fn len(&self) -> usize { + self.inner.shards.iter().map(|s| s.lock.read().len()).sum() + } + + /// `true` if no entries are present. + #[must_use] + pub fn is_empty(&self) -> bool { + self.inner.shards.iter().all(|s| s.lock.read().is_empty()) + } + + /// Remove all entries from every shard. Does **not** fire `on_evict`. + /// Use [`cache_clear_with_on_evict`](Self::cache_clear_with_on_evict) to opt into callback firing. + pub fn clear(&self) { + for shard in self.inner.shards.iter() { + shard.lock.write().clear(); + } + } + + /// Remove all entries from every shard, firing `on_evict` for each removed entry when a + /// callback is configured. + /// + /// If no `on_evict` callback is configured, this is equivalent to [`clear`](Self::clear). + /// Increments the evictions counter for each removed entry only when `on_evict` is set. + pub fn cache_clear_with_on_evict(&self) { + if self.inner.on_evict.is_none() { + return self.clear(); + } + for shard in self.inner.shards.iter() { + let removed: Vec<(K, V)> = shard.lock.write().drain().collect(); + if !removed.is_empty() { + self.inner + .evictions + .fetch_add(removed.len() as u64, Ordering::Relaxed); + if let Some(on_evict) = &self.inner.on_evict { + for (k, v) in &removed { + on_evict(k, v); + } + } + } + } + } + + /// Sweep all shards for expired entries, remove them, fire the `on_evict` callback + /// (if set) for each, and return the total count of removed entries. + #[must_use] + pub fn evict(&self) -> usize + where + K: Clone, + { + let mut total = 0; + for shard in self.inner.shards.iter() { + let removed = { + let mut guard = shard.lock.write(); + let expired_keys: Vec = guard + .iter() + .filter(|(_, v)| v.is_expired()) + .map(|(k, _)| k.clone()) + .collect(); + let mut removed = Vec::new(); + for k in expired_keys { + if let Some(v) = guard.remove(&k) { + removed.push((k, v)); + } + } + removed + }; + + total += removed.len(); + if !removed.is_empty() { + self.inner + .evictions + .fetch_add(removed.len() as u64, Ordering::Relaxed); + if let Some(on_evict) = &self.inner.on_evict { + for (k, v) in &removed { + on_evict(k, v); + } + } + } + } + total + } +} + +impl ShardedExpiringCacheBase +where + K: Hash + Eq, + V: Clone + Expires, + H: ShardHasher, +{ + /// Retrieve a value from the cache. + #[inline] + pub fn cache_get(&self, k: &K) -> Result, std::convert::Infallible> { + >::cache_get(self, k) + } + + /// Set a value in the cache, returning the previous value at that key if any. + /// + /// The returned value is the raw stored entry and may be logically expired + /// (i.e., `is_expired()` returns `true`). If you need to distinguish a live + /// previous entry from an expired one, check `is_expired()` on the returned value. + #[inline] + pub fn cache_set(&self, k: K, v: V) -> Result, std::convert::Infallible> { + >::cache_set(self, k, v) + } + + /// Remove a value from the cache. + #[inline] + pub fn cache_remove(&self, k: &K) -> Result, std::convert::Infallible> { + >::cache_remove(self, k) + } + + /// Remove an entry from the cache, returning the stored key and value. + /// + /// Unlike [`cache_remove`](Self::cache_remove), this always returns `Some((key, value))` + /// when the entry was physically deleted, even if the value was logically expired. + #[inline] + pub fn cache_remove_entry(&self, k: &K) -> Result, std::convert::Infallible> { + >::cache_remove_entry(self, k) + } + + /// Delete a value from the cache. + #[inline] + pub fn cache_delete(&self, k: &K) -> Result { + >::cache_delete(self, k) + } +} + +impl ConcurrentCached for ShardedExpiringCacheBase +where + K: Hash + Eq, + V: Clone + Expires, + H: ShardHasher, +{ + type Error = std::convert::Infallible; + + 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. + let (expired, value) = { + let guard = shard.lock.read(); + match guard.get(k) { + Some(v) => { + let expired = v.is_expired(); + let val = if !expired { Some(v.clone()) } else { None }; + (expired, val) + } + None => { + shard.misses.fetch_add(1, Ordering::Relaxed); + return Ok(None); + } + } + }; + + if expired { + // Upgrade to write lock to remove the expired entry. + let mut guard = shard.lock.write(); + // Re-check under write lock — another thread may have replaced the entry + // with a fresh value in the meantime; clone it out in the same lookup. + let fresh_val = match guard.get(k) { + Some(v) if !v.is_expired() => Some(v.clone()), + _ => None, + }; + if let Some(fresh_val) = fresh_val { + drop(guard); + shard.hits.fetch_add(1, Ordering::Relaxed); + return Ok(Some(fresh_val)); + } + // Still expired (or already gone) — remove it. + let removed = guard.remove_entry(k); + drop(guard); + if let Some((stored_k, v)) = removed { + self.inner.evictions.fetch_add(1, Ordering::Relaxed); + if let Some(on_evict) = &self.inner.on_evict { + on_evict(&stored_k, &v); + } + } + shard.misses.fetch_add(1, Ordering::Relaxed); + return Ok(None); + } + + shard.hits.fetch_add(1, Ordering::Relaxed); + Ok(value) + } + + fn cache_set(&self, k: K, v: V) -> Result, Self::Error> { + let shard = self.shard_of(&k); + Ok(shard.lock.write().insert(k, v)) + } + + fn cache_remove(&self, k: &K) -> Result, Self::Error> { + let shard = self.shard_of(k); + let removed = shard.lock.write().remove_entry(k); + if let Some((stored_k, v)) = removed { + self.inner.evictions.fetch_add(1, Ordering::Relaxed); + if let Some(on_evict) = &self.inner.on_evict { + on_evict(&stored_k, &v); + } + if v.is_expired() { + Ok(None) + } else { + Ok(Some(v)) + } + } else { + Ok(None) + } + } + + fn cache_remove_entry(&self, k: &K) -> Result, Self::Error> { + let shard = self.shard_of(k); + let removed = shard.lock.write().remove_entry(k); + if let Some((ref stored_k, ref v)) = removed { + self.inner.evictions.fetch_add(1, Ordering::Relaxed); + if let Some(on_evict) = &self.inner.on_evict { + on_evict(stored_k, v); + } + } + Ok(removed) + } + + /// No-op: this store uses value-defined expiry, not a refreshable TTL. Always returns `false`. + fn set_refresh_on_hit(&mut self, _refresh: bool) -> bool { + false + } +} + +#[cfg(feature = "async_core")] +impl ConcurrentCachedAsync for ShardedExpiringCacheBase +where + K: Hash + Eq + Send + Sync, + V: Clone + Expires + Send + Sync, + H: ShardHasher, +{ + type Error = std::convert::Infallible; + + async fn cache_get(&self, k: &K) -> Result, Self::Error> { + ConcurrentCached::cache_get(self, k) + } + + async fn cache_set(&self, k: K, v: V) -> Result, Self::Error> { + ConcurrentCached::cache_set(self, k, v) + } + + async fn cache_remove(&self, k: &K) -> Result, Self::Error> { + ConcurrentCached::cache_remove(self, k) + } + + async fn cache_remove_entry(&self, k: &K) -> Result, Self::Error> { + ConcurrentCached::cache_remove_entry(self, k) + } + + fn set_refresh_on_hit(&mut self, b: bool) -> bool { + >::set_refresh_on_hit(self, b) + } +} + +impl CacheEvict for ShardedExpiringCacheBase +where + K: Clone + Hash + Eq, + V: Expires, + H: ShardHasher, +{ + fn evict(&mut self) -> usize { + ShardedExpiringCacheBase::evict(self) + } +} + +/// Builder for [`ShardedExpiringCacheBase`]. +/// +/// Note: there is intentionally **no `.ttl()` setter**. A sharded expiring cache has no global +/// expiry duration — each value decides when it is expired via the [`Expires`] trait. For a +/// single global TTL applied to every entry, use +/// [`ShardedTtlCache`](crate::ShardedTtlCache) or +/// [`ShardedLruTtlCache`](crate::ShardedLruTtlCache) instead. +#[doc(alias = "ttl")] +pub struct ShardedExpiringCacheBuilder { + shards: Option, + hasher: Option, + on_evict: Option>, + _k: std::marker::PhantomData, + _v: std::marker::PhantomData, +} + +impl Default for ShardedExpiringCacheBuilder { + fn default() -> Self { + Self { + shards: None, + hasher: Some(DefaultShardHasher::default()), + on_evict: None, + _k: std::marker::PhantomData, + _v: std::marker::PhantomData, + } + } +} + +impl ShardedExpiringCacheBuilder { + /// Set the number of shards (rounded up to the next power of two). + #[must_use] + pub fn shards(mut self, shards: usize) -> Self { + self.shards = Some(shards); + self + } + + /// Set a custom shard-selection hasher, changing the type parameter. + #[must_use] + pub fn hasher>(self, hasher: H2) -> ShardedExpiringCacheBuilder { + ShardedExpiringCacheBuilder { + shards: self.shards, + hasher: Some(hasher), + on_evict: self.on_evict, + _k: std::marker::PhantomData, + _v: std::marker::PhantomData, + } + } + + /// Set a callback invoked when an entry is evicted. Fires on expired-entry removal during + /// [`cache_get`](ConcurrentCached::cache_get), explicitly via + /// [`evict`](ShardedExpiringCacheBase::evict), on explicit + /// [`cache_remove`](ConcurrentCached::cache_remove), and on + /// [`cache_remove_entry`](ConcurrentCached::cache_remove_entry). + /// Does **not** fire on [`clear`](ShardedExpiringCacheBase::clear); + /// use [`cache_clear_with_on_evict`](ShardedExpiringCacheBase::cache_clear_with_on_evict) to opt in. + /// + /// The closure must be `'static` (its captures cannot borrow from the local stack), but `K` + /// and `V` themselves are not required to be `'static`. + #[must_use] + pub fn on_evict(mut self, on_evict: impl Fn(&K, &V) + Send + Sync + 'static) -> Self { + self.on_evict = Some(Arc::new(on_evict)); + self + } + + /// Build the new cache and copy every non-expired entry from `existing` into it. + /// + /// Acquires each shard's read lock on `existing` one at a time — `existing` + /// keeps serving concurrent ops throughout. Entries whose + /// [`is_expired`](crate::Expires::is_expired) returns `true` at copy time are + /// skipped and not transferred. + /// + /// **Note**: `on_evict` callbacks on `existing` do not fire — entries are read + /// (not removed) from the source cache. + #[must_use] + pub fn copy_from>( + self, + existing: &ShardedExpiringCacheBase, + ) -> ShardedExpiringCacheBase + where + K: Clone + Hash + Eq, + V: Clone, + H: ShardHasher, + { + let new_cache = self.build(); + for shard in existing.inner.shards.iter() { + let entries: Vec<(K, V)> = { + let guard = shard.lock.read(); + guard + .iter() + .filter(|(_, v)| !v.is_expired()) + .map(|(k, v)| (k.clone(), v.clone())) + .collect() + }; + for (k, v) in entries { + let _ = ConcurrentCached::cache_set(&new_cache, k, v); + } + } + new_cache + } + + /// Build the cache. + /// + /// # Panics + /// + /// Panics if construction fails (e.g. shard count overflow). + #[must_use] + pub fn build(self) -> ShardedExpiringCacheBase + where + K: Hash + Eq, + H: ShardHasher, + { + self.try_build() + .unwrap_or_else(|error| panic!("ShardedExpiringCache build failed: {error}")) + } + + /// Build the cache, returning an error instead of panicking. + /// + /// # Errors + /// + /// Returns [`BuildError`] if `shards` count is invalid or overflows. + pub fn try_build(self) -> Result, BuildError> + where + K: Hash + Eq, + H: ShardHasher, + { + let n = checked_shard_count(self.shards)?; + let mask = n - 1; + let shards = (0..n) + .map(|_| CachePadded(Shard::new(HashMap::with_hasher(RandomState::new())))) + .collect::>() + .into_boxed_slice(); + Ok(ShardedExpiringCacheBase { + inner: Arc::new(ExpiringInner { + shards, + shard_mask: mask, + hasher: self + .hasher + .expect("hasher is always initialized via Default or .hasher()"), + on_evict: self.on_evict, + evictions: AtomicU64::new(0), + }), + }) + } +} + +impl ConcurrentCloneCached for ShardedExpiringCacheBase +where + K: Hash + Eq, + V: Clone + Expires, + H: ShardHasher, +{ + /// Returns `(Some(v), false)` for a live entry (hit), `(Some(v), true)` for an expired + /// entry (miss, **no removal**, no eviction counter), or `(None, false)` when absent (miss). + fn cache_get_with_expiry_status(&self, k: &K) -> (Option, bool) { + let shard = self.shard_of(k); + let guard = shard.lock.read(); + match guard.get(k) { + None => { + drop(guard); + shard.misses.fetch_add(1, Ordering::Relaxed); + (None, false) + } + Some(v) => { + let expired = v.is_expired(); + let value = v.clone(); + drop(guard); + if expired { + shard.misses.fetch_add(1, Ordering::Relaxed); + (Some(value), true) + } else { + shard.hits.fetch_add(1, Ordering::Relaxed); + (Some(value), false) + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ConcurrentCached as SyncConcurrentCached; + use crate::ConcurrentCloneCached; + + #[derive(Clone)] + struct Val { + v: u32, + expired: bool, + } + impl crate::Expires for Val { + fn is_expired(&self) -> bool { + self.expired + } + } + + #[test] + fn copy_from_skips_expired() { + let old = ShardedExpiringCache::::new(); + for i in 0..10u32 { + SyncConcurrentCached::cache_set( + &old, + i, + Val { + v: i, + expired: true, + }, + ) + .expect("insert must succeed"); + } + let new_cache = ShardedExpiringCacheBase::::builder().copy_from(&old); + assert_eq!(new_cache.len(), 0); + } + + #[test] + fn copy_from_preserves_live_entries() { + let old = ShardedExpiringCache::::new(); + for i in 0..20u32 { + SyncConcurrentCached::cache_set( + &old, + i, + Val { + v: i * 10, + expired: false, + }, + ) + .expect("insert must succeed"); + } + let new_cache = ShardedExpiringCacheBase::::builder().copy_from(&old); + assert_eq!(new_cache.len(), 20); + for i in 0..20u32 { + let got = + SyncConcurrentCached::cache_get(&new_cache, &i).expect("key was just inserted"); + assert_eq!(got.map(|v| v.v), Some(i * 10)); + } + } + + #[test] + fn cache_remove_fires_on_evict_and_updates_metrics() { + use std::sync::atomic::{AtomicU64, Ordering as AtomicOrd}; + use std::sync::Arc; + + let evict_count = Arc::new(AtomicU64::new(0)); + let ec = evict_count.clone(); + let cache = ShardedExpiringCacheBase::::builder() + .shards(1) + .on_evict(move |_, _| { + ec.fetch_add(1, AtomicOrd::Relaxed); + }) + .build(); + + SyncConcurrentCached::cache_set( + &cache, + 1, + Val { + v: 1, + expired: false, + }, + ) + .expect("insert must succeed"); + SyncConcurrentCached::cache_set( + &cache, + 2, + Val { + v: 2, + expired: true, + }, + ) + .expect("insert must succeed"); + + // Removing a live entry fires on_evict and increments evictions. + let before = cache.metrics().evictions.expect("eviction-tracking stores report an evictions count"); + let got = SyncConcurrentCached::cache_remove(&cache, &1).expect("key must be present"); + assert_eq!(got.map(|v| v.v), Some(1)); + assert_eq!( + evict_count.load(AtomicOrd::Relaxed), + 1, + "on_evict must fire" + ); + assert_eq!( + cache.metrics().evictions.expect("eviction-tracking stores report an evictions count") - before, + 1, + "evictions metric must increment on successful remove" + ); + + // Removing an expired entry fires on_evict and increments the evictions + // counter, but returns None (the value is expired). This is consistent + // across all stores: cache_remove returns None for an expired entry. + let before2 = cache.metrics().evictions.expect("eviction-tracking stores report an evictions count"); + let got2 = SyncConcurrentCached::cache_remove(&cache, &2).expect("key must be present"); + assert_eq!( + got2.map(|v| v.v), + None, + "expired entry must return None from cache_remove" + ); + assert_eq!( + evict_count.load(AtomicOrd::Relaxed), + 2, + "on_evict must fire even for expired entries" + ); + assert_eq!( + cache.metrics().evictions.expect("eviction-tracking stores report an evictions count") - before2, + 1, + "evictions metric increments even for expired removes" + ); + } + + #[test] + fn build_panic_includes_build_error_context() { + let panic = std::panic::catch_unwind(|| { + ShardedExpiringCacheBase::::builder() + .shards(0) + .build() + }) + .expect_err("zero shards should panic"); + let message = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .unwrap_or(""); + assert!(message.contains("ShardedExpiringCache build failed")); + assert!(message.contains("shards")); + } + + #[test] + fn cache_clear_with_on_evict_fires_for_all_entries() { + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::Arc; + let count = Arc::new(AtomicU64::new(0)); + let count2 = count.clone(); + let c = ShardedExpiringCacheBase::::builder() + .on_evict(move |_, _| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + for i in 0..20u32 { + SyncConcurrentCached::cache_set( + &c, + i, + Val { + v: i, + expired: false, + }, + ) + .expect("insert must succeed"); + } + let before = c.metrics().evictions.expect("eviction-tracking stores report an evictions count"); + c.cache_clear_with_on_evict(); + assert_eq!( + c.len(), + 0, + "cache must be empty after cache_clear_with_on_evict" + ); + assert_eq!( + count.load(Ordering::Relaxed), + 20, + "on_evict must fire for every entry" + ); + assert_eq!( + c.metrics().evictions.expect("eviction-tracking stores report an evictions count") - before, + 20, + "evictions counter must increment for each entry" + ); + } + + #[test] + fn clear_does_not_fire_on_evict() { + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::Arc; + let count = Arc::new(AtomicU64::new(0)); + let count2 = count.clone(); + let c = ShardedExpiringCacheBase::::builder() + .on_evict(move |_, _| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + for i in 0..10u32 { + SyncConcurrentCached::cache_set( + &c, + i, + Val { + v: i, + expired: false, + }, + ) + .expect("insert must succeed"); + } + c.clear(); + assert_eq!( + count.load(Ordering::Relaxed), + 0, + "clear must not fire on_evict" + ); + } + + #[test] + fn cache_remove_entry_returns_some_for_live_entry() { + let c = ShardedExpiringCache::::new(); + SyncConcurrentCached::cache_set( + &c, + 1u32, + Val { + v: 1, + expired: false, + }, + ) + .expect("insert must succeed"); + assert!(c + .cache_remove_entry(&999u32) + .expect("cache_remove_entry must succeed") + .is_none()); + let removed = c.cache_remove_entry(&1u32).expect("key must be present"); + assert!(removed.is_some()); + assert_eq!(removed.expect("must be Some").0, 1u32); + assert!(SyncConcurrentCached::cache_get(&c, &1u32) + .expect("cache_get must succeed") + .is_none()); + } + + #[test] + fn cache_remove_entry_returns_some_for_expired_entry() { + let c = ShardedExpiringCache::::new(); + SyncConcurrentCached::cache_set( + &c, + 1u32, + Val { + v: 1, + expired: true, + }, + ) + .expect("insert must succeed"); + SyncConcurrentCached::cache_set( + &c, + 2u32, + Val { + v: 2, + expired: true, + }, + ) + .expect("insert must succeed"); + + // cache_remove returns None for expired. + assert!(SyncConcurrentCached::cache_remove(&c, &1u32) + .expect("cache_remove must succeed") + .is_none()); + + // cache_remove_entry returns Some even for expired. + let removed = c.cache_remove_entry(&2u32).expect("key must be present"); + assert!( + removed.is_some(), + "cache_remove_entry must return Some for expired entry" + ); + assert_eq!(removed.expect("must be Some").0, 2u32); + } + + #[test] + fn cache_delete_returns_true_for_expired_entry() { + let c = ShardedExpiringCache::::new(); + SyncConcurrentCached::cache_set( + &c, + 1u32, + Val { + v: 1, + expired: true, + }, + ) + .expect("insert must succeed"); + assert!( + SyncConcurrentCached::cache_delete(&c, &1u32).expect("cache_delete must succeed"), + "cache_delete must be true for expired entry" + ); + assert!(!SyncConcurrentCached::cache_delete(&c, &1u32).expect("cache_delete must succeed")); + } + + #[test] + fn cache_remove_entry_fires_on_evict_for_expired() { + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::Arc; + let count = Arc::new(AtomicU64::new(0)); + let count2 = count.clone(); + let c = ShardedExpiringCacheBase::::builder() + .shards(1) + .on_evict(move |_, _| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + SyncConcurrentCached::cache_set( + &c, + 1u32, + Val { + v: 1, + expired: true, + }, + ) + .expect("insert must succeed"); + c.cache_remove_entry(&1u32).expect("key must be present"); + assert_eq!( + count.load(Ordering::Relaxed), + 1, + "on_evict fires for expired entries" + ); + + c.cache_remove_entry(&999u32) + .expect("cache_remove_entry must succeed"); + assert_eq!(count.load(Ordering::Relaxed), 1, "no fire for absent key"); + } + + #[test] + fn cache_remove_entry_increments_eviction_counter() { + let c = ShardedExpiringCacheBase::::builder() + .shards(1) + .build(); + SyncConcurrentCached::cache_set( + &c, + 1u32, + Val { + v: 1, + expired: true, + }, + ) + .expect("insert must succeed"); + let before = c.metrics().evictions.expect("evictions are always tracked"); + c.cache_remove_entry(&1u32).expect("key must be present"); // expired but present — must increment + c.cache_remove_entry(&999u32) + .expect("cache_remove_entry must succeed"); // absent — must not increment + assert_eq!( + c.metrics().evictions.expect("evictions are always tracked") - before, + 1, + "cache_remove_entry must increment evictions for present key only" + ); + } + + // --- ConcurrentCloneCached tests --- + + #[test] + fn concurrent_clone_cached_absent_is_none_false() { + let c = ShardedExpiringCache::::new(); + let (val, expired) = ConcurrentCloneCached::cache_get_with_expiry_status(&c, &1u32); + assert!(val.is_none(), "absent key must return None"); + assert!(!expired, "absent key must return expired=false"); + assert_eq!( + c.metrics().misses, + Some(1), + "absent lookup must increment misses" + ); + } + + #[test] + fn concurrent_clone_cached_live_entry_is_some_false() { + let c = ShardedExpiringCache::::new(); + SyncConcurrentCached::cache_set( + &c, + 1u32, + Val { + v: 7, + expired: false, + }, + ) + .expect("insert must succeed"); + let result = ConcurrentCloneCached::cache_get_with_expiry_status(&c, &1u32); + assert_eq!( + result.0.map(|v| v.v), + Some(7), + "live entry must return the value" + ); + assert!(!result.1, "live entry must not set the expired flag"); + assert_eq!(c.metrics().hits, Some(1), "live lookup must increment hits"); + assert_eq!( + c.metrics().evictions, + Some(0), + "live lookup must not increment evictions" + ); + } + + #[test] + fn concurrent_clone_cached_expired_returns_stale_no_eviction() { + let c = ShardedExpiringCacheBase::::builder() + .shards(1) + .build(); + SyncConcurrentCached::cache_set( + &c, + 1u32, + Val { + v: 55, + expired: true, + }, + ) + .expect("insert must succeed"); + + let result = ConcurrentCloneCached::cache_get_with_expiry_status(&c, &1u32); + assert_eq!( + result.0.map(|v| v.v), + Some(55), + "expired entry must return the stale value" + ); + assert!(result.1, "expired entry must set the expired flag"); + assert_eq!( + c.metrics().misses, + Some(1), + "expired lookup must increment misses" + ); + assert_eq!( + c.metrics().evictions, + Some(0), + "expired lookup must NOT increment evictions" + ); + + // Entry must NOT have been removed — a second call still sees it. + let result2 = ConcurrentCloneCached::cache_get_with_expiry_status(&c, &1u32); + assert_eq!( + result2.0.map(|v| v.v), + Some(55), + "entry must still be present after expiry-status lookup" + ); + assert!( + result2.1, + "entry must still be expired on second expiry-status call" + ); + } +} diff --git a/src/stores/sharded/expiring_lru.rs b/src/stores/sharded/expiring_lru.rs new file mode 100644 index 00000000..9d18a416 --- /dev/null +++ b/src/stores/sharded/expiring_lru.rs @@ -0,0 +1,1296 @@ +use std::hash::Hash; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +#[cfg(feature = "async_core")] +use crate::ConcurrentCachedAsync; +use crate::{ + CacheMetrics, CachedIter, CachedPeek, ConcurrentCached, ConcurrentCloneCached, Expires, +}; + +use super::{ + checked_shard_count, shard_index, CachePadded, DefaultShardHasher, Shard, ShardHasher, +}; +use crate::stores::{BuildError, CacheEvict, LruCache}; +use crate::Cached; + +type OnEvict = Arc; + +#[allow(clippy::type_complexity)] +struct ExpiringLruInner { + shards: Box<[CachePadded>>]>, + shard_mask: usize, + hasher: H, + on_evict: Option>, + evictions: AtomicU64, + total_capacity: usize, +} + +/// A fully-concurrent, partitioned, LRU size-bounded in-memory cache with per-value expiry. +/// +/// Each value controls its own expiration by implementing [`Expires`]. Expired entries +/// are checked on lookup and evicted on access or during explicit [`evict`](CacheEvict::evict) sweeps. +/// Eviction is also enforced independently per shard when capacity limits are hit. +/// +/// Wraps an `Arc` — `clone()` is an Arc-share (shared state), not a deep copy. +/// Use [`deep_clone`](ShardedExpiringLruCacheBase::deep_clone) to get an independent copy. +/// +/// **Note**: `K` and `V` must implement `Clone` (`K` for LRU key tracking; `V` because reads +/// 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()`]. +/// +/// **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`. +pub type ShardedExpiringLruCache = ShardedExpiringLruCacheBase; + +/// Backing type for [`ShardedExpiringLruCache`] with a generic shard hasher `H`. +pub struct ShardedExpiringLruCacheBase { + inner: Arc>, +} + +impl Clone for ShardedExpiringLruCacheBase { + /// Arc-share clone — both handles point to the same underlying cache. + fn clone(&self) -> Self { + Self { + inner: Arc::clone(&self.inner), + } + } +} + +impl std::fmt::Debug for ShardedExpiringLruCacheBase { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ShardedExpiringLruCache") + .field("shards", &self.inner.shards.len()) + .field("capacity", &self.inner.total_capacity) + .field("evictions", &self.inner.evictions.load(Ordering::Relaxed)) + .finish_non_exhaustive() + } +} + +impl ShardedExpiringLruCacheBase +where + K: Hash + Eq + Clone, + V: Expires, + H: ShardHasher, +{ + /// Return a builder for constructing a [`ShardedExpiringLruCacheBase`]. + /// + /// 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. + pub fn builder() -> ShardedExpiringLruCacheBuilder { + ShardedExpiringLruCacheBuilder::default() + } + + #[inline] + fn shard_of(&self, k: &K) -> &CachePadded>> { + let h = self.inner.hasher.shard_hash(k); + &self.inner.shards[shard_index(h, self.inner.shard_mask)] + } +} + +impl ShardedExpiringLruCache +where + K: Hash + Eq + Clone, + V: Expires, +{ + /// Create a new LRU-bounded expiring sharded cache with the given 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**. + /// With the default shard count, the minimum effective capacity is + /// `16 * default_shard_count()` entries. + /// If you require smaller, strict limits under low capacities, configure `shards = 1` or specify + /// `per_shard_max_size` directly. + #[must_use] + pub fn with_max_size(size: usize) -> Self { + ShardedExpiringLruCacheBuilder::default() + .max_size(size) + .try_build() + .unwrap_or_else(|error| panic!("ShardedExpiringLruCache build failed: {error}")) + } + + /// Create a new LRU-bounded expiring sharded cache with the given total capacity and shard count. + /// + /// **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**. + /// If you require smaller, strict limits under low capacities, configure `shards = 1` or specify + /// `per_shard_max_size` directly. + #[must_use] + pub fn with_max_size_and_shards(size: usize, shards: usize) -> Self { + ShardedExpiringLruCacheBuilder::default() + .max_size(size) + .shards(shards) + .try_build() + .unwrap_or_else(|error| panic!("ShardedExpiringLruCache build failed: {error}")) + } +} + +impl + Clone> + ShardedExpiringLruCacheBase +{ + /// 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. + #[must_use] + pub fn deep_clone(&self) -> Self { + let n = self.inner.shards.len(); + let shards = (0..n) + .map(|i| { + let guard = self.inner.shards[i].lock.read(); + let store_copy = guard.clone(); + drop(guard); + let hits = self.inner.shards[i].hits.load(Ordering::Relaxed); + let misses = self.inner.shards[i].misses.load(Ordering::Relaxed); + let shard = Shard { + lock: parking_lot::RwLock::new(store_copy), + hits: AtomicU64::new(hits), + misses: AtomicU64::new(misses), + }; + CachePadded(shard) + }) + .collect::>() + .into_boxed_slice(); + Self { + inner: Arc::new(ExpiringLruInner { + shards, + shard_mask: self.inner.shard_mask, + hasher: self.inner.hasher.clone(), + on_evict: self.inner.on_evict.clone(), + evictions: AtomicU64::new(self.inner.evictions.load(Ordering::Relaxed)), + total_capacity: self.inner.total_capacity, + }), + } + } +} + +impl> ShardedExpiringLruCacheBase +where + K: Hash + Eq + Clone, + V: Expires, +{ + /// Return aggregate metrics across all shards. + /// + /// `evictions` counts both LRU capacity evictions (tracked per-shard) and + /// explicit removes via [`ConcurrentCached::cache_remove`]. + /// `capacity` reflects the effective total capacity — may exceed the requested + /// `size` when the 16-per-shard minimum floor is applied; see [`capacity`](Self::capacity). + #[must_use] + pub fn metrics(&self) -> CacheMetrics { + let mut hits = 0u64; + let mut misses = 0u64; + let mut inner_evictions = 0u64; + let mut size = 0usize; + for shard in self.inner.shards.iter() { + hits += shard.hits.load(Ordering::Relaxed); + misses += shard.misses.load(Ordering::Relaxed); + let guard = shard.lock.read(); + if let Some(e) = guard.cache_evictions() { + inner_evictions += e; + } + size += guard.cache_size(); + } + CacheMetrics { + hits: Some(hits), + misses: Some(misses), + evictions: Some(inner_evictions + self.inner.evictions.load(Ordering::Relaxed)), + size, + capacity: Some(self.inner.total_capacity), + } + } + + /// Number of shards. + #[must_use] + pub fn shards(&self) -> usize { + self.inner.shards.len() + } + + /// Per-shard live entry counts (including expired-but-not-yet-swept entries). + #[must_use] + pub fn shard_sizes(&self) -> Vec { + self.inner + .shards + .iter() + .map(|s| s.lock.read().cache_size()) + .collect() + } + + /// Total number of entries across all shards (including not-yet-swept expired entries). + #[must_use] + pub fn len(&self) -> usize { + self.inner + .shards + .iter() + .map(|s| s.lock.read().cache_size()) + .sum() + } + + /// `true` if no entries are present. + #[must_use] + pub fn is_empty(&self) -> bool { + self.inner + .shards + .iter() + .all(|s| s.lock.read().cache_size() == 0) + } + + /// Remove all entries from every shard. Does **not** fire `on_evict`. + /// Use [`cache_clear_with_on_evict`](Self::cache_clear_with_on_evict) to opt into callback firing. + pub fn clear(&self) { + for shard in self.inner.shards.iter() { + shard.lock.write().cache_clear(); + } + } + + /// Remove all entries from every shard, firing `on_evict` for each removed entry when a + /// callback is configured. + /// + /// If no `on_evict` callback is configured, this is equivalent to [`clear`](Self::clear). + /// Increments the evictions counter for each removed entry only when `on_evict` is set. + pub fn cache_clear_with_on_evict(&self) { + if self.inner.on_evict.is_none() { + return self.clear(); + } + for shard in self.inner.shards.iter() { + let removed: Vec<(K, V)> = { + let mut guard = shard.lock.write(); + let keys: Vec = guard.iter().map(|(k, _)| k.clone()).collect(); + let mut removed = Vec::with_capacity(keys.len()); + for k in keys { + if let Some(pair) = guard.pop_raw(&k) { + removed.push(pair); + } + } + if !removed.is_empty() { + guard + .evictions + .fetch_add(removed.len() as u64, Ordering::Relaxed); + } + removed + }; + if let Some(on_evict) = &self.inner.on_evict { + for (k, v) in &removed { + on_evict(k, v); + } + } + } + } + + /// Effective total capacity across all shards. + /// + /// When constructed with [`max_size`](ShardedExpiringLruCacheBuilder::max_size), this may + /// be larger than the requested size because per-shard capacity is rounded + /// up with ceiling division. + #[must_use] + pub fn capacity(&self) -> usize { + self.inner.total_capacity + } +} + +impl ShardedExpiringLruCacheBase +where + K: Hash + Eq + Clone, + V: Clone + Expires, + H: ShardHasher, +{ + /// Retrieve a value from the cache. + #[inline] + pub fn cache_get(&self, k: &K) -> Result, std::convert::Infallible> { + >::cache_get(self, k) + } + + /// Set a value in the cache, returning the previous value at that key if any. + /// + /// The returned value is the raw stored entry and may be logically expired + /// (i.e., `is_expired()` returns `true`). If you need to distinguish a live + /// previous entry from an expired one, check `is_expired()` on the returned value. + #[inline] + pub fn cache_set(&self, k: K, v: V) -> Result, std::convert::Infallible> { + >::cache_set(self, k, v) + } + + /// Remove a value from the cache. + #[inline] + pub fn cache_remove(&self, k: &K) -> Result, std::convert::Infallible> { + >::cache_remove(self, k) + } + + /// Remove an entry from the cache, returning the stored key and value. + /// + /// Unlike [`cache_remove`](Self::cache_remove), this always returns `Some((key, value))` + /// when the entry was physically deleted, even if the value was logically expired. + #[inline] + pub fn cache_remove_entry(&self, k: &K) -> Result, std::convert::Infallible> { + >::cache_remove_entry(self, k) + } + + /// Delete a value from the cache. + #[inline] + pub fn cache_delete(&self, k: &K) -> Result { + >::cache_delete(self, k) + } +} + +impl ConcurrentCached for ShardedExpiringLruCacheBase +where + K: Hash + Eq + Clone, + V: Clone + Expires, + H: ShardHasher, +{ + type Error = std::convert::Infallible; + + fn cache_get(&self, k: &K) -> Result, Self::Error> { + let shard = self.shard_of(k); + let mut guard = shard.lock.write(); + let expired = match guard.cache_peek(k) { + None => { + shard.misses.fetch_add(1, Ordering::Relaxed); + return Ok(None); + } + Some(v) => v.is_expired(), + }; + + if expired { + let removed = guard.pop_raw(k); + drop(guard); + if let Some((ref key, ref val)) = removed { + // `pop_raw` removes the entry without bumping the inner LRU eviction counter, + // so track expired-on-access removals in the outer counter instead. Explicit + // removes via `cache_remove` bump the inner LRU counter (`guard.evictions`). + // Both paths feed into `metrics().evictions` via the combined sum in `metrics()`. + self.inner.evictions.fetch_add(1, Ordering::Relaxed); + if let Some(on_evict) = &self.inner.on_evict { + on_evict(key, val); + } + } + shard.misses.fetch_add(1, Ordering::Relaxed); + Ok(None) + } else { + // Live hit — update LRU recency and extract value + let val = guard.cache_get(k).cloned(); + shard.hits.fetch_add(1, Ordering::Relaxed); + Ok(val) + } + } + + fn cache_set(&self, k: K, v: V) -> Result, Self::Error> { + let shard = self.shard_of(&k); + Ok(shard.lock.write().cache_set(k, v)) + } + + fn cache_remove(&self, k: &K) -> Result, Self::Error> { + let shard = self.shard_of(k); + let removed = { + let mut guard = shard.lock.write(); + let removed = guard.pop_raw(k); + if removed.is_some() { + guard.evictions.fetch_add(1, Ordering::Relaxed); + } + removed + }; + let Some((key, val)) = removed else { + return Ok(None); + }; + if let Some(on_evict) = &self.inner.on_evict { + on_evict(&key, &val); + } + if val.is_expired() { + Ok(None) + } else { + Ok(Some(val)) + } + } + + fn cache_remove_entry(&self, k: &K) -> Result, Self::Error> { + let shard = self.shard_of(k); + let removed = { + let mut guard = shard.lock.write(); + let removed = guard.pop_raw(k); + if removed.is_some() { + guard.evictions.fetch_add(1, Ordering::Relaxed); + } + removed + }; + let Some((key, val)) = removed else { + return Ok(None); + }; + if let Some(on_evict) = &self.inner.on_evict { + on_evict(&key, &val); + } + Ok(Some((key, val))) + } + + /// No-op: this store uses value-defined expiry, not a refreshable TTL. Always returns `false`. + fn set_refresh_on_hit(&mut self, _refresh: bool) -> bool { + false + } +} + +#[cfg(feature = "async_core")] +impl ConcurrentCachedAsync for ShardedExpiringLruCacheBase +where + K: Hash + Eq + Clone + Send + Sync, + V: Clone + Expires + Send + Sync, + H: ShardHasher, +{ + type Error = std::convert::Infallible; + + async fn cache_get(&self, k: &K) -> Result, Self::Error> { + ConcurrentCached::cache_get(self, k) + } + + async fn cache_set(&self, k: K, v: V) -> Result, Self::Error> { + ConcurrentCached::cache_set(self, k, v) + } + + async fn cache_remove(&self, k: &K) -> Result, Self::Error> { + ConcurrentCached::cache_remove(self, k) + } + + async fn cache_remove_entry(&self, k: &K) -> Result, Self::Error> { + ConcurrentCached::cache_remove_entry(self, k) + } + + fn set_refresh_on_hit(&mut self, b: bool) -> bool { + >::set_refresh_on_hit(self, b) + } +} + +impl ShardedExpiringLruCacheBase +where + K: Clone + Hash + Eq, + V: Expires, + H: ShardHasher, +{ + /// Sweep all shards for expired entries, remove them, fire the `on_evict` callback + /// (if set) for each, and return the total count of removed entries. + #[must_use] + pub fn evict(&self) -> usize { + let mut total = 0; + for shard in self.inner.shards.iter() { + let removed = { + let mut guard = shard.lock.write(); + let expired_keys: Vec = guard + .iter() + .filter(|(_, v)| v.is_expired()) + .map(|(k, _)| k.clone()) + .collect(); + let mut removed = Vec::new(); + for k in expired_keys { + if let Some((key, val)) = guard.pop_raw(&k) { + removed.push((key, val)); + } + } + removed + }; + + total += removed.len(); + if !removed.is_empty() { + self.inner + .evictions + .fetch_add(removed.len() as u64, Ordering::Relaxed); + if let Some(on_evict) = &self.inner.on_evict { + for (k, v) in &removed { + on_evict(k, v); + } + } + } + } + total + } +} + +impl CacheEvict for ShardedExpiringLruCacheBase +where + K: Clone + Hash + Eq, + V: Expires, + H: ShardHasher, +{ + fn evict(&mut self) -> usize { + ShardedExpiringLruCacheBase::evict(self) + } +} + +/// Builder for [`ShardedExpiringLruCacheBase`]. +/// +/// Note: there is intentionally **no `.ttl()` setter**. A sharded expiring LRU cache has no +/// global 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 [`ShardedLruTtlCache`](crate::ShardedLruTtlCache) instead. +#[doc(alias = "ttl")] +pub struct ShardedExpiringLruCacheBuilder { + shards: Option, + max_size: Option, + per_shard_max_size: Option, + hasher: Option, + on_evict: Option>, + _k: std::marker::PhantomData, + _v: std::marker::PhantomData, +} + +impl Default for ShardedExpiringLruCacheBuilder { + fn default() -> Self { + Self { + shards: None, + max_size: None, + per_shard_max_size: None, + hasher: Some(DefaultShardHasher::default()), + on_evict: None, + _k: std::marker::PhantomData, + _v: std::marker::PhantomData, + } + } +} + +impl ShardedExpiringLruCacheBuilder { + /// Set the requested total capacity (divided across shards via `div_ceil`). + /// Mutually exclusive with [`per_shard_max_size`](Self::per_shard_max_size). + /// + /// Eviction is enforced independently per shard. Each shard gets + /// `ceil(size / shards)` entries, with a minimum of 16 per shard when + /// `shards > 1` (see the **Capacity Fragmentation Warning** on + /// [`with_max_size`](ShardedExpiringLruCache::with_max_size)). + /// Use [`per_shard_max_size`](Self::per_shard_max_size) for an exact per-shard cap instead. + #[doc(alias = "size")] + #[doc(alias = "capacity")] + #[must_use] + pub fn max_size(mut self, max_size: usize) -> Self { + self.max_size = Some(max_size); + self + } + + /// Set per-shard capacity directly. Mutually exclusive with [`max_size`](Self::max_size). + #[must_use] + pub fn per_shard_max_size(mut self, per_shard_max_size: usize) -> Self { + self.per_shard_max_size = Some(per_shard_max_size); + self + } + + /// Set the number of shards (rounded up to the next power of two). + #[must_use] + pub fn shards(mut self, shards: usize) -> Self { + self.shards = Some(shards); + self + } + + /// Set a custom shard-selection hasher, changing the type parameter. + #[must_use] + pub fn hasher>( + self, + hasher: H2, + ) -> ShardedExpiringLruCacheBuilder { + ShardedExpiringLruCacheBuilder { + shards: self.shards, + max_size: self.max_size, + per_shard_max_size: self.per_shard_max_size, + hasher: Some(hasher), + on_evict: self.on_evict, + _k: std::marker::PhantomData, + _v: std::marker::PhantomData, + } + } + + /// Set a callback invoked when an entry is evicted. Fires for LRU capacity evictions, + /// expired-entry removal during [`cache_get`](ConcurrentCached::cache_get), explicitly via + /// [`evict`](ShardedExpiringLruCacheBase::evict), on explicit + /// [`cache_remove`](ConcurrentCached::cache_remove), and on + /// [`cache_remove_entry`](ConcurrentCached::cache_remove_entry). + /// Does **not** fire on [`clear`](ShardedExpiringLruCacheBase::clear); + /// use [`cache_clear_with_on_evict`](ShardedExpiringLruCacheBase::cache_clear_with_on_evict) to opt in. + /// + /// Capacity-eviction callbacks run while the affected shard's write lock is held. Do not call + /// methods on the same sharded cache from the callback; doing so can deadlock if the callback + /// re-enters the locked shard. Expiry sweeps via [`evict`](ShardedExpiringLruCacheBase::evict) + /// and explicit removes via [`cache_remove`](ConcurrentCached::cache_remove) / + /// [`cache_remove_entry`](ConcurrentCached::cache_remove_entry) fire `on_evict` after + /// releasing the shard lock and do not have this restriction. + /// + /// The closure must be `'static` (its captures cannot borrow from the local stack), but `K` + /// and `V` themselves are not required to be `'static`. + #[must_use] + pub fn on_evict(mut self, on_evict: impl Fn(&K, &V) + Send + Sync + 'static) -> Self { + self.on_evict = Some(Arc::new(on_evict)); + self + } + + fn resolve_per_shard_cap(&self, n_shards: usize) -> Result { + match (self.max_size, self.per_shard_max_size) { + (Some(_), Some(_)) => Err(BuildError::InvalidValue { + field: "max_size / per_shard_max_size", + reason: "`max_size` and `per_shard_max_size` are mutually exclusive", + }), + (None, None) => Err(BuildError::MissingRequired("max_size")), + (Some(total), None) => { + if total == 0 { + return Err(BuildError::InvalidValue { + field: "max_size", + reason: "must be greater than zero", + }); + } + let mut cap = total.div_ceil(n_shards); + if n_shards > 1 { + // Enforce a minimum capacity of 16 per shard to avoid capacity fragmentation/eviction flakes + cap = std::cmp::max(cap, 16); + } + Ok(cap) + } + (None, Some(per)) => { + if per == 0 { + return Err(BuildError::InvalidValue { + field: "per_shard_max_size", + reason: "must be greater than zero", + }); + } + Ok(per) + } + } + } + + fn total_capacity(&self, n_shards: usize, per_shard_cap: usize) -> Result { + // Name the attribute the user actually set so the diagnostic points at the + // right knob (`per_shard_max_size` multiplies by shard count; `max_size` does not). + let field = if self.per_shard_max_size.is_some() { + "per_shard_max_size" + } else { + "max_size" + }; + n_shards + .checked_mul(per_shard_cap) + .ok_or(BuildError::InvalidValue { + field, + reason: "effective sharded capacity overflows usize", + }) + } + + /// Build the new cache and copy every non-expired entry from `existing` into it, + /// preserving LRU ordering (least-recently-used entries inserted first so that + /// most-recently-used entries end up at the head of the new cache). + /// + /// Acquires each shard's read lock on `existing` one at a time — `existing` + /// keeps serving concurrent ops throughout. Entries whose + /// [`is_expired`](crate::Expires::is_expired) returns `true` at copy time are + /// skipped and not transferred. Entries that cannot fit in the new per-shard + /// capacity are evicted (LRU-first), firing `on_evict` on the NEW cache's + /// callback if set. + /// + /// **Note**: `on_evict` callbacks on `existing` do not fire — entries are read + /// (not removed) from the source cache. + /// + /// # Panics + /// + /// Panics if `size` (or `per_shard_max_size`) was not set or is `0`. + #[must_use] + pub fn copy_from>( + self, + existing: &ShardedExpiringLruCacheBase, + ) -> ShardedExpiringLruCacheBase + where + K: Clone + Hash + Eq, + V: Clone, + H: ShardHasher, + { + let new_cache = self + .try_build() + .unwrap_or_else(|e| panic!("ShardedExpiringLruCache build failed: {e}")); + for shard in existing.inner.shards.iter() { + // iter_order returns MRU-first; insert in reverse (LRU-first) so + // that MRU entries land at the head of the new cache. + let entries: Vec<(K, V)> = { + let guard = shard.lock.read(); + guard.iter_order() + }; + for (k, v) in entries.into_iter().rev() { + if !v.is_expired() { + let _ = ConcurrentCached::cache_set(&new_cache, k, v); + } + } + } + new_cache + } + + /// Build the cache. + /// + /// # Panics + /// + /// Panics if construction fails (e.g. shard count overflow, missing size). + #[must_use] + pub fn build(self) -> ShardedExpiringLruCacheBase + where + K: Hash + Eq + Clone, + H: ShardHasher, + { + self.try_build() + .unwrap_or_else(|e| panic!("ShardedExpiringLruCache build failed: {e}")) + } + + /// Build the cache, returning an error if required fields are missing or invalid. + /// + /// # Errors + /// + /// 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. + pub fn try_build(self) -> Result, BuildError> + where + K: Hash + Eq + Clone, + H: ShardHasher, + { + let n = checked_shard_count(self.shards)?; + let mask = n - 1; + let per_shard_cap = self.resolve_per_shard_cap(n)?; + let total_cap = self.total_capacity(n, per_shard_cap)?; + let on_evict = self.on_evict.clone(); + let shards = (0..n) + .map(|_| { + let mut lru = LruCache::try_with_max_size(per_shard_cap)?; + lru.on_evict = on_evict.clone(); + lru.disable_hit_miss_tracking(); + Ok(CachePadded(Shard::new(lru))) + }) + .collect::, BuildError>>()? + .into_boxed_slice(); + Ok(ShardedExpiringLruCacheBase { + inner: Arc::new(ExpiringLruInner { + shards, + shard_mask: mask, + hasher: self + .hasher + .expect("hasher is always initialized via Default or .hasher()"), + on_evict: self.on_evict, + evictions: AtomicU64::new(0), + total_capacity: total_cap, + }), + }) + } +} + +impl ConcurrentCloneCached for ShardedExpiringLruCacheBase +where + K: Hash + Eq + Clone, + V: Clone + Expires, + H: ShardHasher, +{ + /// Returns `(Some(v), false)` for a live entry (hit, LRU promoted), `(Some(v), true)` for an + /// expired entry (miss, **no removal**, no LRU promotion, no eviction counter), or + /// `(None, false)` when absent (miss). + fn cache_get_with_expiry_status(&self, k: &K) -> (Option, bool) { + let shard = self.shard_of(k); + let mut guard = shard.lock.write(); + // Single peek captures both expiry status and value; the expired path + // can then return without a second lookup. + let (expired, peeked) = match guard.cache_peek(k) { + None => { + drop(guard); + shard.misses.fetch_add(1, Ordering::Relaxed); + return (None, false); + } + Some(v) => (v.is_expired(), v.clone()), + }; + if expired { + // Return stale value without removing the entry, promoting LRU recency, + // or touching eviction counters. + drop(guard); + shard.misses.fetch_add(1, Ordering::Relaxed); + (Some(peeked), true) + } else { + // Live hit — promote LRU recency via cache_get. + let value = guard.cache_get(k).cloned(); + drop(guard); + shard.hits.fetch_add(1, Ordering::Relaxed); + (value, false) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ConcurrentCached as SyncConcurrentCached; + use crate::ConcurrentCloneCached; + + #[derive(Clone)] + struct Val { + v: u32, + expired: bool, + } + impl crate::Expires for Val { + fn is_expired(&self) -> bool { + self.expired + } + } + + #[test] + fn copy_from_skips_expired() { + let old = ShardedExpiringLruCache::::with_max_size(64); + for i in 0..10u32 { + SyncConcurrentCached::cache_set( + &old, + i, + Val { + v: i, + expired: true, + }, + ) + .expect("insert must succeed"); + } + let new_cache = ShardedExpiringLruCacheBase::::builder() + .max_size(64) + .copy_from(&old); + assert_eq!(new_cache.len(), 0); + } + + #[test] + fn copy_from_preserves_live_entries() { + let old = ShardedExpiringLruCache::::with_max_size(64); + for i in 0..20u32 { + SyncConcurrentCached::cache_set( + &old, + i, + Val { + v: i * 10, + expired: false, + }, + ) + .expect("insert must succeed"); + } + let new_cache = ShardedExpiringLruCacheBase::::builder() + .max_size(64) + .copy_from(&old); + assert_eq!(new_cache.len(), 20); + for i in 0..20u32 { + let got = + SyncConcurrentCached::cache_get(&new_cache, &i).expect("key was just inserted"); + assert_eq!(got.map(|v| v.v), Some(i * 10)); + } + } + + #[test] + fn copy_from_respects_capacity() { + let old = ShardedExpiringLruCache::::with_max_size(64); + for i in 0..40u32 { + SyncConcurrentCached::cache_set( + &old, + i, + Val { + v: i, + expired: false, + }, + ) + .expect("insert must succeed"); + } + let new_cache = ShardedExpiringLruCacheBase::::builder() + .shards(1) + .max_size(8) + .copy_from(&old); + assert!( + new_cache.len() <= 8, + "new cache should not exceed capacity; got {}", + new_cache.len() + ); + assert!(!new_cache.is_empty(), "new cache should not be empty"); + } + + #[test] + fn cache_remove_fires_on_evict_and_updates_metrics() { + use std::sync::atomic::{AtomicU64, Ordering as AtomicOrd}; + use std::sync::Arc; + + let evict_count = Arc::new(AtomicU64::new(0)); + let ec = evict_count.clone(); + let cache = ShardedExpiringLruCacheBase::::builder() + .shards(1) + .max_size(8) + .on_evict(move |_, _| { + ec.fetch_add(1, AtomicOrd::Relaxed); + }) + .build(); + + SyncConcurrentCached::cache_set( + &cache, + 1, + Val { + v: 1, + expired: false, + }, + ) + .expect("insert must succeed"); + SyncConcurrentCached::cache_set( + &cache, + 2, + Val { + v: 2, + expired: true, + }, + ) + .expect("insert must succeed"); + + let before = cache.metrics().evictions.expect("eviction-tracking stores report an evictions count"); + + // Removing a live (non-expired) entry fires on_evict and increments evictions. + let got = SyncConcurrentCached::cache_remove(&cache, &1).expect("key must be present"); + assert_eq!(got.map(|v| v.v), Some(1)); + assert_eq!( + evict_count.load(AtomicOrd::Relaxed), + 1, + "on_evict must fire" + ); + assert_eq!( + cache.metrics().evictions.expect("eviction-tracking stores report an evictions count") - before, + 1, + "evictions metric must increment on successful remove" + ); + + // Removing an expired entry fires on_evict and increments evictions, but + // returns None (the value is expired) — consistent across all stores. + let before2 = cache.metrics().evictions.expect("eviction-tracking stores report an evictions count"); + let got2 = SyncConcurrentCached::cache_remove(&cache, &2).expect("key must be present"); + assert_eq!( + got2.map(|v| v.v), + None, + "expired entry must return None from cache_remove" + ); + assert_eq!( + evict_count.load(AtomicOrd::Relaxed), + 2, + "on_evict must fire even for expired entries" + ); + // Evictions counter still increments for expired explicit removes. + assert_eq!( + cache.metrics().evictions.expect("eviction-tracking stores report an evictions count") - before2, + 1, + "evictions metric increments even for expired removes" + ); + } + + #[test] + fn convenience_constructor_panics_include_build_error_context() { + let panic = + std::panic::catch_unwind(|| ShardedExpiringLruCache::::with_max_size(0)) + .expect_err("zero size should panic"); + let message = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .unwrap_or(""); + assert!(message.contains("ShardedExpiringLruCache build failed")); + assert!(message.contains("max_size")); + + let panic = std::panic::catch_unwind(|| { + ShardedExpiringLruCache::::with_max_size_and_shards(1, 0) + }) + .expect_err("zero shards should panic"); + let message = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .unwrap_or(""); + assert!(message.contains("ShardedExpiringLruCache build failed")); + assert!(message.contains("shards")); + } + + #[test] + fn cache_clear_with_on_evict_fires_for_all_entries() { + use std::sync::atomic::{AtomicU64, Ordering as AtomicOrd}; + use std::sync::Arc; + let count = Arc::new(AtomicU64::new(0)); + let count2 = count.clone(); + let c = ShardedExpiringLruCacheBase::::builder() + .shards(1) + .max_size(64) + .on_evict(move |_, _| { + count2.fetch_add(1, AtomicOrd::Relaxed); + }) + .build(); + for i in 0..20u32 { + SyncConcurrentCached::cache_set( + &c, + i, + Val { + v: i, + expired: false, + }, + ) + .expect("insert must succeed"); + } + let before = c.metrics().evictions.expect("eviction-tracking stores report an evictions count"); + c.cache_clear_with_on_evict(); + assert_eq!( + c.len(), + 0, + "cache must be empty after cache_clear_with_on_evict" + ); + assert_eq!( + count.load(AtomicOrd::Relaxed), + 20, + "on_evict must fire for every entry" + ); + assert_eq!( + c.metrics().evictions.expect("eviction-tracking stores report an evictions count") - before, + 20, + "evictions counter must increment for each entry" + ); + } + + #[test] + fn clear_does_not_fire_on_evict() { + use std::sync::atomic::{AtomicU64, Ordering as AtomicOrd}; + use std::sync::Arc; + let count = Arc::new(AtomicU64::new(0)); + let count2 = count.clone(); + let c = ShardedExpiringLruCacheBase::::builder() + .max_size(64) + .on_evict(move |_, _| { + count2.fetch_add(1, AtomicOrd::Relaxed); + }) + .build(); + for i in 0..10u32 { + SyncConcurrentCached::cache_set( + &c, + i, + Val { + v: i, + expired: false, + }, + ) + .expect("insert must succeed"); + } + c.clear(); + assert_eq!( + count.load(AtomicOrd::Relaxed), + 0, + "clear must not fire on_evict" + ); + } + + #[test] + fn cache_remove_entry_returns_some_for_live_entry() { + let c = ShardedExpiringLruCache::::with_max_size(64); + SyncConcurrentCached::cache_set( + &c, + 1u32, + Val { + v: 1, + expired: false, + }, + ) + .expect("insert must succeed"); + assert!(c + .cache_remove_entry(&999u32) + .expect("cache_remove_entry must succeed") + .is_none()); + let removed = c.cache_remove_entry(&1u32).expect("key must be present"); + assert!(removed.is_some()); + assert_eq!(removed.expect("must be Some").0, 1u32); + assert!(SyncConcurrentCached::cache_get(&c, &1u32) + .expect("cache_get must succeed") + .is_none()); + } + + #[test] + fn cache_remove_entry_returns_some_for_expired_entry() { + let c = ShardedExpiringLruCache::::with_max_size(64); + SyncConcurrentCached::cache_set( + &c, + 1u32, + Val { + v: 1, + expired: true, + }, + ) + .expect("insert must succeed"); + SyncConcurrentCached::cache_set( + &c, + 2u32, + Val { + v: 2, + expired: true, + }, + ) + .expect("insert must succeed"); + + // cache_remove returns None for expired. + assert!(SyncConcurrentCached::cache_remove(&c, &1u32) + .expect("cache_remove must succeed") + .is_none()); + + // cache_remove_entry returns Some even for expired. + let removed = c.cache_remove_entry(&2u32).expect("key must be present"); + assert!( + removed.is_some(), + "cache_remove_entry must return Some for expired entry" + ); + assert_eq!(removed.expect("must be Some").0, 2u32); + } + + #[test] + fn cache_delete_returns_true_for_expired_entry() { + let c = ShardedExpiringLruCache::::with_max_size(64); + SyncConcurrentCached::cache_set( + &c, + 1u32, + Val { + v: 1, + expired: true, + }, + ) + .expect("insert must succeed"); + assert!( + SyncConcurrentCached::cache_delete(&c, &1u32).expect("cache_delete must succeed"), + "cache_delete must be true for expired entry" + ); + assert!(!SyncConcurrentCached::cache_delete(&c, &1u32).expect("cache_delete must succeed")); + } + + #[test] + fn cache_remove_entry_fires_on_evict_for_expired() { + use std::sync::atomic::{AtomicU64, Ordering as AtomicOrd}; + use std::sync::Arc; + let count = Arc::new(AtomicU64::new(0)); + let count2 = count.clone(); + let c = ShardedExpiringLruCacheBase::::builder() + .shards(1) + .max_size(64) + .on_evict(move |_, _| { + count2.fetch_add(1, AtomicOrd::Relaxed); + }) + .build(); + SyncConcurrentCached::cache_set( + &c, + 1u32, + Val { + v: 1, + expired: true, + }, + ) + .expect("insert must succeed"); + c.cache_remove_entry(&1u32).expect("key must be present"); + assert_eq!( + count.load(AtomicOrd::Relaxed), + 1, + "on_evict fires for expired entries" + ); + + c.cache_remove_entry(&999u32) + .expect("cache_remove_entry must succeed"); + assert_eq!(count.load(AtomicOrd::Relaxed), 1, "no fire for absent key"); + } + + #[test] + fn cache_remove_entry_increments_eviction_counter() { + let c = ShardedExpiringLruCacheBase::::builder() + .max_size(64) + .shards(1) + .build(); + SyncConcurrentCached::cache_set( + &c, + 1u32, + Val { + v: 1, + expired: true, + }, + ) + .expect("insert must succeed"); + let before = c.metrics().evictions.expect("evictions are always tracked"); + c.cache_remove_entry(&1u32).expect("key must be present"); // expired but present — must increment + c.cache_remove_entry(&999u32) + .expect("cache_remove_entry must succeed"); // absent — must not increment + assert_eq!( + c.metrics().evictions.expect("evictions are always tracked") - before, + 1, + "cache_remove_entry must increment evictions for present key only" + ); + } + + // --- ConcurrentCloneCached tests --- + + #[test] + fn concurrent_clone_cached_absent_is_none_false() { + let c = ShardedExpiringLruCache::::with_max_size(64); + let (val, expired) = ConcurrentCloneCached::cache_get_with_expiry_status(&c, &1u32); + assert!(val.is_none(), "absent key must return None"); + assert!(!expired, "absent key must return expired=false"); + assert_eq!( + c.metrics().misses, + Some(1), + "absent lookup must increment misses" + ); + } + + #[test] + fn concurrent_clone_cached_live_entry_is_some_false() { + let c = ShardedExpiringLruCache::::with_max_size(64); + SyncConcurrentCached::cache_set( + &c, + 1u32, + Val { + v: 7, + expired: false, + }, + ) + .expect("insert must succeed"); + let result = ConcurrentCloneCached::cache_get_with_expiry_status(&c, &1u32); + assert_eq!( + result.0.map(|v| v.v), + Some(7), + "live entry must return the value" + ); + assert!(!result.1, "live entry must not set the expired flag"); + assert_eq!(c.metrics().hits, Some(1), "live lookup must increment hits"); + assert_eq!( + c.metrics().evictions, + Some(0), + "live lookup must not increment evictions" + ); + } + + #[test] + fn concurrent_clone_cached_expired_returns_stale_no_eviction() { + let c = ShardedExpiringLruCacheBase::::builder() + .max_size(64) + .shards(1) + .build(); + SyncConcurrentCached::cache_set( + &c, + 1u32, + Val { + v: 55, + expired: true, + }, + ) + .expect("insert must succeed"); + + let result = ConcurrentCloneCached::cache_get_with_expiry_status(&c, &1u32); + assert_eq!( + result.0.map(|v| v.v), + Some(55), + "expired entry must return the stale value" + ); + assert!(result.1, "expired entry must set the expired flag"); + assert_eq!( + c.metrics().misses, + Some(1), + "expired lookup must increment misses" + ); + assert_eq!( + c.metrics().evictions, + Some(0), + "expired lookup must NOT increment evictions" + ); + + // Entry must NOT have been removed — a second call still sees it. + let result2 = ConcurrentCloneCached::cache_get_with_expiry_status(&c, &1u32); + assert_eq!( + result2.0.map(|v| v.v), + Some(55), + "entry must still be present after expiry-status lookup" + ); + assert!( + result2.1, + "entry must still be expired on second expiry-status call" + ); + } +} diff --git a/src/stores/sharded/lru.rs b/src/stores/sharded/lru.rs new file mode 100644 index 00000000..433bb4aa --- /dev/null +++ b/src/stores/sharded/lru.rs @@ -0,0 +1,979 @@ +use std::hash::Hash; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +#[cfg(feature = "async_core")] +use crate::ConcurrentCachedAsync; +use crate::{CacheMetrics, CachedIter, ConcurrentCached}; + +use super::{ + checked_shard_count, shard_index, CachePadded, DefaultShardHasher, Shard, ShardHasher, +}; +use crate::stores::{BuildError, LruCache}; + +type OnEvict = Arc; + +#[allow(clippy::type_complexity)] +struct LruInner { + shards: Box<[CachePadded>>]>, + shard_mask: usize, + hasher: H, + on_evict: Option>, + /// Total logical capacity (sum of per-shard caps). + total_capacity: usize, +} + +/// A fully-concurrent, partitioned, LRU-bounded in-memory cache. +/// +/// Wraps an `Arc` — `clone()` is an Arc-share (shared state), not a deep copy. +/// 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()`]. +/// +/// **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. +/// +/// **Note**: `K` must implement `Clone` (needed for LRU key tracking). `ShardedCache` +/// requires only `K: Hash + Eq`. `V` must also implement `Clone`, because reads return owned +/// values cloned from under the shard lock. +/// +/// **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`. +pub type ShardedLruCache = ShardedLruCacheBase; + +/// Backing type for [`ShardedLruCache`] with a generic shard hasher `H`. +pub struct ShardedLruCacheBase { + inner: Arc>, +} + +impl Clone for ShardedLruCacheBase { + /// Arc-share clone — both handles point to the same underlying cache. + fn clone(&self) -> Self { + Self { + inner: Arc::clone(&self.inner), + } + } +} + +impl std::fmt::Debug for ShardedLruCacheBase { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ShardedLruCache") + .field("shards", &self.inner.shards.len()) + .field("capacity", &self.inner.total_capacity) + .finish_non_exhaustive() + } +} + +impl ShardedLruCacheBase +where + K: Hash + Eq + Clone, + H: ShardHasher, +{ + /// Return a builder for constructing a [`ShardedLruCacheBase`]. + /// + /// 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() -> ShardedLruCacheBuilder { + ShardedLruCacheBuilder::default() + } + + #[inline] + fn shard_of(&self, k: &K) -> &CachePadded>> { + let h = self.inner.hasher.shard_hash(k); + &self.inner.shards[shard_index(h, self.inner.shard_mask)] + } +} + +impl ShardedLruCache +where + K: Hash + Eq + Clone, +{ + /// Create a new LRU-bounded sharded cache with the requested 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**. + /// With the default shard count, the minimum effective capacity is + /// `16 * default_shard_count()` entries. + /// If you require smaller, strict limits under low capacities, configure `shards = 1` or specify + /// `per_shard_max_size` directly. + #[must_use] + pub fn with_max_size(size: usize) -> Self { + ShardedLruCacheBuilder::default() + .max_size(size) + .try_build() + .unwrap_or_else(|error| panic!("ShardedLruCache build failed: {error}")) + } + + /// Create a new LRU-bounded sharded cache with the requested total capacity and shard count. + /// + /// **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**. + /// If you require smaller, strict limits under low capacities, configure `shards = 1` or specify + /// `per_shard_max_size` directly. + #[must_use] + pub fn with_max_size_and_shards(size: usize, shards: usize) -> Self { + ShardedLruCacheBuilder::default() + .max_size(size) + .shards(shards) + .try_build() + .unwrap_or_else(|error| panic!("ShardedLruCache build failed: {error}")) + } +} + +impl + Clone> 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. + #[must_use] + pub fn deep_clone(&self) -> Self { + let n = self.inner.shards.len(); + let shards = (0..n) + .map(|i| { + let guard = self.inner.shards[i].lock.read(); + let store_copy = guard.clone(); + let hits = self.inner.shards[i].hits.load(Ordering::Relaxed); + let misses = self.inner.shards[i].misses.load(Ordering::Relaxed); + drop(guard); + let shard = Shard { + lock: parking_lot::RwLock::new(store_copy), + hits: AtomicU64::new(hits), + misses: AtomicU64::new(misses), + }; + CachePadded(shard) + }) + .collect::>() + .into_boxed_slice(); + Self { + inner: Arc::new(LruInner { + shards, + shard_mask: self.inner.shard_mask, + hasher: self.inner.hasher.clone(), + on_evict: self.inner.on_evict.clone(), + total_capacity: self.inner.total_capacity, + }), + } + } +} + +impl> ShardedLruCacheBase +where + K: Hash + Eq + Clone, +{ + /// Return aggregate metrics across all shards. + /// + /// `evictions` counts both LRU capacity evictions (tracked per-shard) and + /// explicit removes via [`ConcurrentCached::cache_remove`]. + /// `capacity` reflects the effective total capacity — may exceed the requested + /// `size` when the 16-per-shard minimum floor is applied; see [`capacity`](Self::capacity). + #[must_use] + pub fn metrics(&self) -> CacheMetrics { + let mut hits = 0u64; + let mut misses = 0u64; + let mut evictions = 0u64; + let mut size = 0usize; + for shard in self.inner.shards.iter() { + hits += shard.hits.load(Ordering::Relaxed); + misses += shard.misses.load(Ordering::Relaxed); + let guard = shard.lock.read(); + if let Some(e) = guard.cache_evictions() { + evictions += e; + } + size += guard.cache_size(); + } + + CacheMetrics { + hits: Some(hits), + misses: Some(misses), + evictions: Some(evictions), + size, + capacity: Some(self.inner.total_capacity), + } + } + + /// Number of shards. + #[must_use] + pub fn shards(&self) -> usize { + self.inner.shards.len() + } + + /// Per-shard live entry counts — useful for diagnosing key distribution skew. + #[must_use] + pub fn shard_sizes(&self) -> Vec { + self.inner + .shards + .iter() + .map(|s| s.lock.read().cache_size()) + .collect() + } + + /// Total number of live entries across all shards. + #[must_use] + pub fn len(&self) -> usize { + self.inner + .shards + .iter() + .map(|s| s.lock.read().cache_size()) + .sum() + } + + /// `true` if no entries are present. + #[must_use] + pub fn is_empty(&self) -> bool { + self.inner + .shards + .iter() + .all(|s| s.lock.read().cache_size() == 0) + } + + /// Remove all entries from every shard. Does **not** fire `on_evict`. + /// Use [`cache_clear_with_on_evict`](Self::cache_clear_with_on_evict) to opt into callback firing. + pub fn clear(&self) { + for shard in self.inner.shards.iter() { + shard.lock.write().cache_clear(); + } + } + + /// Remove all entries from every shard, firing `on_evict` for each removed entry when a + /// callback is configured. + /// + /// If no `on_evict` callback is configured, this is equivalent to [`clear`](Self::clear). + /// Increments the evictions counter for each removed entry only when `on_evict` is set. + pub fn cache_clear_with_on_evict(&self) { + if self.inner.on_evict.is_none() { + return self.clear(); + } + for shard in self.inner.shards.iter() { + let removed: Vec<(K, V)> = { + let mut guard = shard.lock.write(); + let keys: Vec = guard.iter().map(|(k, _)| k.clone()).collect(); + let mut removed = Vec::with_capacity(keys.len()); + for k in keys { + if let Some(pair) = guard.pop_raw(&k) { + removed.push(pair); + } + } + if !removed.is_empty() { + guard + .evictions + .fetch_add(removed.len() as u64, Ordering::Relaxed); + } + removed + }; + if let Some(on_evict) = &self.inner.on_evict { + for (k, v) in &removed { + on_evict(k, v); + } + } + } + } + + /// Effective total capacity across all shards. + /// + /// When constructed with [`max_size`](ShardedLruCacheBuilder::max_size), this may + /// be larger than the requested size because per-shard capacity is rounded + /// up with ceiling division. + #[must_use] + pub fn capacity(&self) -> usize { + self.inner.total_capacity + } +} + +impl ShardedLruCacheBase +where + K: Hash + Eq + Clone, + V: Clone, + H: ShardHasher, +{ + /// Retrieve a value from the cache. + #[inline] + pub fn cache_get(&self, k: &K) -> Result, std::convert::Infallible> { + >::cache_get(self, k) + } + + /// Set a value in the cache. + #[inline] + pub fn cache_set(&self, k: K, v: V) -> Result, std::convert::Infallible> { + >::cache_set(self, k, v) + } + + /// Remove a value from the cache. + #[inline] + pub fn cache_remove(&self, k: &K) -> Result, std::convert::Infallible> { + >::cache_remove(self, k) + } + + /// Remove an entry from the cache, returning the stored key and value. + #[inline] + pub fn cache_remove_entry(&self, k: &K) -> Result, std::convert::Infallible> { + >::cache_remove_entry(self, k) + } + + /// Delete a value from the cache. + #[inline] + pub fn cache_delete(&self, k: &K) -> Result { + >::cache_delete(self, k) + } +} + +use crate::Cached; + +impl ConcurrentCached for ShardedLruCacheBase +where + K: Hash + Eq + Clone, + V: Clone, + H: ShardHasher, +{ + type Error = std::convert::Infallible; + + fn cache_get(&self, k: &K) -> Result, Self::Error> { + let shard = self.shard_of(k); + let mut guard = shard.lock.write(); + match guard.cache_get(k) { + Some(v) => { + let v = v.clone(); + shard.hits.fetch_add(1, Ordering::Relaxed); + Ok(Some(v)) + } + None => { + shard.misses.fetch_add(1, Ordering::Relaxed); + Ok(None) + } + } + } + + fn cache_set(&self, k: K, v: V) -> Result, Self::Error> { + let shard = self.shard_of(&k); + Ok(shard.lock.write().cache_set(k, v)) + } + + fn cache_remove(&self, k: &K) -> Result, Self::Error> { + self.cache_remove_entry(k).map(|r| r.map(|(_, v)| v)) + } + + fn cache_remove_entry(&self, k: &K) -> Result, Self::Error> { + let shard = self.shard_of(k); + let removed = { + let mut guard = shard.lock.write(); + let removed = guard.pop_raw(k); + if removed.is_some() { + guard.evictions.fetch_add(1, Ordering::Relaxed); + } + removed + }; + if let Some((ref key, ref value)) = removed { + if let Some(on_evict) = &self.inner.on_evict { + on_evict(key, value); + } + } + Ok(removed) + } + + /// No-op: this store has no TTL to refresh on hit. Always returns `false`. + fn set_refresh_on_hit(&mut self, _refresh: bool) -> bool { + false + } +} + +#[cfg(feature = "async_core")] +impl ConcurrentCachedAsync for ShardedLruCacheBase +where + K: Hash + Eq + Clone + Send + Sync, + V: Clone + Send + Sync, + H: ShardHasher, +{ + type Error = std::convert::Infallible; + + async fn cache_get(&self, k: &K) -> Result, Self::Error> { + ConcurrentCached::cache_get(self, k) + } + + async fn cache_set(&self, k: K, v: V) -> Result, Self::Error> { + ConcurrentCached::cache_set(self, k, v) + } + + async fn cache_remove(&self, k: &K) -> Result, Self::Error> { + ConcurrentCached::cache_remove(self, k) + } + + async fn cache_remove_entry(&self, k: &K) -> Result, Self::Error> { + ConcurrentCached::cache_remove_entry(self, k) + } + + fn set_refresh_on_hit(&mut self, b: bool) -> bool { + >::set_refresh_on_hit(self, b) + } +} + +/// Builder for [`ShardedLruCacheBase`]. +pub struct ShardedLruCacheBuilder { + shards: Option, + max_size: Option, + per_shard_max_size: Option, + hasher: Option, + on_evict: Option>, + _k: std::marker::PhantomData, + _v: std::marker::PhantomData, +} + +impl Default for ShardedLruCacheBuilder { + fn default() -> Self { + Self { + shards: None, + max_size: None, + per_shard_max_size: None, + hasher: Some(DefaultShardHasher::default()), + on_evict: None, + _k: std::marker::PhantomData, + _v: std::marker::PhantomData, + } + } +} + +impl ShardedLruCacheBuilder { + /// Set the requested total capacity (divided across shards via `div_ceil`). + /// + /// Eviction is enforced independently per shard. Each shard gets + /// `ceil(size / shards)` entries, with a minimum of 16 per shard when + /// `shards > 1` (see the **Capacity Fragmentation Warning** on + /// [`with_max_size`](ShardedLruCache::with_max_size)). + /// Use [`per_shard_max_size`](Self::per_shard_max_size) for an exact per-shard cap. + /// Mutually exclusive with [`per_shard_max_size`](Self::per_shard_max_size). + #[doc(alias = "size")] + #[doc(alias = "capacity")] + #[must_use] + pub fn max_size(mut self, max_size: usize) -> Self { + self.max_size = Some(max_size); + self + } + + /// Set per-shard capacity directly. Advanced — bypasses the automatic + /// division. Mutually exclusive with [`max_size`](Self::max_size). + #[must_use] + pub fn per_shard_max_size(mut self, per_shard_max_size: usize) -> Self { + self.per_shard_max_size = Some(per_shard_max_size); + self + } + + /// Set the number of shards (rounded up to the next power of two). + #[must_use] + pub fn shards(mut self, shards: usize) -> Self { + self.shards = Some(shards); + self + } + + /// Set a custom shard-selection hasher, changing the type parameter. + #[must_use] + pub fn hasher>(self, hasher: H2) -> ShardedLruCacheBuilder { + ShardedLruCacheBuilder { + shards: self.shards, + max_size: self.max_size, + per_shard_max_size: self.per_shard_max_size, + hasher: Some(hasher), + on_evict: self.on_evict, + _k: std::marker::PhantomData, + _v: std::marker::PhantomData, + } + } + + /// Set a callback invoked when an entry is evicted by LRU capacity pressure, explicit + /// [`cache_remove`](ConcurrentCached::cache_remove), or + /// [`cache_remove_entry`](ConcurrentCached::cache_remove_entry). + /// Does **not** fire on [`clear`](ShardedLruCacheBase::clear); + /// use [`cache_clear_with_on_evict`](ShardedLruCacheBase::cache_clear_with_on_evict) to opt in. + /// + /// Capacity-eviction callbacks run while the affected shard's write lock is held. Do not call + /// methods on the same sharded cache from the callback; doing so can deadlock if the callback + /// re-enters the locked shard. + /// + /// The closure must be `'static` (its captures cannot borrow from the local stack), but `K` + /// and `V` themselves are not required to be `'static`. + #[must_use] + pub fn on_evict(mut self, on_evict: impl Fn(&K, &V) + Send + Sync + 'static) -> Self { + self.on_evict = Some(Arc::new(on_evict)); + self + } + + fn resolve_per_shard_cap(&self, n_shards: usize) -> Result { + match (self.max_size, self.per_shard_max_size) { + (Some(_), Some(_)) => Err(BuildError::InvalidValue { + field: "max_size / per_shard_max_size", + reason: "`max_size` and `per_shard_max_size` are mutually exclusive", + }), + (None, None) => Err(BuildError::MissingRequired("max_size")), + (Some(total), None) => { + if total == 0 { + return Err(BuildError::InvalidValue { + field: "max_size", + reason: "must be greater than zero", + }); + } + let mut cap = total.div_ceil(n_shards); + if n_shards > 1 { + // Enforce a minimum capacity of 16 per shard to avoid capacity fragmentation/eviction flakes + cap = std::cmp::max(cap, 16); + } + Ok(cap) + } + (None, Some(per)) => { + if per == 0 { + return Err(BuildError::InvalidValue { + field: "per_shard_max_size", + reason: "must be greater than zero", + }); + } + Ok(per) + } + } + } + + fn total_capacity(&self, n_shards: usize, per_shard_cap: usize) -> Result { + // Name the attribute the user actually set so the diagnostic points at the + // right knob (`per_shard_max_size` multiplies by shard count; `max_size` does not). + let field = if self.per_shard_max_size.is_some() { + "per_shard_max_size" + } else { + "max_size" + }; + n_shards + .checked_mul(per_shard_cap) + .ok_or(BuildError::InvalidValue { + field, + reason: "effective sharded capacity overflows usize", + }) + } + + /// Build the cache, returning an error if required fields are missing or invalid. + /// + /// # Errors + /// + /// 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. + pub fn try_build(self) -> Result, BuildError> + where + K: Hash + Eq + Clone, + H: ShardHasher, + { + let n = checked_shard_count(self.shards)?; + let mask = n - 1; + let per_shard_cap = self.resolve_per_shard_cap(n)?; + let total_cap = self.total_capacity(n, per_shard_cap)?; + let on_evict = self.on_evict.clone(); + let shards = (0..n) + .map(|_| { + let mut lru = LruCache::try_with_max_size(per_shard_cap)?; + lru.on_evict = on_evict.clone(); + lru.disable_hit_miss_tracking(); + Ok(CachePadded(Shard::new(lru))) + }) + .collect::, BuildError>>()? + .into_boxed_slice(); + Ok(ShardedLruCacheBase { + inner: Arc::new(LruInner { + shards, + shard_mask: mask, + hasher: self + .hasher + .expect("hasher is always initialized via Default or .hasher()"), + on_evict: self.on_evict, + total_capacity: total_cap, + }), + }) + } + + /// Build the cache. + /// + /// # Panics + /// + /// Panics if `size` (or `per_shard_max_size`) was not set or is `0`. + #[must_use] + pub fn build(self) -> ShardedLruCacheBase + where + K: Hash + Eq + Clone, + H: ShardHasher, + { + self.try_build() + .unwrap_or_else(|e| panic!("ShardedLruCache build failed: {e}")) + } + + /// Build the new cache and copy every entry from `existing` into it, + /// preserving per-shard LRU ordering (least-recently-used entries inserted + /// first so that most-recently-used entries end up at the head of each + /// shard). After resharding, global recency rank across all shards is not + /// guaranteed to be preserved. + /// + /// Acquires each shard's read lock on `existing` one at a time — `existing` + /// keeps serving concurrent ops throughout. Entries that cannot fit in the + /// new per-shard capacity are evicted (LRU-first), firing `on_evict` on the + /// NEW cache's callback if set. + /// + /// **Note**: `on_evict` callbacks on `existing` do not fire — entries are read + /// (not removed) from the source cache. + #[must_use] + pub fn copy_from>( + self, + existing: &ShardedLruCacheBase, + ) -> ShardedLruCacheBase + where + K: Clone + Hash + Eq, + V: Clone, + H: ShardHasher, + { + let new_cache = self + .try_build() + .unwrap_or_else(|e| panic!("ShardedLruCache build failed: {e}")); + for shard in existing.inner.shards.iter() { + // iter_order returns MRU-first; insert in reverse (LRU-first) + // so that the MRU entries are pushed in last and land at the head. + let entries: Vec<(K, V)> = { + let guard = shard.lock.read(); + guard.iter_order() + }; + for (k, v) in entries.into_iter().rev() { + let _ = ConcurrentCached::cache_set(&new_cache, k, v); + } + } + new_cache + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ConcurrentCached as SyncConcurrentCached; + + #[test] + fn basic_get_set_remove() { + let c = ShardedLruCache::::with_max_size(64); + assert_eq!( + SyncConcurrentCached::cache_get(&c, &1).expect("cache_get must succeed"), + None + ); + assert_eq!( + SyncConcurrentCached::cache_set(&c, 1, 100).expect("insert must succeed"), + None + ); + assert_eq!( + SyncConcurrentCached::cache_get(&c, &1).expect("key was just inserted"), + Some(100) + ); + assert_eq!( + SyncConcurrentCached::cache_remove(&c, &1).expect("key must be present"), + Some(100) + ); + assert_eq!( + SyncConcurrentCached::cache_get(&c, &1).expect("cache_get must succeed"), + None + ); + } + + #[test] + fn clone_shares_state() { + let c1 = ShardedLruCache::::with_max_size(64); + let c2 = c1.clone(); + SyncConcurrentCached::cache_set(&c1, 1, 10).expect("insert must succeed"); + assert_eq!( + SyncConcurrentCached::cache_get(&c2, &1).expect("key was just inserted"), + Some(10) + ); + } + + #[test] + fn eviction_fires() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let count = Arc::new(AtomicUsize::new(0)); + let count2 = count.clone(); + let c = ShardedLruCacheBase::::builder() + .max_size(8) + .shards(1) // single shard so capacity=8 exactly + .on_evict(move |_, _| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + for i in 0..16u32 { + SyncConcurrentCached::cache_set(&c, i, i).expect("insert must succeed"); + } + assert!( + count.load(Ordering::Relaxed) > 0, + "eviction should have fired" + ); + } + + #[test] + fn cache_remove_fires_on_evict() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let count = Arc::new(AtomicUsize::new(0)); + let count2 = count.clone(); + let c = ShardedLruCacheBase::::builder() + .max_size(64) + .shards(1) + .on_evict(move |_, _| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + SyncConcurrentCached::cache_set(&c, 1, 10).expect("insert must succeed"); + SyncConcurrentCached::cache_remove(&c, &1).expect("key must be present"); + assert_eq!( + count.load(Ordering::Relaxed), + 1, + "on_evict must fire on successful cache_remove" + ); + } + + #[test] + fn cache_remove_increments_eviction_metrics() { + let c = ShardedLruCache::::with_max_size(64); + SyncConcurrentCached::cache_set(&c, 1, 10).expect("insert must succeed"); + SyncConcurrentCached::cache_set(&c, 2, 20).expect("insert must succeed"); + let before = c.metrics().evictions.expect("eviction-tracking stores report an evictions count"); + SyncConcurrentCached::cache_remove(&c, &1).expect("key must be present"); + SyncConcurrentCached::cache_remove(&c, &999).expect("cache_remove must succeed"); + let after = c.metrics().evictions.expect("eviction-tracking stores report an evictions count"); + assert_eq!( + after - before, + 1, + "successful remove must increment evictions" + ); + } + + #[test] + fn per_shard_max_size_and_size_exclusive() { + let err = ShardedLruCacheBase::::builder() + .max_size(100) + .per_shard_max_size(10) + .try_build(); + assert!(err.is_err()); + } + + #[test] + fn try_build_rejects_overflowing_shards_and_capacity() { + let err = ShardedLruCacheBase::::builder() + .max_size(1) + .shards(usize::MAX) + .try_build(); + assert!(matches!( + err, + Err(BuildError::InvalidValue { + field: "shards", + .. + }) + )); + + let err = ShardedLruCacheBase::::builder() + .per_shard_max_size(usize::MAX) + .shards(2) + .try_build(); + assert!(matches!( + err, + Err(BuildError::InvalidValue { + field: "per_shard_max_size", + .. + }) + )); + } + + #[test] + fn copy_from_preserves_entries() { + // Use shards(1) to avoid per-shard capacity eviction during insertion. + let old = ShardedLruCacheBase::::builder() + .max_size(1024) + .shards(1) + .build(); + for i in 0..50u32 { + SyncConcurrentCached::cache_set(&old, i, i * 10).expect("insert must succeed"); + } + let new_cache = ShardedLruCacheBase::::builder() + .max_size(1024) + .shards(4) + .copy_from(&old); + for i in 0..50u32 { + assert_eq!( + SyncConcurrentCached::cache_get(&new_cache, &i).expect("key was just inserted"), + Some(i * 10) + ); + } + } + + #[test] + fn copy_from_respects_capacity() { + let old = ShardedLruCacheBase::::builder() + .max_size(64) + .shards(1) + .build(); + for i in 0..32u32 { + SyncConcurrentCached::cache_set(&old, i, i).expect("insert must succeed"); + } + // new cache has smaller capacity + let new_cache = ShardedLruCacheBase::::builder() + .max_size(16) + .shards(1) + .copy_from(&old); + assert!(new_cache.len() <= 16); + } + + #[test] + fn convenience_constructor_panics_include_build_error_context() { + let panic = std::panic::catch_unwind(|| ShardedLruCache::::with_max_size(0)) + .expect_err("zero size should panic"); + let message = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .unwrap_or(""); + assert!(message.contains("ShardedLruCache build failed")); + assert!(message.contains("max_size")); + + let panic = std::panic::catch_unwind(|| { + ShardedLruCache::::with_max_size_and_shards(1, 0) + }) + .expect_err("zero shards should panic"); + let message = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .unwrap_or(""); + assert!(message.contains("ShardedLruCache build failed")); + assert!(message.contains("shards")); + } + + #[test] + fn send_sync() { + fn assert_send_sync() {} + assert_send_sync::>(); + } + + #[test] + fn cache_clear_with_on_evict_fires_for_all_entries() { + use std::sync::atomic::{AtomicU64, Ordering}; + let count = Arc::new(AtomicU64::new(0)); + let count2 = count.clone(); + let c = ShardedLruCacheBase::::builder() + .shards(1) + .max_size(64) + .on_evict(move |_, _| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + for i in 0..20u32 { + SyncConcurrentCached::cache_set(&c, i, i).expect("insert must succeed"); + } + let before = c.metrics().evictions.expect("eviction-tracking stores report an evictions count"); + c.cache_clear_with_on_evict(); + assert_eq!( + c.len(), + 0, + "cache must be empty after cache_clear_with_on_evict" + ); + assert_eq!( + count.load(Ordering::Relaxed), + 20, + "on_evict must fire for every entry" + ); + assert_eq!( + c.metrics().evictions.expect("eviction-tracking stores report an evictions count") - before, + 20, + "evictions counter must increment for each entry" + ); + } + + #[test] + fn clear_does_not_fire_on_evict() { + use std::sync::atomic::{AtomicU64, Ordering}; + let count = Arc::new(AtomicU64::new(0)); + let count2 = count.clone(); + let c = ShardedLruCacheBase::::builder() + .max_size(64) + .on_evict(move |_, _| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + for i in 0..10u32 { + SyncConcurrentCached::cache_set(&c, i, i).expect("insert must succeed"); + } + c.clear(); + assert_eq!( + count.load(Ordering::Relaxed), + 0, + "clear must not fire on_evict" + ); + } + + #[test] + fn cache_remove_entry_basic() { + let c = ShardedLruCacheBase::::builder() + .shards(1) + .max_size(8) + .build(); + SyncConcurrentCached::cache_set(&c, 1u32, 100u32).expect("insert must succeed"); + + assert_eq!( + c.cache_remove_entry(&999u32) + .expect("cache_remove_entry must succeed"), + None + ); + assert_eq!( + c.cache_remove_entry(&1u32).expect("key must be present"), + Some((1u32, 100u32)) + ); + assert_eq!( + SyncConcurrentCached::cache_get(&c, &1u32).expect("cache_get must succeed"), + None + ); + } + + #[test] + fn cache_remove_entry_fires_on_evict() { + use std::sync::atomic::{AtomicU64, Ordering}; + let count = Arc::new(AtomicU64::new(0)); + let count2 = count.clone(); + let c = ShardedLruCacheBase::::builder() + .shards(1) + .max_size(8) + .on_evict(move |_, _| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + SyncConcurrentCached::cache_set(&c, 1u32, 10u32).expect("insert must succeed"); + c.cache_remove_entry(&1u32).expect("key must be present"); + assert_eq!(count.load(Ordering::Relaxed), 1); + + c.cache_remove_entry(&999u32) + .expect("cache_remove_entry must succeed"); + assert_eq!(count.load(Ordering::Relaxed), 1, "no fire for absent key"); + } + + #[test] + fn cache_remove_entry_increments_eviction_counter() { + let c = ShardedLruCacheBase::::builder() + .shards(1) + .max_size(8) + .build(); + SyncConcurrentCached::cache_set(&c, 1u32, 10u32).expect("insert must succeed"); + let before = c.metrics().evictions.expect("evictions are always tracked"); + c.cache_remove_entry(&1u32).expect("key must be present"); + c.cache_remove_entry(&999u32) + .expect("cache_remove_entry must succeed"); // absent — must not increment + assert_eq!( + c.metrics().evictions.expect("evictions are always tracked") - before, + 1, + "cache_remove_entry must increment evictions for present key only" + ); + } + + #[test] + fn cache_delete_returns_true_for_present_entry() { + let c = ShardedLruCacheBase::::builder() + .shards(1) + .max_size(8) + .build(); + SyncConcurrentCached::cache_set(&c, 1u32, 10u32).expect("insert must succeed"); + assert!(SyncConcurrentCached::cache_delete(&c, &1u32).expect("cache_delete must succeed")); + assert!(!SyncConcurrentCached::cache_delete(&c, &1u32).expect("cache_delete must succeed")); + } +} diff --git a/src/stores/sharded/lru_ttl.rs b/src/stores/sharded/lru_ttl.rs new file mode 100644 index 00000000..48e7689d --- /dev/null +++ b/src/stores/sharded/lru_ttl.rs @@ -0,0 +1,1668 @@ +use std::hash::Hash; +use std::marker::PhantomData; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; + +use crate::time::{Duration, Instant}; +#[cfg(feature = "async_core")] +use crate::ConcurrentCachedAsync; +use crate::{CacheMetrics, CacheTtl, ConcurrentCached, ConcurrentCloneCached}; + +use super::{ + checked_shard_count, shard_index, CachePadded, DefaultShardHasher, Shard, ShardHasher, +}; +use crate::stores::{BuildError, CacheEvict, HasEvict, LruCache, NoEvict, TimedEntry}; +use crate::{Cached, CachedIter, CachedPeek}; + +type OnEvict = Arc; + +#[allow(clippy::type_complexity)] +struct LruTtlInner { + shards: Box<[CachePadded>>>]>, + 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_nanos: AtomicU64, + refresh: AtomicBool, + /// Evictions not driven by LRU capacity pressure: TTL expiry (via [`evict`](ShardedLruTtlCacheBase::evict)), + /// explicit removes ([`cache_remove`](ConcurrentCached::cache_remove) / + /// [`cache_remove_entry`](ConcurrentCached::cache_remove_entry)), and + /// [`cache_clear_with_on_evict`](ShardedLruTtlCacheBase::cache_clear_with_on_evict). + /// LRU capacity evictions are tracked per-shard in the inner `LruCache`. + non_capacity_evictions: AtomicU64, + total_capacity: usize, +} + +/// A fully-concurrent, partitioned, LRU-bounded, TTL-expiring in-memory cache. +/// +/// Wraps an `Arc` — `clone()` is an Arc-share (shared state), not a deep copy. +/// Use [`deep_clone`](ShardedLruTtlCacheBase::deep_clone) to get an independent copy. +/// +/// **Note**: `K` and `V` must implement `Clone` (`K` for LRU key tracking; `V` because reads +/// 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()`]. +/// +/// **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 +/// when `refresh_on_hit` is disabled). Under many concurrent readers this can be a bottleneck; +/// consider `ShardedTtlCache` if you do not need capacity bounding. +/// +/// **Note**: `K` must implement `Clone` (needed for LRU key tracking). `ShardedTtlCache` +/// requires only `K: Hash + Eq`. +/// +/// **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. +pub type ShardedLruTtlCache = ShardedLruTtlCacheBase; + +/// Backing type for [`ShardedLruTtlCache`] with a generic shard hasher `H`. +pub struct ShardedLruTtlCacheBase { + inner: Arc>, +} + +impl Clone for ShardedLruTtlCacheBase { + /// Arc-share clone — both handles point to the same underlying cache. + fn clone(&self) -> Self { + Self { + inner: Arc::clone(&self.inner), + } + } +} + +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)) + }; + f.debug_struct("ShardedLruTtlCache") + .field("shards", &self.inner.shards.len()) + .field("capacity", &self.inner.total_capacity) + .field("ttl", &ttl) + .finish_non_exhaustive() + } +} + +impl ShardedLruTtlCacheBase +where + K: Hash + Eq + Clone, + H: ShardHasher, +{ + /// Return a builder for constructing a [`ShardedLruTtlCacheBase`]. + /// + /// 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() -> ShardedLruTtlCacheBuilder { + ShardedLruTtlCacheBuilder::default() + } + + #[inline] + fn shard_of(&self, k: &K) -> &CachePadded>>> { + let h = self.inner.hasher.shard_hash(k); + &self.inner.shards[shard_index(h, self.inner.shard_mask)] + } + + #[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)) + } + } +} + +impl ShardedLruTtlCache +where + K: Hash + Eq + Clone, +{ + /// Create a new LRU + TTL-bounded sharded cache with the requested total capacity and TTL. + /// + /// **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**. + /// With the default shard count, the minimum effective capacity is + /// `16 * default_shard_count()` entries. + /// If you require smaller, strict limits under low capacities, configure `shards = 1` or specify + /// `per_shard_max_size` directly. + #[must_use] + pub fn with_max_size_and_ttl(size: usize, ttl: Duration) -> Self { + ShardedLruTtlCacheBuilder::default() + .max_size(size) + .ttl(ttl) + .try_build() + .unwrap_or_else(|error| panic!("ShardedLruTtlCache build failed: {error}")) + } + + /// Create a new LRU + TTL-bounded sharded cache with the requested total capacity, TTL, and + /// shard count. + /// + /// **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**. + /// If you require smaller, strict limits under low capacities, configure `shards = 1` or specify + /// `per_shard_max_size` directly. + #[must_use] + pub fn with_max_size_and_ttl_and_shards(size: usize, ttl: Duration, shards: usize) -> Self { + ShardedLruTtlCacheBuilder::default() + .max_size(size) + .ttl(ttl) + .shards(shards) + .try_build() + .unwrap_or_else(|error| panic!("ShardedLruTtlCache build failed: {error}")) + } +} + +impl + Clone> 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. + #[must_use] + pub fn deep_clone(&self) -> Self { + let n = self.inner.shards.len(); + let shards = (0..n) + .map(|i| { + let guard = self.inner.shards[i].lock.read(); + let store_copy = guard.clone(); + let hits = self.inner.shards[i].hits.load(Ordering::Relaxed); + let misses = self.inner.shards[i].misses.load(Ordering::Relaxed); + drop(guard); + let shard = Shard { + lock: parking_lot::RwLock::new(store_copy), + hits: AtomicU64::new(hits), + misses: AtomicU64::new(misses), + }; + CachePadded(shard) + }) + .collect::>() + .into_boxed_slice(); + Self { + inner: Arc::new(LruTtlInner { + shards, + shard_mask: self.inner.shard_mask, + hasher: self.inner.hasher.clone(), + on_evict: self.inner.on_evict.clone(), + ttl_nanos: AtomicU64::new(self.inner.ttl_nanos.load(Ordering::Relaxed)), + refresh: AtomicBool::new(self.inner.refresh.load(Ordering::Relaxed)), + non_capacity_evictions: AtomicU64::new( + self.inner.non_capacity_evictions.load(Ordering::Relaxed), + ), + total_capacity: self.inner.total_capacity, + }), + } + } +} + +impl> ShardedLruTtlCacheBase +where + K: Hash + Eq + Clone, +{ + /// Return aggregate metrics across all shards. Evictions include LRU + /// capacity evictions (per-shard), TTL-expiry evictions, and explicit + /// [`cache_remove`](ConcurrentCached::cache_remove) calls. + /// + /// Note: the `size` field includes entries that have expired but not yet been + /// swept by [`evict`](Self::evict). Call `evict()` first for an accurate live count. + /// `capacity` reflects the effective total capacity — may exceed the requested + /// `size` when the 16-per-shard minimum floor is applied; see [`capacity`](Self::capacity). + #[must_use] + pub fn metrics(&self) -> CacheMetrics { + let mut hits = 0u64; + let mut misses = 0u64; + let mut lru_evictions = 0u64; + let mut size = 0usize; + for shard in self.inner.shards.iter() { + hits += shard.hits.load(Ordering::Relaxed); + misses += shard.misses.load(Ordering::Relaxed); + let guard = shard.lock.read(); + if let Some(e) = guard.cache_evictions() { + lru_evictions += e; + } + size += guard.cache_size(); + } + CacheMetrics { + hits: Some(hits), + misses: Some(misses), + evictions: Some( + lru_evictions + self.inner.non_capacity_evictions.load(Ordering::Relaxed), + ), + size, + capacity: Some(self.inner.total_capacity), + } + } + + /// Number of shards. + #[must_use] + pub fn shards(&self) -> usize { + self.inner.shards.len() + } + + /// Per-shard live entry counts (including expired-but-not-yet-swept entries). + #[must_use] + pub fn shard_sizes(&self) -> Vec { + self.inner + .shards + .iter() + .map(|s| s.lock.read().cache_size()) + .collect() + } + + /// Total number of entries across all shards (including not-yet-swept expired entries). + #[must_use] + pub fn len(&self) -> usize { + self.inner + .shards + .iter() + .map(|s| s.lock.read().cache_size()) + .sum() + } + + /// `true` if no entries are present. + #[must_use] + pub fn is_empty(&self) -> bool { + self.inner + .shards + .iter() + .all(|s| s.lock.read().cache_size() == 0) + } + + /// Remove all entries from every shard. Does **not** fire `on_evict`. + /// Use [`cache_clear_with_on_evict`](Self::cache_clear_with_on_evict) to opt into callback firing. + pub fn clear(&self) { + for shard in self.inner.shards.iter() { + shard.lock.write().cache_clear(); + } + } + + /// Remove all entries from every shard, firing `on_evict` for each removed entry when a + /// callback is configured. + /// + /// If no `on_evict` callback is configured, this is equivalent to [`clear`](Self::clear). + /// Increments the evictions counter for each removed entry only when `on_evict` is set. + pub fn cache_clear_with_on_evict(&self) { + if self.inner.on_evict.is_none() { + return self.clear(); + } + for shard in self.inner.shards.iter() { + let removed: Vec<(K, TimedEntry)> = { + let mut guard = shard.lock.write(); + let keys: Vec = guard.iter().map(|(k, _)| k.clone()).collect(); + let mut removed = Vec::with_capacity(keys.len()); + for k in keys { + if let Some(pair) = guard.pop_raw(&k) { + removed.push(pair); + } + } + removed + }; + if !removed.is_empty() { + self.inner + .non_capacity_evictions + .fetch_add(removed.len() as u64, Ordering::Relaxed); + if let Some(on_evict) = &self.inner.on_evict { + for (k, entry) in &removed { + on_evict(k, &entry.value); + } + } + } + } + } + + /// Effective total capacity across all shards. + /// + /// When constructed with [`max_size`](ShardedLruTtlCacheBuilder::max_size), this may + /// be larger than the requested size because per-shard capacity is rounded + /// up with ceiling division. + #[must_use] + pub fn capacity(&self) -> usize { + self.inner.total_capacity + } + + /// Sweep all shards for expired entries, remove them, fire the `on_evict` callback + /// (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() { + let removed = { + let mut guard = shard.lock.write(); + let expired: Vec = guard + .iter() + .filter(|(_, e)| now.saturating_duration_since(e.instant) >= ttl) + .map(|(k, _)| k.clone()) + .collect(); + let mut removed = Vec::new(); + for k in expired { + // Use cache_remove_entry (not cache_remove) to avoid double-counting: + // the outer evict() handles on_evict and non_capacity_evictions itself. + if let Some((_k, entry)) = guard.pop_raw(&k) { + removed.push((k, entry)); + } + } + removed + }; + + total += removed.len(); + if !removed.is_empty() { + self.inner + .non_capacity_evictions + .fetch_add(removed.len() as u64, Ordering::Relaxed); + if let Some(cb) = &self.inner.on_evict { + for (k, entry) in &removed { + cb(k, &entry.value); + } + } + } + } + total + } + + // ---- Inherent `&self` TTL knobs ---- + + /// Return the current TTL. + #[must_use] + pub fn ttl(&self) -> Option { + self.ttl_duration() + } + + /// Set the TTL used when checking existing and newly inserted entries, returning the previous value. + /// + /// 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. + 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)) + } + } + + /// 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)) + } + } + + /// Set whether cache hits refresh the TTL of the accessed entry, + /// returning the previous value. + pub fn set_refresh_on_hit(&self, refresh: bool) -> bool { + self.inner.refresh.swap(refresh, Ordering::Relaxed) + } + + /// Return whether cache hits refresh the TTL. + #[must_use] + pub fn refresh_on_hit(&self) -> bool { + self.inner.refresh.load(Ordering::Relaxed) + } +} + +impl ShardedLruTtlCacheBase +where + K: Hash + Eq + Clone, + V: Clone, + H: ShardHasher, +{ + /// Retrieve a value from the cache. + #[inline] + pub fn cache_get(&self, k: &K) -> Result, std::convert::Infallible> { + >::cache_get(self, k) + } + + /// Set a value in the cache, returning the previous value at that key if any. + /// + /// The returned value may have already been past its TTL at the time it was replaced. + /// A `Some` return does not indicate the previous entry was still live. + #[inline] + pub fn cache_set(&self, k: K, v: V) -> Result, std::convert::Infallible> { + >::cache_set(self, k, v) + } + + /// Remove a value from the cache. + #[inline] + pub fn cache_remove(&self, k: &K) -> Result, std::convert::Infallible> { + >::cache_remove(self, k) + } + + /// Remove an entry from the cache, returning the stored key and value. + #[inline] + pub fn cache_remove_entry(&self, k: &K) -> Result, std::convert::Infallible> { + >::cache_remove_entry(self, k) + } + + /// Delete a value from the cache. + #[inline] + pub fn cache_delete(&self, k: &K) -> Result { + >::cache_delete(self, k) + } +} + +impl CacheTtl for ShardedLruTtlCacheBase +where + K: Hash + Eq + Clone, + H: ShardHasher, +{ + fn ttl(&self) -> Option { + self.ttl_duration() + } + + fn set_ttl(&mut self, ttl: Duration) -> Option { + ShardedLruTtlCacheBase::set_ttl(self, ttl) + } + + fn unset_ttl(&mut self) -> Option { + ShardedLruTtlCacheBase::unset_ttl(self) + } +} + +impl CacheEvict for ShardedLruTtlCacheBase +where + K: Hash + Eq + Clone, + H: ShardHasher, +{ + fn evict(&mut self) -> usize { + ShardedLruTtlCacheBase::evict(self) + } +} + +impl ConcurrentCached for ShardedLruTtlCacheBase +where + K: Hash + Eq + Clone, + V: Clone, + H: ShardHasher, +{ + type Error = std::convert::Infallible; + + 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(); + + // Peek first (no LRU promotion) to check expiry before committing recency. + // This avoids promoting entries that will be immediately evicted as expired. + let expired = match guard.cache_peek(k) { + None => { + shard.misses.fetch_add(1, Ordering::Relaxed); + return Ok(None); + } + Some(entry) => match &ttl { + None => false, + Some(t) => entry.instant.elapsed() >= *t, + }, + }; + + if expired { + // Use pop_raw (bypasses on_evict, unlike cache_remove_entry); we fire + // on_evict manually below after releasing the shard lock. + let removed = guard.pop_raw(k); + drop(guard); + if let Some((ref ek, ref entry)) = removed { + if let Some(cb) = &self.inner.on_evict { + cb(ek, &entry.value); + } + self.inner + .non_capacity_evictions + .fetch_add(1, Ordering::Relaxed); + } + shard.misses.fetch_add(1, Ordering::Relaxed); + return Ok(None); + } + + // Live hit — update LRU recency and extract value. + // Use a single mutable access when refresh is enabled to avoid double + // 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(); + e.value.clone() + }) + } else { + guard.cache_get(k).map(|e| e.value.clone()) + }; + shard.hits.fetch_add(1, Ordering::Relaxed); + Ok(value) + } + + fn cache_set(&self, k: K, v: V) -> Result, Self::Error> { + let shard = self.shard_of(&k); + let new_entry = TimedEntry { + instant: Instant::now(), + value: v, + }; + let old = shard.lock.write().cache_set(k, new_entry); + Ok(old.map(|e| e.value)) + } + + fn cache_remove(&self, k: &K) -> Result, Self::Error> { + let shard = self.shard_of(k); + let removed = shard.lock.write().pop_raw(k); + if let Some((key, entry)) = removed { + self.inner + .non_capacity_evictions + .fetch_add(1, Ordering::Relaxed); + 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 { + Ok(None) + } else { + Ok(Some(entry.value)) + } + } else { + Ok(None) + } + } + + fn cache_remove_entry(&self, k: &K) -> Result, Self::Error> { + let shard = self.shard_of(k); + let removed = shard.lock.write().pop_raw(k); + if let Some((ref stored_k, ref entry)) = removed { + self.inner + .non_capacity_evictions + .fetch_add(1, Ordering::Relaxed); + if let Some(on_evict) = &self.inner.on_evict { + on_evict(stored_k, &entry.value); + } + } + Ok(removed.map(|(k, entry)| (k, entry.value))) + } + + fn set_refresh_on_hit(&mut self, refresh: bool) -> bool { + self.inner.refresh.swap(refresh, Ordering::Relaxed) + } + + fn ttl(&self) -> Option { + self.ttl_duration() + } + + fn set_ttl(&mut self, ttl: Duration) -> Option { + ShardedLruTtlCacheBase::set_ttl(self, ttl) + } + + fn unset_ttl(&mut self) -> Option { + ShardedLruTtlCacheBase::unset_ttl(self) + } +} + +#[cfg(feature = "async_core")] +impl ConcurrentCachedAsync for ShardedLruTtlCacheBase +where + K: Hash + Eq + Clone + Send + Sync, + V: Clone + Send + Sync, + H: ShardHasher, +{ + type Error = std::convert::Infallible; + + async fn cache_get(&self, k: &K) -> Result, Self::Error> { + ConcurrentCached::cache_get(self, k) + } + + async fn cache_set(&self, k: K, v: V) -> Result, Self::Error> { + ConcurrentCached::cache_set(self, k, v) + } + + async fn cache_remove(&self, k: &K) -> Result, Self::Error> { + ConcurrentCached::cache_remove(self, k) + } + + async fn cache_remove_entry(&self, k: &K) -> Result, Self::Error> { + ConcurrentCached::cache_remove_entry(self, k) + } + + fn set_refresh_on_hit(&mut self, b: bool) -> bool { + >::set_refresh_on_hit(self, b) + } + + fn ttl(&self) -> Option { + self.ttl_duration() + } + + fn set_ttl(&mut self, ttl: Duration) -> Option { + ShardedLruTtlCacheBase::set_ttl(self, ttl) + } + + fn unset_ttl(&mut self) -> Option { + ShardedLruTtlCacheBase::unset_ttl(self) + } +} + +/// Builder for [`ShardedLruTtlCacheBase`]. +/// +/// The fourth type parameter `E` is a **typestate** marker: it starts as [`NoEvict`] and +/// transitions to [`HasEvict`] after `.on_evict(…)` is called. This encodes at compile time +/// whether an eviction callback has been registered, allowing the two `build()` / `copy_from()` +/// overloads to impose `K: 'static + V: 'static` bounds only when `on_evict` is set. You will +/// see this parameter in IDE completions and compiler errors once you call `.on_evict(…)`; +/// it is otherwise invisible. +pub struct ShardedLruTtlCacheBuilder { + shards: Option, + max_size: Option, + per_shard_max_size: Option, + ttl: Option, + refresh: bool, + hasher: Option, + on_evict: Option>, + _evict: PhantomData, +} + +impl Default for ShardedLruTtlCacheBuilder { + fn default() -> Self { + Self { + shards: None, + max_size: None, + per_shard_max_size: None, + ttl: None, + refresh: false, + hasher: Some(DefaultShardHasher::default()), + on_evict: None, + _evict: PhantomData, + } + } +} + +impl ShardedLruTtlCacheBuilder { + /// Set the requested total capacity (divided across shards via `div_ceil`). + /// + /// Eviction is enforced independently per shard. Each shard gets + /// `ceil(size / shards)` entries, with a minimum of 16 per shard when + /// `shards > 1` (see the **Capacity Fragmentation Warning** on + /// [`with_max_size_and_ttl`](ShardedLruTtlCache::with_max_size_and_ttl)). + /// Use [`per_shard_max_size`](Self::per_shard_max_size) for an exact per-shard cap. + /// Mutually exclusive with [`per_shard_max_size`](Self::per_shard_max_size). + #[doc(alias = "size")] + #[doc(alias = "capacity")] + #[must_use] + pub fn max_size(mut self, max_size: usize) -> Self { + self.max_size = Some(max_size); + self + } + + /// Set per-shard capacity directly. Advanced — bypasses the automatic + /// division. Mutually exclusive with [`max_size`](Self::max_size). + #[must_use] + pub fn per_shard_max_size(mut self, per_shard_max_size: usize) -> Self { + self.per_shard_max_size = Some(per_shard_max_size); + self + } + + /// Set the TTL for cache entries. Required. + #[must_use] + pub fn ttl(mut self, ttl: Duration) -> Self { + self.ttl = Some(ttl); + self + } + + /// Set the number of shards (rounded up to the next power of two). + #[must_use] + pub fn shards(mut self, shards: usize) -> Self { + self.shards = Some(shards); + self + } + + /// Set whether cache hits refresh the TTL. + #[must_use] + pub fn refresh_on_hit(mut self, refresh: bool) -> Self { + self.refresh = refresh; + self + } + + /// Alias for [`refresh_on_hit`](Self::refresh_on_hit). + #[must_use] + pub fn refresh(self, refresh: bool) -> Self { + self.refresh_on_hit(refresh) + } + + /// Set a custom shard-selection hasher, changing the type parameter. + #[must_use] + pub fn hasher>(self, hasher: H2) -> ShardedLruTtlCacheBuilder { + ShardedLruTtlCacheBuilder { + shards: self.shards, + max_size: self.max_size, + per_shard_max_size: self.per_shard_max_size, + ttl: self.ttl, + refresh: self.refresh, + hasher: Some(hasher), + on_evict: self.on_evict, + _evict: PhantomData, + } + } + + fn resolve_per_shard_cap(&self, n_shards: usize) -> Result { + match (self.max_size, self.per_shard_max_size) { + (Some(_), Some(_)) => Err(BuildError::InvalidValue { + field: "max_size / per_shard_max_size", + reason: "`max_size` and `per_shard_max_size` are mutually exclusive", + }), + (None, None) => Err(BuildError::MissingRequired("max_size")), + (Some(total), None) => { + if total == 0 { + return Err(BuildError::InvalidValue { + field: "max_size", + reason: "must be greater than zero", + }); + } + let mut cap = total.div_ceil(n_shards); + if n_shards > 1 { + // Enforce a minimum capacity of 16 per shard to avoid capacity fragmentation/eviction flakes + cap = std::cmp::max(cap, 16); + } + Ok(cap) + } + (None, Some(per)) => { + if per == 0 { + return Err(BuildError::InvalidValue { + field: "per_shard_max_size", + reason: "must be greater than zero", + }); + } + Ok(per) + } + } + } + + fn total_capacity(&self, n_shards: usize, per_shard_cap: usize) -> Result { + // Name the attribute the user actually set so the diagnostic points at the + // right knob (`per_shard_max_size` multiplies by shard count; `max_size` does not). + let field = if self.per_shard_max_size.is_some() { + "per_shard_max_size" + } else { + "max_size" + }; + n_shards + .checked_mul(per_shard_cap) + .ok_or(BuildError::InvalidValue { + field, + reason: "effective sharded capacity overflows usize", + }) + } + + fn validated_parts(&self) -> Result<(Duration, usize, usize, usize), BuildError> { + let ttl = self.ttl.ok_or(BuildError::MissingRequired("ttl"))?; + crate::stores::validate_ttl(ttl)?; + let n = checked_shard_count(self.shards)?; + let mask = n - 1; + let per_shard_cap = self.resolve_per_shard_cap(n)?; + let total_cap = self.total_capacity(n, per_shard_cap)?; + Ok((ttl, mask, per_shard_cap, total_cap)) + } +} + +impl ShardedLruTtlCacheBuilder { + /// Set a callback invoked when an entry is evicted by LRU capacity pressure, + /// TTL-expiry sweeps via [`evict`](ShardedLruTtlCacheBase::evict), explicit + /// [`cache_remove`](ConcurrentCached::cache_remove), or + /// [`cache_remove_entry`](ConcurrentCached::cache_remove_entry). + /// Does **not** fire on [`clear`](ShardedLruTtlCacheBase::clear); + /// use [`cache_clear_with_on_evict`](ShardedLruTtlCacheBase::cache_clear_with_on_evict) to opt in. + /// + /// Capacity-eviction callbacks run while the affected shard's write lock is held. Do not call + /// methods on the same sharded cache from the callback; doing so can deadlock if the callback + /// re-enters the locked shard. TTL expiry sweeps via + /// [`evict`](ShardedLruTtlCacheBase::evict) and explicit removes via + /// [`cache_remove`](ConcurrentCached::cache_remove) / + /// [`cache_remove_entry`](ConcurrentCached::cache_remove_entry) fire `on_evict` after + /// releasing the shard lock and do not have this restriction. + /// + /// # Lifetime Bounds + /// + /// Setting this callback introduces `'static` bounds on `K` and `V` due to the need + /// to map the callback across the internal store layers. If your keys/values have lifetimes, + /// do not set an `on_evict` callback, or ensure they are `'static`. + #[must_use] + pub fn on_evict( + self, + on_evict: impl Fn(&K, &V) + Send + Sync + 'static, + ) -> ShardedLruTtlCacheBuilder { + ShardedLruTtlCacheBuilder { + shards: self.shards, + max_size: self.max_size, + per_shard_max_size: self.per_shard_max_size, + ttl: self.ttl, + refresh: self.refresh, + hasher: self.hasher, + on_evict: Some(Arc::new(on_evict)), + _evict: PhantomData, + } + } + + /// Build the cache, returning an error if required fields are missing or invalid. + /// + /// # Errors + /// + /// Returns [`BuildError`] if `size` (or `per_shard_max_size`) or `ttl` was not set, is `0`, + /// or if both `max_size` and `per_shard_max_size` are set simultaneously. + pub fn try_build(self) -> Result, BuildError> + where + K: Hash + Eq + Clone, + H: ShardHasher, + { + let (ttl, mask, per_shard_cap, total_cap) = self.validated_parts()?; + let n = mask + 1; + + let shards = (0..n) + .map(|_| { + let mut lru: LruCache> = + LruCache::try_with_max_size(per_shard_cap)?; + lru.disable_hit_miss_tracking(); + Ok(CachePadded(Shard::new(lru))) + }) + .collect::, BuildError>>()? + .into_boxed_slice(); + + Ok(ShardedLruTtlCacheBase { + inner: Arc::new(LruTtlInner { + shards, + shard_mask: mask, + hasher: self + .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), + refresh: AtomicBool::new(self.refresh), + non_capacity_evictions: AtomicU64::new(0), + total_capacity: total_cap, + }), + }) + } + + /// Build the cache. + /// + /// # Panics + /// + /// Panics if `size` (or `per_shard_max_size`) or `ttl` was not set or is `0`. + #[must_use] + pub fn build(self) -> ShardedLruTtlCacheBase + where + K: Hash + Eq + Clone, + H: ShardHasher, + { + self.try_build() + .unwrap_or_else(|e| panic!("ShardedLruTtlCache build failed: {e}")) + } + + /// Build the new cache and copy every non-expired entry from `existing` into it, + /// preserving per-shard LRU ordering and original `TimedEntry` timestamps. + /// Global recency rank is not guaranteed across shards after resharding. + /// + /// The target cache uses this builder's TTL setting when checking copied entries. + /// For the same wall-clock expiry schedule, build the target with the same TTL as + /// `existing`; a shorter or longer target TTL can make copied entries expire earlier + /// or later than they would have in the source cache. + /// + /// Acquires each shard's read lock on `existing` one at a time — `existing` + /// keeps serving concurrent ops throughout. Entries that cannot fit in the + /// new per-shard capacity are evicted (LRU-first), firing `on_evict` on the + /// NEW cache's callback if set. + /// + /// **Note**: `on_evict` callbacks on `existing` do not fire — entries are read + /// (not removed) from the source cache. + /// + /// # Panics + /// + /// Panics if `size` (or `per_shard_max_size`) or `ttl` was not set or is `0`. + #[must_use] + pub fn copy_from>( + self, + existing: &ShardedLruTtlCacheBase, + ) -> ShardedLruTtlCacheBase + where + K: Clone + Hash + Eq, + V: Clone, + H: ShardHasher, + { + copy_from_lru_ttl( + self.try_build() + .unwrap_or_else(|e| panic!("ShardedLruTtlCache build failed: {e}")), + existing, + ) + } +} + +impl ShardedLruTtlCacheBuilder { + /// Build the cache, returning an error if required fields are missing or invalid. + /// + /// # Errors + /// + /// Returns [`BuildError`] if `size` (or `per_shard_max_size`) or `ttl` was not set, is `0`, + /// or if both `max_size` and `per_shard_max_size` are set simultaneously. + pub fn try_build(self) -> Result, BuildError> + where + K: Hash + Eq + Clone + 'static, + V: 'static, + H: ShardHasher, + { + let (ttl, mask, per_shard_cap, total_cap) = self.validated_parts()?; + let n = mask + 1; + + #[allow(clippy::type_complexity)] + let lru_on_evict: Option) + Send + Sync>> = + self.on_evict.as_ref().map(|cb| { + let cb = Arc::clone(cb); + let f: Arc) + Send + Sync> = + Arc::new(move |k: &K, entry: &TimedEntry| cb(k, &entry.value)); + f + }); + + let shards = (0..n) + .map(|_| { + let mut lru: LruCache> = + LruCache::try_with_max_size(per_shard_cap)?; + lru.on_evict = lru_on_evict.clone(); + lru.disable_hit_miss_tracking(); + Ok(CachePadded(Shard::new(lru))) + }) + .collect::, BuildError>>()? + .into_boxed_slice(); + + Ok(ShardedLruTtlCacheBase { + inner: Arc::new(LruTtlInner { + shards, + shard_mask: mask, + hasher: self + .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), + refresh: AtomicBool::new(self.refresh), + non_capacity_evictions: AtomicU64::new(0), + total_capacity: total_cap, + }), + }) + } + + /// Build the cache. + /// + /// # Panics + /// + /// Panics if `size` (or `per_shard_max_size`) or `ttl` was not set or is `0`. + #[must_use] + pub fn build(self) -> ShardedLruTtlCacheBase + where + K: Hash + Eq + Clone + 'static, + V: 'static, + H: ShardHasher, + { + self.try_build() + .unwrap_or_else(|e| panic!("ShardedLruTtlCache build failed: {e}")) + } + + /// Build the new cache and copy every non-expired entry from `existing` into it, + /// preserving per-shard LRU ordering and original `TimedEntry` timestamps. + /// Global recency rank is not guaranteed across shards after resharding. + /// + /// The target cache uses this builder's TTL setting when checking copied entries. + /// For the same wall-clock expiry schedule, build the target with the same TTL as + /// `existing`; a shorter or longer target TTL can make copied entries expire earlier + /// or later than they would have in the source cache. + /// + /// Acquires each shard's read lock on `existing` one at a time — `existing` + /// keeps serving concurrent ops throughout. Entries that cannot fit in the + /// new per-shard capacity are evicted (LRU-first), firing `on_evict` on the + /// NEW cache's callback if set. + /// + /// **Note**: `on_evict` callbacks on `existing` do not fire — entries are read + /// (not removed) from the source cache. + /// + /// # Panics + /// + /// Panics if `size` (or `per_shard_max_size`) or `ttl` was not set or is `0`. + #[must_use] + pub fn copy_from>( + self, + existing: &ShardedLruTtlCacheBase, + ) -> ShardedLruTtlCacheBase + where + K: Clone + Hash + Eq + 'static, + V: Clone + 'static, + H: ShardHasher, + { + copy_from_lru_ttl( + self.try_build() + .unwrap_or_else(|e| panic!("ShardedLruTtlCache build failed: {e}")), + existing, + ) + } +} + +fn copy_from_lru_ttl( + new_cache: ShardedLruTtlCacheBase, + existing: &ShardedLruTtlCacheBase, +) -> ShardedLruTtlCacheBase +where + K: Clone + Hash + Eq, + V: Clone, + H: ShardHasher, + H2: ShardHasher, +{ + let existing_ttl = existing.ttl_duration(); + + 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 { + if entry.instant.elapsed() >= ttl { + continue; + } + } + let new_shard = new_cache.shard_of(&k); + new_shard.lock.write().cache_set(k, entry); + } + } + new_cache +} + +impl ConcurrentCloneCached for ShardedLruTtlCacheBase +where + K: Hash + Eq + Clone, + V: Clone, + H: ShardHasher, +{ + /// Returns `(Some(v), false)` for a live entry (hit, LRU promoted), `(Some(v), true)` for an + /// expired entry (miss, **no removal**, no LRU promotion, no eviction counter), or + /// `(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(); + // Single peek captures both expiry status and value; the expired path + // can then return without a second lookup. + let (expired, peeked) = match guard.cache_peek(k) { + None => { + drop(guard); + shard.misses.fetch_add(1, Ordering::Relaxed); + return (None, false); + } + Some(entry) => { + let expired = match &ttl { + None => false, + Some(t) => entry.instant.elapsed() >= *t, + }; + (expired, entry.value.clone()) + } + }; + if expired { + // Return stale value without removing the entry, promoting LRU recency, + // or touching eviction counters. + drop(guard); + shard.misses.fetch_add(1, Ordering::Relaxed); + (Some(peeked), true) + } else { + // Live hit — promote LRU recency and optionally refresh the TTL instant. + let value = if refresh { + guard.cache_get_mut(k).map(|e| { + e.instant = Instant::now(); + e.value.clone() + }) + } else { + guard.cache_get(k).map(|e| e.value.clone()) + }; + drop(guard); + shard.hits.fetch_add(1, Ordering::Relaxed); + (value, false) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ConcurrentCached as SyncConcurrentCached; + use crate::ConcurrentCloneCached; + + #[test] + fn basic_get_set_remove() { + let c = ShardedLruTtlCache::::with_max_size_and_ttl(64, Duration::from_secs(60)); + assert_eq!( + SyncConcurrentCached::cache_get(&c, &1).expect("cache_get must succeed"), + None + ); + assert_eq!( + SyncConcurrentCached::cache_set(&c, 1, 100).expect("insert must succeed"), + None + ); + assert_eq!( + SyncConcurrentCached::cache_get(&c, &1).expect("key was just inserted"), + Some(100) + ); + assert_eq!( + SyncConcurrentCached::cache_remove(&c, &1).expect("key must be present"), + Some(100) + ); + assert_eq!( + SyncConcurrentCached::cache_get(&c, &1).expect("cache_get must succeed"), + None + ); + } + + #[test] + fn cache_remove_fires_on_evict_and_increments_metrics() { + use std::sync::atomic::{AtomicUsize, Ordering}; + + let count = Arc::new(AtomicUsize::new(0)); + let count2 = count.clone(); + let c = ShardedLruTtlCacheBase::::builder() + .max_size(64) + .ttl(Duration::from_secs(60)) + .shards(1) + .on_evict(move |_, _| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + + SyncConcurrentCached::cache_set(&c, 1, 10).expect("insert must succeed"); + let before = c.metrics().evictions.expect("eviction-tracking stores report an evictions count"); + assert_eq!( + SyncConcurrentCached::cache_remove(&c, &1).expect("key must be present"), + Some(10) + ); + assert_eq!( + SyncConcurrentCached::cache_remove(&c, &999).expect("cache_remove must succeed"), + None + ); + let after = c.metrics().evictions.expect("eviction-tracking stores report an evictions count"); + + assert_eq!(count.load(Ordering::Relaxed), 1); + assert_eq!(after - before, 1); + } + + #[test] + fn clone_shares_state() { + let c1 = ShardedLruTtlCache::::with_max_size_and_ttl(64, Duration::from_secs(60)); + let c2 = c1.clone(); + SyncConcurrentCached::cache_set(&c1, 1, 10).expect("insert must succeed"); + assert_eq!( + SyncConcurrentCached::cache_get(&c2, &1).expect("key was just inserted"), + Some(10) + ); + } + + #[test] + fn ttl_expiry() { + let c = + ShardedLruTtlCache::::with_max_size_and_ttl(64, Duration::from_millis(50)); + SyncConcurrentCached::cache_set(&c, 1, 100).expect("insert must succeed"); + assert_eq!( + SyncConcurrentCached::cache_get(&c, &1).expect("key was just inserted"), + Some(100) + ); + std::thread::sleep(std::time::Duration::from_millis(100)); + assert_eq!( + SyncConcurrentCached::cache_get(&c, &1).expect("cache_get must succeed"), + None + ); + } + + #[test] + fn lru_eviction_fires() { + use std::sync::atomic::{AtomicUsize, Ordering as AO}; + let count = std::sync::Arc::new(AtomicUsize::new(0)); + let count2 = count.clone(); + let c = ShardedLruTtlCacheBase::::builder() + .max_size(8) + .shards(1) + .ttl(Duration::from_secs(60)) + .on_evict(move |_, _| { + count2.fetch_add(1, AO::Relaxed); + }) + .build(); + for i in 0..16u32 { + SyncConcurrentCached::cache_set(&c, i, i).expect("insert must succeed"); + } + assert!( + count.load(AO::Relaxed) > 0, + "LRU eviction should have fired" + ); + } + + #[test] + fn per_shard_max_size_and_size_exclusive() { + let err = ShardedLruTtlCacheBase::::builder() + .max_size(100) + .per_shard_max_size(10) + .ttl(Duration::from_secs(60)) + .try_build(); + assert!(err.is_err()); + } + + #[test] + fn try_build_rejects_overflowing_shards_and_capacity() { + let err = ShardedLruTtlCacheBase::::builder() + .max_size(1) + .ttl(Duration::from_secs(60)) + .shards(usize::MAX) + .try_build(); + assert!(matches!( + err, + Err(BuildError::InvalidValue { + field: "shards", + .. + }) + )); + + let err = ShardedLruTtlCacheBase::::builder() + .per_shard_max_size(usize::MAX) + .ttl(Duration::from_secs(60)) + .shards(2) + .try_build(); + assert!(matches!( + err, + Err(BuildError::InvalidValue { + field: "per_shard_max_size", + .. + }) + )); + } + + #[test] + fn builder_without_on_evict_does_not_require_static_keys_or_values() { + let key = String::from("key"); + let value = String::from("value"); + let cache: ShardedLruTtlCacheBase<&str, &str> = ShardedLruTtlCache::builder() + .max_size(8) + .ttl(Duration::from_secs(60)) + .try_build() + .expect("valid builder config"); + + SyncConcurrentCached::cache_set(&cache, key.as_str(), value.as_str()) + .expect("insert must succeed"); + assert_eq!( + SyncConcurrentCached::cache_get(&cache, &key.as_str()).expect("key was just inserted"), + Some(value.as_str()) + ); + } + + #[test] + fn set_ttl_inherent() { + let c = ShardedLruTtlCache::::with_max_size_and_ttl(64, Duration::from_secs(60)); + let prev = c.set_ttl(Duration::from_secs(30)); + assert_eq!(prev, Some(Duration::from_secs(60))); + assert_eq!(c.ttl(), Some(Duration::from_secs(30))); + } + + #[test] + fn copy_from_skips_expired() { + let old = + ShardedLruTtlCache::::with_max_size_and_ttl(64, Duration::from_millis(50)); + for i in 0..10u32 { + SyncConcurrentCached::cache_set(&old, i, i).expect("insert must succeed"); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + let new_cache = ShardedLruTtlCacheBase::::builder() + .max_size(64) + .ttl(Duration::from_secs(60)) + .copy_from(&old); + assert_eq!(new_cache.len(), 0); + } + + #[test] + fn copy_from_preserves_live_entries() { + // Use shards(1) to avoid per-shard capacity eviction during insertion. + let old = ShardedLruTtlCacheBase::::builder() + .max_size(1024) + .shards(1) + .ttl(Duration::from_secs(60)) + .build(); + for i in 0..20u32 { + SyncConcurrentCached::cache_set(&old, i, i * 10).expect("insert must succeed"); + } + let new_cache = ShardedLruTtlCacheBase::::builder() + .max_size(1024) + .shards(4) + .ttl(Duration::from_secs(60)) + .copy_from(&old); + for i in 0..20u32 { + assert_eq!( + SyncConcurrentCached::cache_get(&new_cache, &i).expect("key was just inserted"), + Some(i * 10) + ); + } + } + + #[test] + fn copy_from_respects_capacity() { + let old = ShardedLruTtlCacheBase::::builder() + .max_size(64) + .shards(1) + .ttl(Duration::from_secs(60)) + .build(); + for i in 0..32u32 { + SyncConcurrentCached::cache_set(&old, i, i).expect("insert must succeed"); + } + let new_cache = ShardedLruTtlCacheBase::::builder() + .max_size(16) + .shards(1) + .ttl(Duration::from_secs(60)) + .copy_from(&old); + assert!(new_cache.len() <= 16); + } + + #[test] + fn convenience_constructor_panics_include_build_error_context() { + let panic = std::panic::catch_unwind(|| { + ShardedLruTtlCache::::with_max_size_and_ttl(0, Duration::from_secs(60)) + }) + .expect_err("zero size should panic"); + let message = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .unwrap_or(""); + assert!(message.contains("ShardedLruTtlCache build failed")); + assert!(message.contains("max_size")); + + let panic = std::panic::catch_unwind(|| { + ShardedLruTtlCache::::with_max_size_and_ttl_and_shards( + 1, + Duration::from_secs(60), + 0, + ) + }) + .expect_err("zero shards should panic"); + let message = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .unwrap_or(""); + assert!(message.contains("ShardedLruTtlCache build failed")); + assert!(message.contains("shards")); + + let panic = std::panic::catch_unwind(|| { + ShardedLruTtlCache::::with_max_size_and_ttl(1, Duration::from_nanos(0)) + }) + .expect_err("zero ttl should panic"); + let message = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .unwrap_or(""); + assert!(message.contains("ShardedLruTtlCache build failed")); + assert!(message.contains("ttl")); + } + + #[test] + fn send_sync() { + fn assert_send_sync() {} + assert_send_sync::>(); + } + + #[test] + fn try_build_rejects_zero_ttl() { + let err = ShardedLruTtlCacheBase::::builder() + .max_size(8) + .ttl(Duration::from_nanos(0)) + .try_build(); + assert!( + matches!(err, Err(crate::stores::BuildError::InvalidTtl { .. })), + "expected InvalidTtl, got {err:?}", + ); + } + + #[test] + fn cache_clear_with_on_evict_fires_for_all_entries() { + use std::sync::atomic::{AtomicU64, Ordering}; + let count = Arc::new(AtomicU64::new(0)); + let count2 = count.clone(); + let c = ShardedLruTtlCacheBase::::builder() + .shards(1) + .max_size(64) + .ttl(Duration::from_secs(3600)) + .on_evict(move |_, _| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + for i in 0..20u32 { + SyncConcurrentCached::cache_set(&c, i, i).expect("insert must succeed"); + } + let before = c.metrics().evictions.expect("eviction-tracking stores report an evictions count"); + c.cache_clear_with_on_evict(); + assert_eq!( + c.len(), + 0, + "cache must be empty after cache_clear_with_on_evict" + ); + assert_eq!( + count.load(Ordering::Relaxed), + 20, + "on_evict must fire for every entry" + ); + assert_eq!( + c.metrics().evictions.expect("eviction-tracking stores report an evictions count") - before, + 20, + "evictions counter must increment for each entry" + ); + } + + #[test] + fn clear_does_not_fire_on_evict() { + use std::sync::atomic::{AtomicU64, Ordering}; + let count = Arc::new(AtomicU64::new(0)); + let count2 = count.clone(); + let c = ShardedLruTtlCacheBase::::builder() + .max_size(64) + .ttl(Duration::from_secs(3600)) + .on_evict(move |_, _| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + for i in 0..10u32 { + SyncConcurrentCached::cache_set(&c, i, i).expect("insert must succeed"); + } + c.clear(); + assert_eq!( + count.load(Ordering::Relaxed), + 0, + "clear must not fire on_evict" + ); + } + + #[test] + fn cache_remove_entry_returns_some_for_live_entry() { + let c = ShardedLruTtlCache::::with_max_size_and_ttl(64, Duration::from_secs(60)); + SyncConcurrentCached::cache_set(&c, 1u32, 100u32).expect("insert must succeed"); + assert_eq!( + c.cache_remove_entry(&999u32) + .expect("cache_remove_entry must succeed"), + None + ); + assert_eq!( + c.cache_remove_entry(&1u32).expect("key must be present"), + Some((1u32, 100u32)) + ); + assert_eq!( + SyncConcurrentCached::cache_get(&c, &1u32).expect("cache_get must succeed"), + None + ); + } + + #[test] + fn cache_remove_entry_returns_some_for_expired_entry() { + let c = + ShardedLruTtlCache::::with_max_size_and_ttl(64, Duration::from_millis(50)); + SyncConcurrentCached::cache_set(&c, 1u32, 100u32).expect("insert must succeed"); + SyncConcurrentCached::cache_set(&c, 2u32, 200u32).expect("insert must succeed"); + std::thread::sleep(std::time::Duration::from_millis(100)); + + // cache_remove returns None for expired. + assert_eq!( + SyncConcurrentCached::cache_remove(&c, &1u32).expect("cache_remove must succeed"), + None + ); + + // cache_remove_entry returns Some even for expired. + let removed = c.cache_remove_entry(&2u32).expect("key must be present"); + assert!( + removed.is_some(), + "cache_remove_entry must return Some for expired entry" + ); + assert_eq!(removed.expect("must be Some"), (2u32, 200u32)); + } + + #[test] + fn cache_delete_returns_true_for_expired_entry() { + let c = + ShardedLruTtlCache::::with_max_size_and_ttl(64, Duration::from_millis(50)); + SyncConcurrentCached::cache_set(&c, 1u32, 100u32).expect("insert must succeed"); + std::thread::sleep(std::time::Duration::from_millis(100)); + assert!( + SyncConcurrentCached::cache_delete(&c, &1u32).expect("cache_delete must succeed"), + "cache_delete must be true for expired entry" + ); + assert!(!SyncConcurrentCached::cache_delete(&c, &1u32).expect("cache_delete must succeed")); + } + + #[test] + fn cache_remove_entry_fires_on_evict_for_expired() { + use std::sync::atomic::{AtomicU64, Ordering}; + let count = Arc::new(AtomicU64::new(0)); + let count2 = count.clone(); + let c = ShardedLruTtlCacheBase::::builder() + .max_size(64) + .ttl(Duration::from_millis(50)) + .shards(1) + .on_evict(move |_, _| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + SyncConcurrentCached::cache_set(&c, 1u32, 10u32).expect("insert must succeed"); + std::thread::sleep(std::time::Duration::from_millis(100)); + + c.cache_remove_entry(&1u32).expect("key must be present"); + assert_eq!( + count.load(Ordering::Relaxed), + 1, + "on_evict fires for expired entries" + ); + + c.cache_remove_entry(&999u32) + .expect("cache_remove_entry must succeed"); + assert_eq!(count.load(Ordering::Relaxed), 1, "no fire for absent key"); + } + + #[test] + fn cache_remove_entry_increments_eviction_counter() { + let c = ShardedLruTtlCacheBase::::builder() + .max_size(64) + .ttl(Duration::from_millis(10)) + .shards(1) + .build(); + SyncConcurrentCached::cache_set(&c, 1u32, 10u32).expect("insert must succeed"); + std::thread::sleep(std::time::Duration::from_millis(100)); + let before = c.metrics().evictions.expect("evictions are always tracked"); + c.cache_remove_entry(&1u32).expect("key must be present"); // expired but present — must increment + c.cache_remove_entry(&999u32) + .expect("cache_remove_entry must succeed"); // absent — must not increment + assert_eq!( + c.metrics().evictions.expect("evictions are always tracked") - before, + 1, + "cache_remove_entry must increment evictions for present key only" + ); + } + + // --- ConcurrentCloneCached tests --- + + #[test] + fn concurrent_clone_cached_absent_is_none_false() { + let c = ShardedLruTtlCache::::with_max_size_and_ttl(64, Duration::from_secs(60)); + assert_eq!( + ConcurrentCloneCached::cache_get_with_expiry_status(&c, &1u32), + (None, false), + "absent key must return (None, false)" + ); + assert_eq!( + c.metrics().misses, + Some(1), + "absent lookup must increment misses" + ); + } + + #[test] + fn concurrent_clone_cached_live_entry_is_some_false() { + let c = ShardedLruTtlCache::::with_max_size_and_ttl(64, Duration::from_secs(60)); + SyncConcurrentCached::cache_set(&c, 1u32, 42u32).expect("insert must succeed"); + assert_eq!( + ConcurrentCloneCached::cache_get_with_expiry_status(&c, &1u32), + (Some(42), false), + "live entry must return (Some(v), false)" + ); + assert_eq!(c.metrics().hits, Some(1), "live lookup must increment hits"); + assert_eq!( + c.metrics().evictions, + Some(0), + "live lookup must not increment evictions" + ); + } + + #[test] + fn concurrent_clone_cached_expired_returns_stale_no_eviction() { + let c = ShardedLruTtlCacheBase::::builder() + .max_size(64) + .ttl(Duration::from_millis(50)) + .shards(1) + .build(); + SyncConcurrentCached::cache_set(&c, 1u32, 99u32).expect("insert must succeed"); + std::thread::sleep(std::time::Duration::from_millis(100)); + + let (val, expired) = ConcurrentCloneCached::cache_get_with_expiry_status(&c, &1u32); + assert_eq!(val, Some(99), "expired entry must return the stale value"); + assert!(expired, "expired entry must set the expired flag"); + assert_eq!( + c.metrics().misses, + Some(1), + "expired lookup must increment misses" + ); + assert_eq!( + c.metrics().evictions, + Some(0), + "expired lookup must NOT increment evictions" + ); + + // Entry must NOT have been removed — a second expiry-status call still sees it. + let (val2, expired2) = ConcurrentCloneCached::cache_get_with_expiry_status(&c, &1u32); + assert_eq!( + val2, + Some(99), + "entry must still be present after expiry-status lookup" + ); + assert!( + expired2, + "entry must still be expired on second expiry-status call" + ); + } +} diff --git a/src/stores/sharded/mod.rs b/src/stores/sharded/mod.rs new file mode 100644 index 00000000..ed7de0ee --- /dev/null +++ b/src/stores/sharded/mod.rs @@ -0,0 +1,200 @@ +use std::sync::atomic::AtomicU64; + +use crate::stores::BuildError; + +/// Cache-line size used for padding. Covers both x86_64 (64 B + Intel adjacent-line prefetch) +/// and Apple Silicon (128 B L1 line). Matches the `repr(align)` on `CachePadded`. +/// Note: `#[repr(align(…))]` only accepts integer literals, so this constant cannot be used +/// directly in the attribute — the literal `128` in `CachePadded` must match it by hand. +pub(crate) const CACHE_LINE: usize = 128; +const _: () = assert!( + CACHE_LINE == 128, + "CachePadded repr(align) literal must match CACHE_LINE" +); + +/// Aligns its payload to a cache line so adjacent elements in a slice +/// can't false-share. Same pattern as `crossbeam_utils::CachePadded`; +/// rolled here to avoid a new dependency. +#[repr(align(128))] +pub(crate) struct CachePadded(pub T); + +impl std::ops::Deref for CachePadded { + type Target = T; + fn deref(&self) -> &T { + &self.0 + } +} +impl std::ops::DerefMut for CachePadded { + fn deref_mut(&mut self) -> &mut T { + &mut self.0 + } +} + +/// Per-shard state. Plain struct — alignment is the caller's responsibility +/// (in practice always `CachePadded>`). The lock word and the +/// hit/miss counters intentionally share a cache line: they are touched by +/// the same op (a `cache_get` acquires the lock and then bumps a counter), +/// so spatial locality is a win. Counters use `Relaxed` atomics; on stores +/// that allow concurrent readers (read-lock paths), increments can race — +/// this is intentional, trading exactness for lower overhead. +pub(crate) struct Shard { + pub lock: parking_lot::RwLock, + pub hits: AtomicU64, + pub misses: AtomicU64, +} + +impl Shard { + pub fn new(store: S) -> Self { + Self { + lock: parking_lot::RwLock::new(store), + hits: AtomicU64::new(0), + misses: AtomicU64::new(0), + } + } +} + +pub(crate) fn default_shard_count() -> usize { + let count = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4) + .saturating_mul(4); + // Clamp before rounding to prevent panic on next_power_of_two overflow + count.clamp(8, 1024).next_power_of_two() +} + +pub(crate) fn checked_shard_count(shards: Option) -> Result { + if let Some(0) = shards { + return Err(BuildError::InvalidValue { + field: "shards", + reason: "shard count must be >= 1", + }); + } + shards + .unwrap_or_else(default_shard_count) + .checked_next_power_of_two() + .ok_or(BuildError::InvalidValue { + field: "shards", + reason: "rounded shard count overflows usize", + }) +} + +#[inline] +pub(crate) fn shard_index(hash: u64, mask: usize) -> usize { + (hash >> 32) as usize & mask +} + +/// Trait for types that deterministically map a key to a `u64` shard hash. +/// +/// No `K: Hash` bound on the trait itself — custom impls can partition by +/// arbitrary logic (e.g. numeric range, string prefix, etc.). +/// +/// # Shard selection +/// +/// The shard index is derived from the upper 32 bits of the returned hash: +/// `(hash >> 32) & shard_mask`. [`DefaultShardHasher`] (ahash when the `ahash` +/// feature is enabled, otherwise std `RandomState`) produces high-quality bits +/// in both halves. Custom implementations should ensure the +/// **upper** 32 bits are well-distributed across keys, not just the lower bits. +/// +/// # Example +/// +/// ```rust +/// use cached::ShardHasher; +/// +/// /// Distributes `u64` keys using Fibonacci hashing (`key * 2^64/φ`). +/// /// Ensures the upper 32 bits (used for shard selection) are well-distributed. +/// struct FibHasher; +/// impl ShardHasher for FibHasher { +/// fn shard_hash(&self, key: &u64) -> u64 { +/// key.wrapping_mul(0x9e3779b97f4a7c15) +/// } +/// } +/// ``` +/// The `'static` bound is required because the hasher is stored inside `Arc`, +/// 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 { + fn shard_hash(&self, key: &K) -> u64; +} + +/// Default shard hasher backed by `ahash::RandomState` (or `std::collections::hash_map::RandomState` +/// when the `ahash` feature is disabled). Requires `K: Hash`. +#[derive(Clone)] +pub struct DefaultShardHasher( + #[cfg(feature = "ahash")] ahash::RandomState, + #[cfg(not(feature = "ahash"))] std::collections::hash_map::RandomState, +); + +impl Default for DefaultShardHasher { + fn default() -> Self { + Self::new() + } +} + +impl DefaultShardHasher { + #[must_use] + pub fn new() -> Self { + #[cfg(feature = "ahash")] + { + Self(ahash::RandomState::new()) + } + #[cfg(not(feature = "ahash"))] + { + Self(std::collections::hash_map::RandomState::new()) + } + } +} + +impl ShardHasher for DefaultShardHasher { + fn shard_hash(&self, key: &K) -> u64 { + use std::hash::BuildHasher; + BuildHasher::hash_one(&self.0, key) + } +} + +mod expiring; +mod expiring_lru; +mod lru; +mod unbound; + +#[cfg(feature = "time_stores")] +mod lru_ttl; +#[cfg(feature = "time_stores")] +mod ttl; + +pub use expiring::{ShardedExpiringCache, ShardedExpiringCacheBase, ShardedExpiringCacheBuilder}; +pub use expiring_lru::{ + ShardedExpiringLruCache, ShardedExpiringLruCacheBase, ShardedExpiringLruCacheBuilder, +}; +pub use lru::{ShardedLruCache, ShardedLruCacheBase, ShardedLruCacheBuilder}; +pub use unbound::{ShardedCache, ShardedCacheBase, ShardedCacheBuilder}; + +#[cfg(feature = "time_stores")] +pub use ttl::{ShardedTtlCache, ShardedTtlCacheBase, ShardedTtlCacheBuilder}; + +#[cfg(feature = "time_stores")] +pub use lru_ttl::{ShardedLruTtlCache, ShardedLruTtlCacheBase, ShardedLruTtlCacheBuilder}; + +#[cfg(test)] +mod tests { + use super::*; + use std::mem::{align_of, size_of}; + + #[test] + fn cache_padded_is_aligned() { + assert_eq!(align_of::>(), CACHE_LINE); + assert_eq!(size_of::>() % CACHE_LINE, 0); + } + + #[test] + fn default_shard_hasher_works() { + let h = DefaultShardHasher::new(); + let v1 = h.shard_hash(&42u64); + let v2 = h.shard_hash(&42u64); + assert_eq!(v1, v2); + // different keys should (almost certainly) produce different hashes + let v3 = h.shard_hash(&43u64); + assert_ne!(v1, v3); + } +} diff --git a/src/stores/sharded/ttl.rs b/src/stores/sharded/ttl.rs new file mode 100644 index 00000000..dd576678 --- /dev/null +++ b/src/stores/sharded/ttl.rs @@ -0,0 +1,1222 @@ +use std::hash::Hash; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; + +#[cfg(feature = "ahash")] +use ahash::RandomState; +#[cfg(not(feature = "ahash"))] +use std::collections::hash_map::RandomState; + +use std::collections::HashMap; + +use crate::time::{Duration, Instant}; +#[cfg(feature = "async_core")] +use crate::ConcurrentCachedAsync; +use crate::{CacheMetrics, CacheTtl, ConcurrentCached, ConcurrentCloneCached}; + +use super::{ + checked_shard_count, shard_index, CachePadded, DefaultShardHasher, Shard, ShardHasher, +}; +use crate::stores::{BuildError, CacheEvict, TimedEntry}; + +type OnEvict = Arc; + +#[allow(clippy::type_complexity)] +struct TtlInner { + shards: Box<[CachePadded, RandomState>>>]>, + shard_mask: usize, + hasher: H, + on_evict: Option>, + /// TTL in nanoseconds; 0 means disabled. + ttl_nanos: AtomicU64, + refresh: AtomicBool, + evictions: AtomicU64, +} + +/// A fully-concurrent, partitioned, TTL-bounded in-memory cache. +/// +/// Wraps an `Arc` — `clone()` is an Arc-share (shared state), not a deep copy. +/// Use [`deep_clone`](ShardedTtlCacheBase::deep_clone) to get an independent copy. +/// +/// **Note**: reads return owned values cloned from under the shard lock, so `V` must +/// implement `Clone`. +/// +/// Read hits use a **shared read lock** per shard by default. When `refresh_on_hit` is enabled, +/// 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. +/// +/// This is a type alias for `ShardedTtlCacheBase`. +/// To use a custom shard hasher, construct a [`ShardedTtlCacheBase`] directly via +/// [`ShardedTtlCacheBase::builder()`]. +pub type ShardedTtlCache = ShardedTtlCacheBase; + +/// Backing type for [`ShardedTtlCache`] with a generic shard hasher `H`. +pub struct ShardedTtlCacheBase { + inner: Arc>, +} + +impl Clone for ShardedTtlCacheBase { + /// Arc-share clone — both handles point to the same underlying cache. + fn clone(&self) -> Self { + Self { + inner: Arc::clone(&self.inner), + } + } +} + +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)) + }; + f.debug_struct("ShardedTtlCache") + .field("shards", &self.inner.shards.len()) + .field("ttl", &ttl) + .finish_non_exhaustive() + } +} + +impl ShardedTtlCacheBase +where + K: Hash + Eq, + H: ShardHasher, +{ + /// Return a builder for constructing a [`ShardedTtlCacheBase`]. + /// + /// 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() -> ShardedTtlCacheBuilder { + ShardedTtlCacheBuilder::default() + } + + #[inline] + fn shard_of(&self, k: &K) -> &CachePadded, RandomState>>> { + let h = self.inner.hasher.shard_hash(k); + &self.inner.shards[shard_index(h, self.inner.shard_mask)] + } + + #[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)) + } + } + + #[inline] + fn is_expired(&self, entry: &TimedEntry) -> bool { + match self.ttl_duration() { + None => false, + Some(ttl) => entry.instant.elapsed() >= ttl, + } + } +} + +impl ShardedTtlCache +where + K: Hash + Eq, +{ + /// Create a new TTL-bounded sharded cache with the given TTL. + #[must_use] + pub fn with_ttl(ttl: Duration) -> Self { + ShardedTtlCacheBuilder::default().ttl(ttl).build() + } + + /// Create a new TTL-bounded sharded cache with the given TTL and shard count. + #[must_use] + pub fn with_ttl_and_shards(ttl: Duration, shards: usize) -> Self { + ShardedTtlCacheBuilder::default() + .ttl(ttl) + .shards(shards) + .build() + } +} + +impl + Clone> 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. + #[must_use] + pub fn deep_clone(&self) -> Self { + let n = self.inner.shards.len(); + let shards = (0..n) + .map(|i| { + let guard = self.inner.shards[i].lock.read(); + let store_copy = guard.clone(); + let hits = self.inner.shards[i].hits.load(Ordering::Relaxed); + let misses = self.inner.shards[i].misses.load(Ordering::Relaxed); + drop(guard); + let shard = Shard { + lock: parking_lot::RwLock::new(store_copy), + hits: AtomicU64::new(hits), + misses: AtomicU64::new(misses), + }; + CachePadded(shard) + }) + .collect::>() + .into_boxed_slice(); + Self { + inner: Arc::new(TtlInner { + shards, + shard_mask: self.inner.shard_mask, + hasher: self.inner.hasher.clone(), + on_evict: self.inner.on_evict.clone(), + ttl_nanos: AtomicU64::new(self.inner.ttl_nanos.load(Ordering::Relaxed)), + refresh: AtomicBool::new(self.inner.refresh.load(Ordering::Relaxed)), + evictions: AtomicU64::new(self.inner.evictions.load(Ordering::Relaxed)), + }), + } + } +} + +impl> ShardedTtlCacheBase +where + K: Hash + Eq, +{ + /// Return aggregate metrics across all shards. + /// + /// Note: the `size` field includes entries that have expired but not yet been + /// swept by [`evict`](Self::evict). Call `evict()` first for an accurate live count. + #[must_use] + pub fn metrics(&self) -> CacheMetrics { + let mut hits = 0u64; + let mut misses = 0u64; + let mut size = 0usize; + for shard in self.inner.shards.iter() { + hits += shard.hits.load(Ordering::Relaxed); + misses += shard.misses.load(Ordering::Relaxed); + size += shard.lock.read().len(); + } + CacheMetrics { + hits: Some(hits), + misses: Some(misses), + evictions: Some(self.inner.evictions.load(Ordering::Relaxed)), + size, + capacity: None, + } + } + + /// Number of shards. + #[must_use] + pub fn shards(&self) -> usize { + self.inner.shards.len() + } + + /// Per-shard live entry counts (including expired-but-not-yet-swept entries). + #[must_use] + pub fn shard_sizes(&self) -> Vec { + self.inner + .shards + .iter() + .map(|s| s.lock.read().len()) + .collect() + } + + /// Total number of entries across all shards (including not-yet-swept expired entries). + #[must_use] + pub fn len(&self) -> usize { + self.inner.shards.iter().map(|s| s.lock.read().len()).sum() + } + + /// `true` if no entries are present. + #[must_use] + pub fn is_empty(&self) -> bool { + self.inner.shards.iter().all(|s| s.lock.read().is_empty()) + } + + /// Remove all entries from every shard. Does **not** fire `on_evict`. + /// Use [`cache_clear_with_on_evict`](Self::cache_clear_with_on_evict) to opt into callback firing. + pub fn clear(&self) { + for shard in self.inner.shards.iter() { + shard.lock.write().clear(); + } + } + + /// Remove all entries from every shard, firing `on_evict` for each removed entry when a + /// callback is configured. + /// + /// If no `on_evict` callback is configured, this is equivalent to [`clear`](Self::clear). + /// Increments the evictions counter for each removed entry only when `on_evict` is set. + pub fn cache_clear_with_on_evict(&self) { + if self.inner.on_evict.is_none() { + return self.clear(); + } + for shard in self.inner.shards.iter() { + let removed: Vec<(K, TimedEntry)> = shard.lock.write().drain().collect(); + if !removed.is_empty() { + self.inner + .evictions + .fetch_add(removed.len() as u64, Ordering::Relaxed); + if let Some(on_evict) = &self.inner.on_evict { + for (k, entry) in &removed { + on_evict(k, &entry.value); + } + } + } + } + } + + /// Sweep all shards for expired entries, remove them, fire the `on_evict` callback + /// (if set) for each, and return the total count of removed entries. + #[must_use] + pub fn evict(&self) -> usize + 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() { + let removed = { + let mut guard = shard.lock.write(); + let expired_keys: Vec = guard + .iter() + .filter(|(_, e)| now.saturating_duration_since(e.instant) >= ttl) + .map(|(k, _)| k.clone()) + .collect(); + let mut removed = Vec::new(); + for k in expired_keys { + if let Some(entry) = guard.remove(&k) { + removed.push((k, entry)); + } + } + removed + }; + + total += removed.len(); + if !removed.is_empty() { + self.inner + .evictions + .fetch_add(removed.len() as u64, Ordering::Relaxed); + if let Some(cb) = &self.inner.on_evict { + for (k, entry) in &removed { + cb(k, &entry.value); + } + } + } + } + total + } + + // ---- Inherent `&self` TTL knobs ---- + + /// Return the current TTL. + #[must_use] + pub fn ttl(&self) -> Option { + self.ttl_duration() + } + + /// Set the TTL used when checking existing and newly inserted entries, returning the previous value. + /// + /// 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. + 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)) + } + } + + /// 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)) + } + } + + /// Set whether cache hits refresh the TTL of the accessed entry, + /// returning the previous value. + pub fn set_refresh_on_hit(&self, refresh: bool) -> bool { + self.inner.refresh.swap(refresh, Ordering::Relaxed) + } + + /// Return whether cache hits refresh the TTL. + #[must_use] + pub fn refresh_on_hit(&self) -> bool { + self.inner.refresh.load(Ordering::Relaxed) + } +} + +impl ShardedTtlCacheBase +where + K: Hash + Eq, + V: Clone, + H: ShardHasher, +{ + /// Retrieve a value from the cache. + #[inline] + pub fn cache_get(&self, k: &K) -> Result, std::convert::Infallible> { + >::cache_get(self, k) + } + + /// Set a value in the cache, returning the previous value at that key if any. + /// + /// The returned value may have already been past its TTL at the time it was replaced. + /// A `Some` return does not indicate the previous entry was still live. + #[inline] + pub fn cache_set(&self, k: K, v: V) -> Result, std::convert::Infallible> { + >::cache_set(self, k, v) + } + + /// Remove a value from the cache. + #[inline] + pub fn cache_remove(&self, k: &K) -> Result, std::convert::Infallible> { + >::cache_remove(self, k) + } + + /// Remove an entry from the cache, returning the stored key and value. + #[inline] + pub fn cache_remove_entry(&self, k: &K) -> Result, std::convert::Infallible> { + >::cache_remove_entry(self, k) + } + + /// Delete a value from the cache. + #[inline] + pub fn cache_delete(&self, k: &K) -> Result { + >::cache_delete(self, k) + } +} + +impl CacheTtl for ShardedTtlCacheBase +where + K: Hash + Eq, + H: ShardHasher, +{ + fn ttl(&self) -> Option { + self.ttl_duration() + } + + fn set_ttl(&mut self, ttl: Duration) -> Option { + ShardedTtlCacheBase::set_ttl(self, ttl) + } + + fn unset_ttl(&mut self) -> Option { + ShardedTtlCacheBase::unset_ttl(self) + } +} + +impl CacheEvict for ShardedTtlCacheBase +where + K: Hash + Eq + Clone, + H: ShardHasher, +{ + fn evict(&mut self) -> usize { + ShardedTtlCacheBase::evict(self) + } +} + +impl ConcurrentCached for ShardedTtlCacheBase +where + K: Hash + Eq, + V: Clone, + H: ShardHasher, +{ + type Error = std::convert::Infallible; + + 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 value = Some(entry.value.clone()); + drop(guard); + shard.hits.fetch_add(1, Ordering::Relaxed); + return Ok(value); + } + Some(_) => { + let removed = guard.remove_entry(k); + drop(guard); + if let Some((stored_k, entry)) = removed { + self.inner.evictions.fetch_add(1, Ordering::Relaxed); + if let Some(cb) = &self.inner.on_evict { + cb(&stored_k, &entry.value); + } + } + shard.misses.fetch_add(1, Ordering::Relaxed); + return Ok(None); + } + None => { + drop(guard); + shard.misses.fetch_add(1, Ordering::Relaxed); + return Ok(None); + } + } + } + + // Check for expiry — try with a read lock. + let (expired, value) = { + let guard = shard.lock.read(); + match guard.get(k) { + None => { + shard.misses.fetch_add(1, Ordering::Relaxed); + return Ok(None); + } + Some(entry) => { + let expired = self.is_expired(entry); + let value = if !expired { + Some(entry.value.clone()) + } else { + None + }; + (expired, value) + } + } + }; + if expired { + // Upgrade to write lock to remove the expired entry. + let mut guard = shard.lock.write(); + // Re-check under write lock — another thread may have replaced the entry + // with a fresh value in the meantime; clone it out in the same lookup. + let fresh_value = match guard.get(k) { + Some(entry) if !self.is_expired(entry) => Some(entry.value.clone()), + _ => None, + }; + if let Some(fresh_value) = fresh_value { + drop(guard); + shard.hits.fetch_add(1, Ordering::Relaxed); + return Ok(Some(fresh_value)); + } + // Still expired (or already gone) — remove it. + let removed = guard.remove_entry(k); + drop(guard); + if let Some((stored_k, entry)) = removed { + self.inner.evictions.fetch_add(1, Ordering::Relaxed); + if let Some(cb) = &self.inner.on_evict { + cb(&stored_k, &entry.value); + } + } + shard.misses.fetch_add(1, Ordering::Relaxed); + return Ok(None); + } + shard.hits.fetch_add(1, Ordering::Relaxed); + Ok(value) + } + + fn cache_set(&self, k: K, v: V) -> Result, Self::Error> { + let shard = self.shard_of(&k); + let new_entry = TimedEntry { + instant: Instant::now(), + value: v, + }; + let old = shard.lock.write().insert(k, new_entry); + Ok(old.map(|e| e.value)) + } + + fn cache_remove(&self, k: &K) -> Result, Self::Error> { + let shard = self.shard_of(k); + let removed = shard.lock.write().remove_entry(k); + if let Some((stored_k, entry)) = removed { + self.inner.evictions.fetch_add(1, Ordering::Relaxed); + if let Some(cb) = &self.inner.on_evict { + cb(&stored_k, &entry.value); + } + if self.is_expired(&entry) { + Ok(None) + } else { + Ok(Some(entry.value)) + } + } else { + Ok(None) + } + } + + fn cache_remove_entry(&self, k: &K) -> Result, Self::Error> { + let shard = self.shard_of(k); + let removed = shard.lock.write().remove_entry(k); + if let Some((ref stored_k, ref entry)) = removed { + self.inner.evictions.fetch_add(1, Ordering::Relaxed); + if let Some(cb) = &self.inner.on_evict { + cb(stored_k, &entry.value); + } + } + Ok(removed.map(|(k, entry)| (k, entry.value))) + } + + fn set_refresh_on_hit(&mut self, refresh: bool) -> bool { + self.inner.refresh.swap(refresh, Ordering::Relaxed) + } + + fn ttl(&self) -> Option { + self.ttl_duration() + } + + fn set_ttl(&mut self, ttl: Duration) -> Option { + ShardedTtlCacheBase::set_ttl(self, ttl) + } + + fn unset_ttl(&mut self) -> Option { + ShardedTtlCacheBase::unset_ttl(self) + } +} + +#[cfg(feature = "async_core")] +impl ConcurrentCachedAsync for ShardedTtlCacheBase +where + K: Hash + Eq + Send + Sync, + V: Clone + Send + Sync, + H: ShardHasher, +{ + type Error = std::convert::Infallible; + + async fn cache_get(&self, k: &K) -> Result, Self::Error> { + ConcurrentCached::cache_get(self, k) + } + + async fn cache_set(&self, k: K, v: V) -> Result, Self::Error> { + ConcurrentCached::cache_set(self, k, v) + } + + async fn cache_remove(&self, k: &K) -> Result, Self::Error> { + ConcurrentCached::cache_remove(self, k) + } + + async fn cache_remove_entry(&self, k: &K) -> Result, Self::Error> { + ConcurrentCached::cache_remove_entry(self, k) + } + + fn set_refresh_on_hit(&mut self, b: bool) -> bool { + >::set_refresh_on_hit(self, b) + } + + fn ttl(&self) -> Option { + self.ttl_duration() + } + + fn set_ttl(&mut self, ttl: Duration) -> Option { + ShardedTtlCacheBase::set_ttl(self, ttl) + } + + fn unset_ttl(&mut self) -> Option { + ShardedTtlCacheBase::unset_ttl(self) + } +} + +/// Builder for [`ShardedTtlCacheBase`]. +/// +/// Unlike the LRU-bounded builders, `ShardedTtlCacheBuilder` has no `per_shard_max_size` method +/// because `ShardedTtlCache` is unbounded in size — entries expire by TTL, not by capacity. +pub struct ShardedTtlCacheBuilder { + shards: Option, + ttl: Option, + refresh: bool, + hasher: Option, + on_evict: Option>, + _k: std::marker::PhantomData, + _v: std::marker::PhantomData, +} + +impl Default for ShardedTtlCacheBuilder { + fn default() -> Self { + Self { + shards: None, + ttl: None, + refresh: false, + hasher: Some(DefaultShardHasher::default()), + on_evict: None, + _k: std::marker::PhantomData, + _v: std::marker::PhantomData, + } + } +} + +impl ShardedTtlCacheBuilder { + /// Set the TTL for cache entries. Required. + #[must_use] + pub fn ttl(mut self, ttl: Duration) -> Self { + self.ttl = Some(ttl); + self + } + + /// Set the number of shards (rounded up to the next power of two). + #[must_use] + pub fn shards(mut self, shards: usize) -> Self { + self.shards = Some(shards); + self + } + + /// Set whether cache hits refresh the TTL. + #[must_use] + pub fn refresh_on_hit(mut self, refresh: bool) -> Self { + self.refresh = refresh; + self + } + + /// Alias for [`refresh_on_hit`](Self::refresh_on_hit). + #[must_use] + pub fn refresh(self, refresh: bool) -> Self { + self.refresh_on_hit(refresh) + } + + /// Set a custom shard-selection hasher, changing the type parameter. + #[must_use] + pub fn hasher>(self, hasher: H2) -> ShardedTtlCacheBuilder { + ShardedTtlCacheBuilder { + shards: self.shards, + ttl: self.ttl, + refresh: self.refresh, + hasher: Some(hasher), + on_evict: self.on_evict, + _k: std::marker::PhantomData, + _v: std::marker::PhantomData, + } + } + + /// Set a callback invoked when an entry is evicted. Fires in four situations: + /// lazily during [`cache_get`](ConcurrentCached::cache_get) when a TTL-expired entry is + /// found and removed; explicitly via [`evict`](ShardedTtlCacheBase::evict); on + /// explicit [`cache_remove`](ConcurrentCached::cache_remove); and on + /// [`cache_remove_entry`](ConcurrentCached::cache_remove_entry). + /// Does **not** fire on [`clear`](ShardedTtlCacheBase::clear); + /// use [`cache_clear_with_on_evict`](ShardedTtlCacheBase::cache_clear_with_on_evict) to opt in. + /// + /// The closure must be `'static` (its captures cannot borrow from the local stack), but `K` + /// and `V` themselves are not required to be `'static`. + #[must_use] + pub fn on_evict(mut self, on_evict: impl Fn(&K, &V) + Send + Sync + 'static) -> Self { + self.on_evict = Some(Arc::new(on_evict)); + self + } + + /// Build the cache. + /// + /// # Panics + /// + /// Panics if `ttl` was not set, if `ttl` is zero, or if the shard count overflows. + #[must_use] + pub fn build(self) -> ShardedTtlCacheBase + where + K: Hash + Eq, + H: ShardHasher, + { + self.try_build() + .unwrap_or_else(|e| panic!("ShardedTtlCache build failed: {e}")) + } + + /// Build the cache, returning an error if required fields are missing. + /// + /// # Errors + /// + /// Returns [`BuildError`] if `ttl` was not set or is zero, or if the shard count overflows. + pub fn try_build(self) -> Result, BuildError> + where + K: Hash + Eq, + H: ShardHasher, + { + let ttl = self.ttl.ok_or(BuildError::MissingRequired("ttl"))?; + crate::stores::validate_ttl(ttl)?; + let n = checked_shard_count(self.shards)?; + let mask = n - 1; + let shards = (0..n) + .map(|_| CachePadded(Shard::new(HashMap::with_hasher(RandomState::new())))) + .collect::>() + .into_boxed_slice(); + Ok(ShardedTtlCacheBase { + inner: Arc::new(TtlInner { + shards, + shard_mask: mask, + hasher: self + .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), + refresh: AtomicBool::new(self.refresh), + evictions: AtomicU64::new(0), + }), + }) + } + + /// Build the new cache and copy every non-expired entry from `existing` into it, + /// preserving the original `TimedEntry` timestamps. + /// + /// The target cache uses this builder's TTL setting when checking copied entries. + /// For the same wall-clock expiry schedule, build the target with the same TTL as + /// `existing`; a shorter or longer target TTL can make copied entries expire earlier + /// or later than they would have in the source cache. + /// + /// Acquires each shard's read lock on `existing` one at a time. Writes to + /// `existing` that occur after a shard's read lock is released may or may + /// not appear in the new cache; the new cache warms up from misses after + /// the swap. + /// + /// **Note**: `on_evict` callbacks on `existing` do not fire — entries are read + /// (not removed) from the source cache. + /// + /// # Panics + /// + /// Panics if `ttl` was not set or is zero, or if the shard count overflows. + #[must_use] + pub fn copy_from>( + self, + existing: &ShardedTtlCacheBase, + ) -> ShardedTtlCacheBase + where + K: Clone + Hash + Eq, + V: Clone, + H: ShardHasher, + { + let new_cache = self + .try_build() + .unwrap_or_else(|e| panic!("ShardedTtlCache build failed: {e}")); + let existing_ttl = existing.ttl_duration(); + for shard in existing.inner.shards.iter() { + let entries: Vec<(K, TimedEntry)> = { + let guard = shard.lock.read(); + 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, + } + }) + .map(|(k, e)| (k.clone(), e.clone())) + .collect() + }; + // Insert preserving original timestamps. + for (k, entry) in entries { + let new_shard = new_cache.shard_of(&k); + new_shard.lock.write().insert(k, entry); + } + } + new_cache + } +} + +impl ConcurrentCloneCached for ShardedTtlCacheBase +where + K: Hash + Eq, + V: Clone, + H: ShardHasher, +{ + /// Returns `(Some(v), false)` for a live entry (hit), `(Some(v), true)` for an expired + /// entry (miss, **no removal**, no eviction counter), or `(None, false)` when absent (miss). + 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. + let mut guard = shard.lock.write(); + match guard.get_mut(k) { + None => { + drop(guard); + shard.misses.fetch_add(1, Ordering::Relaxed); + (None, false) + } + Some(entry) => { + let expired = self.is_expired(entry); + let value = entry.value.clone(); + if !expired { + entry.instant = Instant::now(); + } + drop(guard); + if expired { + shard.misses.fetch_add(1, Ordering::Relaxed); + (Some(value), true) + } else { + shard.hits.fetch_add(1, Ordering::Relaxed); + (Some(value), false) + } + } + } + } else { + // Default path: read lock sufficient; no modification needed. + let guard = shard.lock.read(); + match guard.get(k) { + None => { + drop(guard); + shard.misses.fetch_add(1, Ordering::Relaxed); + (None, false) + } + Some(entry) => { + let expired = self.is_expired(entry); + let value = entry.value.clone(); + drop(guard); + if expired { + shard.misses.fetch_add(1, Ordering::Relaxed); + (Some(value), true) + } else { + shard.hits.fetch_add(1, Ordering::Relaxed); + (Some(value), false) + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ConcurrentCached as SyncConcurrentCached; + use crate::ConcurrentCloneCached; + + #[test] + fn basic_get_set_remove() { + let c = ShardedTtlCache::::with_ttl(Duration::from_secs(60)); + assert_eq!( + SyncConcurrentCached::cache_get(&c, &1).expect("cache_get must succeed"), + None + ); + assert_eq!( + SyncConcurrentCached::cache_set(&c, 1, 100).expect("insert must succeed"), + None + ); + assert_eq!( + SyncConcurrentCached::cache_get(&c, &1).expect("key was just inserted"), + Some(100) + ); + assert_eq!( + SyncConcurrentCached::cache_remove(&c, &1).expect("key must be present"), + Some(100) + ); + assert_eq!( + SyncConcurrentCached::cache_get(&c, &1).expect("cache_get must succeed"), + None + ); + } + + #[test] + fn clone_shares_state() { + let c1 = ShardedTtlCache::::with_ttl(Duration::from_secs(60)); + let c2 = c1.clone(); + SyncConcurrentCached::cache_set(&c1, 1, 10).expect("insert must succeed"); + assert_eq!( + SyncConcurrentCached::cache_get(&c2, &1).expect("key was just inserted"), + Some(10) + ); + } + + #[test] + fn ttl_expiry() { + let c = ShardedTtlCache::::with_ttl(Duration::from_millis(50)); + SyncConcurrentCached::cache_set(&c, 1, 100).expect("insert must succeed"); + assert_eq!( + SyncConcurrentCached::cache_get(&c, &1).expect("key was just inserted"), + Some(100) + ); + std::thread::sleep(std::time::Duration::from_millis(100)); + assert_eq!( + SyncConcurrentCached::cache_get(&c, &1).expect("cache_get must succeed"), + None + ); + } + + #[test] + fn evict_sweeps_expired() { + let c = ShardedTtlCache::::with_ttl(Duration::from_millis(50)); + for i in 0..10u32 { + SyncConcurrentCached::cache_set(&c, i, i).expect("insert must succeed"); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + let removed = c.evict(); + assert_eq!(removed, 10); + assert_eq!(c.metrics().evictions, Some(10)); + } + + #[test] + fn set_ttl_inherent() { + let c = ShardedTtlCache::::with_ttl(Duration::from_secs(60)); + let prev = c.set_ttl(Duration::from_secs(30)); + assert_eq!(prev, Some(Duration::from_secs(60))); + assert_eq!(c.ttl(), Some(Duration::from_secs(30))); + } + + #[test] + fn copy_from_skips_expired() { + let old = ShardedTtlCache::::with_ttl(Duration::from_millis(50)); + for i in 0..10u32 { + SyncConcurrentCached::cache_set(&old, i, i).expect("insert must succeed"); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + let new_cache = ShardedTtlCacheBase::::builder() + .ttl(Duration::from_secs(60)) + .copy_from(&old); + // All original entries expired — new cache should be empty + assert_eq!(new_cache.len(), 0); + } + + #[test] + fn copy_from_preserves_live_entries() { + let old = ShardedTtlCache::::with_ttl(Duration::from_secs(60)); + for i in 0..20u32 { + SyncConcurrentCached::cache_set(&old, i, i * 10).expect("insert must succeed"); + } + let new_cache = ShardedTtlCacheBase::::builder() + .ttl(Duration::from_secs(60)) + .copy_from(&old); + for i in 0..20u32 { + assert_eq!( + SyncConcurrentCached::cache_get(&new_cache, &i).expect("key was just inserted"), + Some(i * 10) + ); + } + } + + #[test] + fn send_sync() { + fn assert_send_sync() {} + assert_send_sync::>(); + } + + #[test] + fn try_build_rejects_zero_ttl() { + let err = ShardedTtlCacheBase::::builder() + .ttl(Duration::from_nanos(0)) + .try_build(); + assert!( + matches!(err, Err(crate::stores::BuildError::InvalidTtl { .. })), + "expected InvalidTtl, got {err:?}", + ); + } + + #[test] + fn cache_clear_with_on_evict_fires_for_all_entries() { + use std::sync::atomic::{AtomicU64, Ordering}; + let count = Arc::new(AtomicU64::new(0)); + let count2 = count.clone(); + let c = ShardedTtlCacheBase::::builder() + .ttl(Duration::from_secs(3600)) + .on_evict(move |_, _| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + for i in 0..20u32 { + SyncConcurrentCached::cache_set(&c, i, i).expect("insert must succeed"); + } + let before = c.metrics().evictions.expect("eviction-tracking stores report an evictions count"); + c.cache_clear_with_on_evict(); + assert_eq!( + c.len(), + 0, + "cache must be empty after cache_clear_with_on_evict" + ); + assert_eq!( + count.load(Ordering::Relaxed), + 20, + "on_evict must fire for every entry" + ); + assert_eq!( + c.metrics().evictions.expect("eviction-tracking stores report an evictions count") - before, + 20, + "evictions counter must increment for each entry" + ); + } + + #[test] + fn clear_does_not_fire_on_evict() { + use std::sync::atomic::{AtomicU64, Ordering}; + let count = Arc::new(AtomicU64::new(0)); + let count2 = count.clone(); + let c = ShardedTtlCacheBase::::builder() + .ttl(Duration::from_secs(3600)) + .on_evict(move |_, _| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + for i in 0..10u32 { + SyncConcurrentCached::cache_set(&c, i, i).expect("insert must succeed"); + } + c.clear(); + assert_eq!( + count.load(Ordering::Relaxed), + 0, + "clear must not fire on_evict" + ); + } + + #[test] + fn cache_remove_entry_returns_some_for_live_entry() { + let c = ShardedTtlCache::::with_ttl(Duration::from_secs(60)); + SyncConcurrentCached::cache_set(&c, 1u32, 100u32).expect("insert must succeed"); + assert_eq!( + c.cache_remove_entry(&999u32) + .expect("cache_remove_entry must succeed"), + None + ); + assert_eq!( + c.cache_remove_entry(&1u32).expect("key must be present"), + Some((1u32, 100u32)) + ); + assert_eq!( + SyncConcurrentCached::cache_get(&c, &1u32).expect("cache_get must succeed"), + None + ); + } + + #[test] + fn cache_remove_entry_returns_some_for_expired_entry() { + let c = ShardedTtlCache::::with_ttl(Duration::from_millis(50)); + SyncConcurrentCached::cache_set(&c, 1u32, 100u32).expect("insert must succeed"); + SyncConcurrentCached::cache_set(&c, 2u32, 200u32).expect("insert must succeed"); + std::thread::sleep(std::time::Duration::from_millis(100)); + + // cache_remove returns None for expired. + assert_eq!( + SyncConcurrentCached::cache_remove(&c, &1u32).expect("cache_remove must succeed"), + None + ); + + // cache_remove_entry returns Some even for expired. + let removed = c.cache_remove_entry(&2u32).expect("key must be present"); + assert!( + removed.is_some(), + "cache_remove_entry must return Some for expired entry" + ); + assert_eq!(removed.expect("must be Some"), (2u32, 200u32)); + } + + #[test] + fn cache_delete_returns_true_for_expired_entry() { + let c = ShardedTtlCache::::with_ttl(Duration::from_millis(50)); + SyncConcurrentCached::cache_set(&c, 1u32, 100u32).expect("insert must succeed"); + std::thread::sleep(std::time::Duration::from_millis(100)); + assert!( + SyncConcurrentCached::cache_delete(&c, &1u32).expect("cache_delete must succeed"), + "cache_delete must be true for expired entry" + ); + assert!(!SyncConcurrentCached::cache_delete(&c, &1u32).expect("cache_delete must succeed")); + } + + #[test] + fn cache_remove_entry_fires_on_evict_for_expired() { + use std::sync::atomic::{AtomicU64, Ordering}; + let count = Arc::new(AtomicU64::new(0)); + let count2 = count.clone(); + let c = ShardedTtlCacheBase::::builder() + .ttl(Duration::from_millis(50)) + .shards(1) + .on_evict(move |_, _| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + SyncConcurrentCached::cache_set(&c, 1u32, 10u32).expect("insert must succeed"); + std::thread::sleep(std::time::Duration::from_millis(100)); + + c.cache_remove_entry(&1u32).expect("key must be present"); + assert_eq!( + count.load(Ordering::Relaxed), + 1, + "on_evict fires for expired entries" + ); + + c.cache_remove_entry(&999u32) + .expect("cache_remove_entry must succeed"); + assert_eq!(count.load(Ordering::Relaxed), 1, "no fire for absent key"); + } + + #[test] + fn cache_remove_entry_increments_eviction_counter() { + let c = ShardedTtlCacheBase::::builder() + .ttl(Duration::from_millis(10)) + .shards(1) + .build(); + SyncConcurrentCached::cache_set(&c, 1u32, 10u32).expect("insert must succeed"); + std::thread::sleep(std::time::Duration::from_millis(100)); + let before = c.metrics().evictions.expect("evictions are always tracked"); + c.cache_remove_entry(&1u32).expect("key must be present"); // expired but present — must increment + c.cache_remove_entry(&999u32) + .expect("cache_remove_entry must succeed"); // absent — must not increment + assert_eq!( + c.metrics().evictions.expect("evictions are always tracked") - before, + 1, + "cache_remove_entry must increment evictions for present key only" + ); + } + + // --- ConcurrentCloneCached tests --- + + #[test] + fn concurrent_clone_cached_absent_is_none_false() { + let c = ShardedTtlCache::::with_ttl(Duration::from_secs(60)); + assert_eq!( + ConcurrentCloneCached::cache_get_with_expiry_status(&c, &1u32), + (None, false), + "absent key must return (None, false)" + ); + assert_eq!( + c.metrics().misses, + Some(1), + "absent lookup must increment misses" + ); + } + + #[test] + fn concurrent_clone_cached_live_entry_is_some_false() { + let c = ShardedTtlCache::::with_ttl(Duration::from_secs(60)); + SyncConcurrentCached::cache_set(&c, 1u32, 42u32).expect("insert must succeed"); + assert_eq!( + ConcurrentCloneCached::cache_get_with_expiry_status(&c, &1u32), + (Some(42), false), + "live entry must return (Some(v), false)" + ); + assert_eq!(c.metrics().hits, Some(1), "live lookup must increment hits"); + assert_eq!( + c.metrics().evictions, + Some(0), + "live lookup must not increment evictions" + ); + } + + #[test] + fn concurrent_clone_cached_expired_returns_stale_no_eviction() { + let c = ShardedTtlCacheBase::::builder() + .ttl(Duration::from_millis(50)) + .shards(1) + .build(); + SyncConcurrentCached::cache_set(&c, 1u32, 99u32).expect("insert must succeed"); + std::thread::sleep(std::time::Duration::from_millis(100)); + + let (val, expired) = ConcurrentCloneCached::cache_get_with_expiry_status(&c, &1u32); + assert_eq!(val, Some(99), "expired entry must return the stale value"); + assert!(expired, "expired entry must set the expired flag"); + assert_eq!( + c.metrics().misses, + Some(1), + "expired lookup must increment misses" + ); + assert_eq!( + c.metrics().evictions, + Some(0), + "expired lookup must NOT increment evictions" + ); + + // The entry must NOT have been removed — a regular cache_get still sees it. + // (cache_get will evict it, hence the separate assertion above.) + let (val2, expired2) = ConcurrentCloneCached::cache_get_with_expiry_status(&c, &1u32); + assert_eq!( + val2, + Some(99), + "entry must still be present after expiry-status lookup" + ); + assert!( + expired2, + "entry must still be expired on second expiry-status call" + ); + } +} diff --git a/src/stores/sharded/unbound.rs b/src/stores/sharded/unbound.rs new file mode 100644 index 00000000..5ad2d84b --- /dev/null +++ b/src/stores/sharded/unbound.rs @@ -0,0 +1,793 @@ +use std::hash::Hash; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +#[cfg(feature = "ahash")] +use ahash::RandomState; +#[cfg(not(feature = "ahash"))] +use std::collections::hash_map::RandomState; + +use std::collections::HashMap; + +#[cfg(feature = "async_core")] +use crate::ConcurrentCachedAsync; +use crate::{CacheMetrics, ConcurrentCached}; + +use super::{ + checked_shard_count, default_shard_count, shard_index, CachePadded, DefaultShardHasher, Shard, + ShardHasher, +}; +use crate::stores::BuildError; + +type OnEvict = Arc; + +#[allow(clippy::type_complexity)] +struct UnboundInner { + shards: Box<[CachePadded>>]>, + shard_mask: usize, + hasher: H, + on_evict: Option>, +} + +/// 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. +/// +/// **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; + +/// Backing type for [`ShardedCache`] with a generic shard hasher `H`. +/// +/// In most cases prefer the [`ShardedCache`] 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 { + inner: Arc>, +} + +impl Clone for ShardedCacheBase { + /// Arc-share clone — both handles point to the same underlying cache. + fn clone(&self) -> Self { + Self { + inner: Arc::clone(&self.inner), + } + } +} + +impl std::fmt::Debug for ShardedCacheBase { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ShardedCache") + .field("shards", &self.inner.shards.len()) + .finish_non_exhaustive() + } +} + +impl ShardedCacheBase +where + K: Hash + Eq, + H: ShardHasher, +{ + /// Return a builder for constructing a [`ShardedCacheBase`]. + /// + /// 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() + } + + #[inline] + fn shard_of(&self, k: &K) -> &CachePadded>> { + let h = self.inner.hasher.shard_hash(k); + &self.inner.shards[shard_index(h, self.inner.shard_mask)] + } +} + +impl ShardedCache +where + K: Hash + Eq, +{ + /// Create a new unbounded sharded cache with the default shard count. + #[must_use] + pub fn new() -> Self { + Self::with_shards(default_shard_count()) + } + + /// Create a new unbounded sharded cache with the given shard count + /// (rounded up to the next power of two). + #[must_use] + pub fn with_shards(shards: usize) -> Self { + ShardedCacheBuilder::default().shards(shards).build() + } +} + +impl Default for ShardedCache +where + K: Hash + Eq, +{ + fn default() -> Self { + Self::new() + } +} + +impl + Clone> ShardedCacheBase { + /// 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}; + /// + /// let cache: ShardedCache = ShardedCache::new(); + /// cache.cache_set("k".to_string(), 1).expect("ShardedCache operations are infallible"); + /// + /// 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 + /// ``` + #[must_use] + pub fn deep_clone(&self) -> Self { + let n = self.inner.shards.len(); + let shards = (0..n) + .map(|i| { + let guard = self.inner.shards[i].lock.read(); + let store_copy = guard.clone(); + drop(guard); + let hits = self.inner.shards[i].hits.load(Ordering::Relaxed); + let misses = self.inner.shards[i].misses.load(Ordering::Relaxed); + let shard = Shard { + lock: parking_lot::RwLock::new(store_copy), + hits: AtomicU64::new(hits), + misses: AtomicU64::new(misses), + }; + CachePadded(shard) + }) + .collect::>() + .into_boxed_slice(); + Self { + inner: Arc::new(UnboundInner { + shards, + shard_mask: self.inner.shard_mask, + hasher: self.inner.hasher.clone(), + on_evict: self.inner.on_evict.clone(), + }), + } + } +} + +impl> ShardedCacheBase +where + K: Hash + Eq, +{ + /// Return aggregate metrics across all shards. + #[must_use] + pub fn metrics(&self) -> CacheMetrics { + let mut hits = 0u64; + let mut misses = 0u64; + let mut size = 0usize; + for shard in self.inner.shards.iter() { + hits += shard.hits.load(Ordering::Relaxed); + misses += shard.misses.load(Ordering::Relaxed); + size += shard.lock.read().len(); + } + CacheMetrics { + hits: Some(hits), + misses: Some(misses), + evictions: None, + size, + capacity: None, + } + } + + /// Number of shards. + #[must_use] + pub fn shards(&self) -> usize { + self.inner.shards.len() + } + + /// Per-shard live entry counts — useful for diagnosing key distribution skew. + #[must_use] + pub fn shard_sizes(&self) -> Vec { + self.inner + .shards + .iter() + .map(|s| s.lock.read().len()) + .collect() + } + + /// Total number of live entries across all shards. + #[must_use] + pub fn len(&self) -> usize { + self.inner.shards.iter().map(|s| s.lock.read().len()).sum() + } + + /// `true` if no entries are present. + #[must_use] + pub fn is_empty(&self) -> bool { + self.inner.shards.iter().all(|s| s.lock.read().is_empty()) + } + + /// Remove all entries from every shard. Does **not** fire `on_evict`. + /// Use [`cache_clear_with_on_evict`](Self::cache_clear_with_on_evict) to opt into callback firing. + pub fn clear(&self) { + for shard in self.inner.shards.iter() { + shard.lock.write().clear(); + } + } + + /// Remove all entries from every shard, firing `on_evict` for each removed entry when a + /// callback is configured. + /// + /// If no `on_evict` callback is configured, this is equivalent to [`clear`](Self::clear). + /// + /// **Note:** `ShardedCache` 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() { + return self.clear(); + } + for shard in self.inner.shards.iter() { + let entries: Vec<(K, V)> = shard.lock.write().drain().collect(); + if let Some(on_evict) = &self.inner.on_evict { + for (k, v) in &entries { + on_evict(k, v); + } + } + } + } +} + +impl ShardedCacheBase +where + K: Hash + Eq, + V: Clone, + H: ShardHasher, +{ + /// Retrieve a value from the cache. + #[inline] + pub fn cache_get(&self, k: &K) -> Result, std::convert::Infallible> { + >::cache_get(self, k) + } + + /// Set a value in the cache. + #[inline] + pub fn cache_set(&self, k: K, v: V) -> Result, std::convert::Infallible> { + >::cache_set(self, k, v) + } + + /// Remove a value from the cache. + #[inline] + pub fn cache_remove(&self, k: &K) -> Result, std::convert::Infallible> { + >::cache_remove(self, k) + } + + /// Remove an entry from the cache, returning the stored key and value. + #[inline] + pub fn cache_remove_entry(&self, k: &K) -> Result, std::convert::Infallible> { + >::cache_remove_entry(self, k) + } + + /// Delete a value from the cache. + #[inline] + pub fn cache_delete(&self, k: &K) -> Result { + >::cache_delete(self, k) + } +} + +impl ConcurrentCached for ShardedCacheBase +where + K: Hash + Eq, + V: Clone, + H: ShardHasher, +{ + type Error = std::convert::Infallible; + + fn cache_get(&self, k: &K) -> Result, Self::Error> { + let shard = self.shard_of(k); + let guard = shard.lock.read(); + match guard.get(k) { + Some(v) => { + shard.hits.fetch_add(1, Ordering::Relaxed); + Ok(Some(v.clone())) + } + None => { + shard.misses.fetch_add(1, Ordering::Relaxed); + Ok(None) + } + } + } + + fn cache_set(&self, k: K, v: V) -> Result, Self::Error> { + let shard = self.shard_of(&k); + Ok(shard.lock.write().insert(k, v)) + } + + fn cache_remove(&self, k: &K) -> Result, Self::Error> { + self.cache_remove_entry(k).map(|r| r.map(|(_, v)| v)) + } + + fn cache_remove_entry(&self, k: &K) -> Result, Self::Error> { + let shard = self.shard_of(k); + let removed = shard.lock.write().remove_entry(k); + if let Some((ref stored_k, ref v)) = removed { + if let Some(on_evict) = &self.inner.on_evict { + on_evict(stored_k, v); + } + } + Ok(removed) + } + + fn set_refresh_on_hit(&mut self, _refresh: bool) -> bool { + false + } +} + +#[cfg(feature = "async_core")] +impl ConcurrentCachedAsync for ShardedCacheBase +where + K: Hash + Eq + Send + Sync, + V: Clone + Send + Sync, + H: ShardHasher, +{ + type Error = std::convert::Infallible; + + async fn cache_get(&self, k: &K) -> Result, Self::Error> { + ConcurrentCached::cache_get(self, k) + } + + async fn cache_set(&self, k: K, v: V) -> Result, Self::Error> { + ConcurrentCached::cache_set(self, k, v) + } + + async fn cache_remove(&self, k: &K) -> Result, Self::Error> { + ConcurrentCached::cache_remove(self, k) + } + + async fn cache_remove_entry(&self, k: &K) -> Result, Self::Error> { + ConcurrentCached::cache_remove_entry(self, k) + } + + fn set_refresh_on_hit(&mut self, b: bool) -> bool { + >::set_refresh_on_hit(self, b) + } +} + +/// Builder for [`ShardedCacheBase`]. +pub struct ShardedCacheBuilder { + shards: Option, + hasher: Option, + on_evict: Option>, + _k: std::marker::PhantomData, + _v: std::marker::PhantomData, +} + +impl Default for ShardedCacheBuilder { + fn default() -> Self { + Self { + shards: None, + hasher: Some(DefaultShardHasher::default()), + on_evict: None, + _k: std::marker::PhantomData, + _v: std::marker::PhantomData, + } + } +} + +impl ShardedCacheBuilder { + /// Set the number of shards (rounded up to the next power of two). + #[must_use] + pub fn shards(mut self, shards: usize) -> Self { + self.shards = Some(shards); + self + } + + /// Set a custom shard-selection hasher, changing the type parameter. + #[doc(alias = "with_hasher")] + #[must_use] + pub fn hasher>(self, hasher: H2) -> ShardedCacheBuilder { + ShardedCacheBuilder { + shards: self.shards, + hasher: Some(hasher), + on_evict: self.on_evict, + _k: std::marker::PhantomData, + _v: std::marker::PhantomData, + } + } + + /// 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. + /// + /// **Note**: `ShardedCache` 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. + /// + /// The closure must be `'static` (its captures cannot borrow from the local stack), but `K` + /// and `V` themselves are not required to be `'static`. + #[must_use] + pub fn on_evict(mut self, on_evict: impl Fn(&K, &V) + Send + Sync + 'static) -> Self { + self.on_evict = Some(Arc::new(on_evict)); + self + } + + /// Build the cache. + /// + /// # Panics + /// + /// Panics if construction fails (e.g. shard count overflow). + #[must_use] + pub fn build(self) -> ShardedCacheBase + where + K: Hash + Eq, + H: ShardHasher, + { + self.try_build() + .unwrap_or_else(|error| panic!("ShardedCache build failed: {error}")) + } + + /// Build the cache, returning an error instead of panicking. + /// + /// # Errors + /// + /// Returns [`BuildError`] if `shards` count is invalid or overflows. + pub fn try_build(self) -> Result, BuildError> + where + K: Hash + Eq, + H: ShardHasher, + { + let n = checked_shard_count(self.shards)?; + let mask = n - 1; + let shards = (0..n) + .map(|_| CachePadded(Shard::new(HashMap::with_hasher(RandomState::new())))) + .collect::>() + .into_boxed_slice(); + Ok(ShardedCacheBase { + inner: Arc::new(UnboundInner { + shards, + shard_mask: mask, + hasher: self + .hasher + .expect("hasher is always initialized via Default or .hasher()"), + on_evict: self.on_evict, + }), + }) + } + + /// Build the new cache and copy every entry from `existing` into it. + /// + /// Entries are re-hashed through `H` so they land in the correct shards + /// of the new cache. Acquires each shard's read lock on `existing` one at + /// a time — `existing` keeps serving concurrent ops throughout. + /// + /// Swapping which cache is "live" after the copy is the caller's + /// responsibility. Requests racing the swap may observe a cache miss. + /// + /// **Note**: writes to `existing` that occur after a shard's read lock is + /// released may or may not appear in the new cache; the new cache warms up + /// from misses after the swap. + /// + /// **Note**: `on_evict` callbacks on `existing` do not fire — entries are read + /// (not removed) from the source cache. + #[must_use] + pub fn copy_from>( + self, + existing: &ShardedCacheBase, + ) -> ShardedCacheBase + where + K: Clone + Hash + Eq, + V: Clone, + H: ShardHasher, + { + let new_cache = self.build(); + for shard in existing.inner.shards.iter() { + let entries: Vec<(K, V)> = { + let guard = shard.lock.read(); + guard.iter().map(|(k, v)| (k.clone(), v.clone())).collect() + }; + for (k, v) in entries { + let _ = ConcurrentCached::cache_set(&new_cache, k, v); + } + } + new_cache + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ConcurrentCached as SyncConcurrentCached; + + #[test] + fn basic_get_set_remove() { + let c = ShardedCache::::new(); + assert_eq!( + SyncConcurrentCached::cache_get(&c, &1).expect("cache_get must succeed"), + None + ); + assert_eq!( + SyncConcurrentCached::cache_set(&c, 1, 100).expect("insert must succeed"), + None + ); + assert_eq!( + SyncConcurrentCached::cache_get(&c, &1).expect("key was just inserted"), + Some(100) + ); + assert_eq!( + SyncConcurrentCached::cache_set(&c, 1, 200).expect("insert must succeed"), + Some(100) + ); + assert_eq!( + SyncConcurrentCached::cache_get(&c, &1).expect("key was just inserted"), + Some(200) + ); + assert_eq!( + SyncConcurrentCached::cache_remove(&c, &1).expect("key must be present"), + Some(200) + ); + assert_eq!( + SyncConcurrentCached::cache_get(&c, &1).expect("cache_get must succeed"), + None + ); + } + + #[test] + fn clone_shares_state() { + let c1 = ShardedCache::::new(); + let c2 = c1.clone(); + SyncConcurrentCached::cache_set(&c1, 1, 10).expect("insert must succeed"); + assert_eq!( + SyncConcurrentCached::cache_get(&c2, &1).expect("key was just inserted"), + Some(10) + ); + } + + #[test] + fn metrics_sum() { + let c = ShardedCache::::new(); + 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"); + let m = c.metrics(); + assert_eq!(m.hits, Some(1)); + assert_eq!(m.misses, Some(1)); + } + + #[test] + fn len_and_clear() { + let c = ShardedCache::::new(); + for i in 0..10u32 { + SyncConcurrentCached::cache_set(&c, i, i).expect("insert must succeed"); + } + assert_eq!(c.len(), 10); + assert!(!c.is_empty()); + c.clear(); + assert_eq!(c.len(), 0); + assert!(c.is_empty()); + } + + #[test] + fn shard_sizes() { + let c = ShardedCache::::with_shards(8); + for i in 0..100u32 { + SyncConcurrentCached::cache_set(&c, i, i).expect("insert must succeed"); + } + let sizes = c.shard_sizes(); + assert_eq!(sizes.len(), 8); + assert_eq!(sizes.iter().sum::(), 100); + } + + #[test] + fn on_evict_fires_on_remove() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let count = Arc::new(AtomicUsize::new(0)); + let count2 = count.clone(); + let c = ShardedCacheBase::::builder() + .on_evict(move |_, _| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + SyncConcurrentCached::cache_set(&c, 1, 1).expect("insert must succeed"); + SyncConcurrentCached::cache_remove(&c, &1).expect("key must be present"); + assert_eq!(count.load(Ordering::Relaxed), 1); + } + + #[test] + fn custom_hasher() { + #[derive(Default)] + struct ConstHasher; + impl ShardHasher for ConstHasher { + fn shard_hash(&self, _key: &u32) -> u64 { + 0 + } + } + let c = ShardedCacheBase::::builder() + .shards(8) + .hasher(ConstHasher) + .build(); + for i in 0..10u32 { + SyncConcurrentCached::cache_set(&c, i, i).expect("insert must succeed"); + } + // All keys route to shard 0 + let sizes = c.shard_sizes(); + assert_eq!(sizes[0], 10); + assert_eq!(sizes[1..].iter().sum::(), 0); + } + + #[test] + fn copy_from_preserves_entries() { + let old = ShardedCache::::new(); + for i in 0..50u32 { + SyncConcurrentCached::cache_set(&old, i, i * 10).expect("insert must succeed"); + } + let new_cache = ShardedCacheBase::::builder() + .shards(4) + .copy_from(&old); + for i in 0..50u32 { + assert_eq!( + SyncConcurrentCached::cache_get(&new_cache, &i).expect("key was just inserted"), + Some(i * 10) + ); + } + } + + #[test] + fn deep_clone_is_independent() { + let c1 = ShardedCache::::new(); + 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"); + assert_eq!( + SyncConcurrentCached::cache_get(&c2, &2).expect("cache_get must succeed"), + None + ); + assert_eq!( + SyncConcurrentCached::cache_get(&c1, &1).expect("key was just inserted"), + Some(1) + ); + assert_eq!( + SyncConcurrentCached::cache_get(&c2, &1).expect("key was copied to deep clone"), + Some(1) + ); + } + + #[test] + fn send_sync() { + fn assert_send_sync() {} + assert_send_sync::>(); + } + + #[test] + fn try_build_error_on_overflow() { + let c = ShardedCacheBase::::builder() + .shards(usize::MAX) + .try_build(); + assert!(c.is_err()); + match c.expect_err("usize::MAX shards should fail") { + BuildError::InvalidValue { field, reason } => { + assert_eq!(field, "shards"); + assert!(reason.contains("overflows")); + } + _ => panic!("expected BuildError::InvalidValue"), + } + } + + #[test] + fn build_panic_includes_build_error_context() { + let panic = + std::panic::catch_unwind(|| ShardedCacheBase::::builder().shards(0).build()) + .expect_err("zero shards should panic"); + let message = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .unwrap_or(""); + assert!(message.contains("ShardedCache build failed")); + assert!(message.contains("shards")); + } + + #[test] + fn cache_clear_with_on_evict_fires_for_all_entries() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let count = Arc::new(AtomicUsize::new(0)); + let count2 = count.clone(); + let c = ShardedCacheBase::::builder() + .on_evict(move |_, _| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + for i in 0..20u32 { + SyncConcurrentCached::cache_set(&c, i, i).expect("insert must succeed"); + } + c.cache_clear_with_on_evict(); + assert_eq!( + c.len(), + 0, + "cache must be empty after cache_clear_with_on_evict" + ); + assert_eq!( + count.load(Ordering::Relaxed), + 20, + "on_evict must fire for every entry" + ); + } + + #[test] + fn clear_does_not_fire_on_evict() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let count = Arc::new(AtomicUsize::new(0)); + let count2 = count.clone(); + let c = ShardedCacheBase::::builder() + .on_evict(move |_, _| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + for i in 0..10u32 { + SyncConcurrentCached::cache_set(&c, i, i).expect("insert must succeed"); + } + c.clear(); + assert_eq!( + count.load(Ordering::Relaxed), + 0, + "clear must not fire on_evict" + ); + } + + #[test] + fn cache_remove_entry_basic() { + let c = ShardedCacheBase::::builder().shards(1).build(); + SyncConcurrentCached::cache_set(&c, 1u32, 100u32).expect("insert must succeed"); + + assert_eq!( + c.cache_remove_entry(&999u32) + .expect("cache_remove_entry must succeed"), + None + ); + assert_eq!( + c.cache_remove_entry(&1u32).expect("key must be present"), + Some((1u32, 100u32)) + ); + assert_eq!( + SyncConcurrentCached::cache_get(&c, &1u32).expect("cache_get must succeed"), + None + ); + } + + #[test] + fn cache_remove_entry_fires_on_evict() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let count = Arc::new(AtomicUsize::new(0)); + let count2 = count.clone(); + let c = ShardedCacheBase::::builder() + .shards(1) + .on_evict(move |_, _| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + SyncConcurrentCached::cache_set(&c, 1u32, 10u32).expect("insert must succeed"); + c.cache_remove_entry(&1u32).expect("key must be present"); + assert_eq!(count.load(Ordering::Relaxed), 1); + + c.cache_remove_entry(&999u32) + .expect("cache_remove_entry must succeed"); + assert_eq!(count.load(Ordering::Relaxed), 1, "no fire for absent key"); + } + + #[test] + fn cache_delete_returns_true_for_present_entry() { + let c = ShardedCacheBase::::builder().shards(1).build(); + SyncConcurrentCached::cache_set(&c, 1u32, 10u32).expect("insert must succeed"); + assert!(SyncConcurrentCached::cache_delete(&c, &1u32).expect("cache_delete must succeed")); + assert!(!SyncConcurrentCached::cache_delete(&c, &1u32).expect("cache_delete must succeed")); + } +} diff --git a/src/stores/ttl.rs b/src/stores/ttl.rs index efd9e606..5d252778 100644 --- a/src/stores/ttl.rs +++ b/src/stores/ttl.rs @@ -95,12 +95,26 @@ impl TtlCacheBuilder { /// Set whether cache hits refresh the TTL of the accessed entry. #[must_use] - pub fn refresh(mut self, refresh: bool) -> Self { + pub fn refresh_on_hit(mut self, refresh: bool) -> Self { self.refresh = refresh; self } - /// Set a callback to be invoked when an entry is evicted. + /// Alias for [`refresh_on_hit`](Self::refresh_on_hit). + #[must_use] + pub fn refresh(self, refresh: bool) -> Self { + self.refresh_on_hit(refresh) + } + + /// Set a callback to be invoked when an entry is evicted. The callback fires for: + /// - TTL-expiry sweeps via [`evict`](TtlCache::evict). + /// - Explicit [`cache_remove`](crate::Cached::cache_remove), even when the removed + /// entry was already expired (`cache_remove` returns `None` but still fires the + /// callback and increments the evictions counter). + /// + /// Does **not** fire on [`cache_clear`](crate::Cached::cache_clear). + /// Use [`cache_clear_with_on_evict`](TtlCache::cache_clear_with_on_evict) + /// instead to opt into callback firing when clearing all entries. #[must_use] pub fn on_evict(mut self, on_evict: impl Fn(&K, &V) + Send + Sync + 'static) -> Self { self.on_evict = Some(Arc::new(on_evict)); @@ -111,43 +125,33 @@ impl TtlCacheBuilder { /// /// # Panics /// - /// Panics if `ttl` was not set. + /// Panics if `ttl` was not set or is zero. #[must_use] pub fn build(self) -> TtlCache where K: Hash + Eq, { - let ttl = self - .ttl - .expect("`TtlCacheBuilder` requires `ttl` to be set"); - TtlCache { - store: TtlCache::::new_store(self.capacity), - ttl, - hits: std::sync::atomic::AtomicU64::new(0), - misses: std::sync::atomic::AtomicU64::new(0), - evictions: std::sync::atomic::AtomicU64::new(0), - initial_capacity: self.capacity, - refresh: self.refresh, - on_evict: self.on_evict, - } + self.try_build() + .unwrap_or_else(|e| panic!("TtlCache build failed: {e}")) } /// Build the cache, returning an error instead of panicking. /// /// # Errors /// - /// Returns [`BuildError`](super::BuildError) if `ttl` was not set. + /// Returns [`BuildError`](super::BuildError) if `ttl` was not set or is zero. pub fn try_build(self) -> Result, super::BuildError> where K: Hash + Eq, { let ttl = self.ttl.ok_or(super::BuildError::MissingRequired("ttl"))?; + super::validate_ttl(ttl)?; Ok(TtlCache { store: TtlCache::::new_store(self.capacity), ttl, - hits: std::sync::atomic::AtomicU64::new(0), - misses: std::sync::atomic::AtomicU64::new(0), - evictions: std::sync::atomic::AtomicU64::new(0), + hits: AtomicU64::new(0), + misses: AtomicU64::new(0), + evictions: AtomicU64::new(0), initial_capacity: self.capacity, refresh: self.refresh, on_evict: self.on_evict, @@ -229,14 +233,38 @@ impl TtlCache { &self.store } + /// Remove all entries and fire the `on_evict` callback for each one, incrementing the + /// evictions counter. + /// + /// Unlike [`cache_clear`](crate::Cached::cache_clear) (which removes entries silently), + /// this method invokes `on_evict` for every removed entry (whether or not they had expired) + /// and increments `evictions`. If no `on_evict` callback was configured, it falls back to + /// the plain `cache_clear`. + pub fn cache_clear_with_on_evict(&mut self) { + if self.on_evict.is_none() { + return self.cache_clear(); + } + let entries: Vec<(K, TimedEntry)> = self.store.drain().collect(); + let count = entries.len() as u64; + if count > 0 { + self.evictions.fetch_add(count, Ordering::Relaxed); + } + if let Some(on_evict) = &self.on_evict { + for (k, entry) in &entries { + on_evict(k, &entry.value); + } + } + } + /// Evict expired values from the cache. 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 entry.instant.elapsed() < ttl { + if now.saturating_duration_since(entry.instant) < ttl { true } else { if let Some(on_evict) = on_evict { @@ -403,14 +431,37 @@ impl Cached for TtlCache { K: std::borrow::Borrow, Q: std::hash::Hash + Eq + ?Sized, { - self.store.remove(k).and_then(|entry| { + if let Some((stored_k, entry)) = self.store.remove_entry(k) { + if let Some(on_evict) = &self.on_evict { + on_evict(&stored_k, &entry.value); + } + self.evictions.fetch_add(1, Ordering::Relaxed); if entry.instant.elapsed() < self.ttl { Some(entry.value) } else { None } - }) + } else { + None + } + } + + fn cache_remove_entry(&mut self, k: &Q) -> Option<(K, V)> + where + K: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized, + { + if let Some((stored_k, entry)) = self.store.remove_entry(k) { + if let Some(on_evict) = &self.on_evict { + on_evict(&stored_k, &entry.value); + } + self.evictions.fetch_add(1, Ordering::Relaxed); + Some((stored_k, entry.value)) + } else { + None + } } + fn cache_clear(&mut self) { self.store.clear(); } @@ -628,6 +679,46 @@ mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; + #[test] + fn cache_clear_with_on_evict_fires_for_all_entries() { + let count = Arc::new(AtomicUsize::new(0)); + let count2 = count.clone(); + let mut c = TtlCache::builder() + .ttl(crate::time::Duration::from_secs(60)) + .on_evict(move |_k: &u32, _v: &u32| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + c.cache_set(1, 10); + c.cache_set(2, 20); + c.cache_set(3, 30); + c.cache_clear_with_on_evict(); + assert_eq!(c.cache_size(), 0); + assert_eq!(count.load(Ordering::Relaxed), 3); + assert_eq!(c.cache_evictions(), Some(3)); + } + + #[test] + fn cache_clear_does_not_fire_on_evict() { + let count = Arc::new(AtomicUsize::new(0)); + let count2 = count.clone(); + let mut c = TtlCache::builder() + .ttl(crate::time::Duration::from_secs(60)) + .on_evict(move |_k: &u32, _v: &u32| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + c.cache_set(1, 10); + c.cache_set(2, 20); + c.cache_clear(); + assert_eq!(c.cache_size(), 0); + assert_eq!( + count.load(Ordering::Relaxed), + 0, + "cache_clear must not fire on_evict" + ); + } + #[test] fn cache_reset_does_not_fire_on_evict() { let evict_count = Arc::new(AtomicUsize::new(0)); @@ -674,5 +765,109 @@ mod tests { let builder = TtlCache::::builder(); let try_built = builder.try_build(); assert!(try_built.is_err()); // Missing required ttl + + let builder = TtlCache::::builder().ttl(crate::time::Duration::ZERO); + let try_built = builder.try_build(); + assert!(try_built.is_err()); // Zero ttl is invalid + } + + #[test] + fn cache_remove_entry_returns_some_for_live_entry() { + let mut c = TtlCache::builder() + .ttl(crate::time::Duration::from_secs(60)) + .build(); + c.cache_set(1u32, 100u32); + assert_eq!(c.cache_remove_entry(&999u32), None); // absent + assert_eq!(c.cache_remove_entry(&1u32), Some((1u32, 100u32))); + assert_eq!(c.cache_get(&1u32), None); + } + + #[test] + fn cache_remove_entry_returns_some_for_expired_entry() { + let mut c = TtlCache::builder() + .ttl(crate::time::Duration::from_millis(50)) + .build(); + c.cache_set(1u32, 100u32); + std::thread::sleep(std::time::Duration::from_millis(100)); + + // cache_remove returns None for an expired entry. + assert_eq!( + c.cache_remove(&1u32), + None, + "cache_remove: None for expired" + ); + + // Re-insert and verify cache_remove_entry returns Some even though expired. + c.cache_set(2u32, 200u32); + std::thread::sleep(std::time::Duration::from_millis(100)); + let removed = c.cache_remove_entry(&2u32); + assert!( + removed.is_some(), + "cache_remove_entry must return Some even for expired entries" + ); + assert_eq!( + removed.expect("cache_remove_entry must return Some for a present entry"), + (2u32, 200u32) + ); + } + + #[test] + fn cache_delete_returns_true_for_expired_entry() { + let mut c = TtlCache::builder() + .ttl(crate::time::Duration::from_millis(50)) + .build(); + c.cache_set(1u32, 100u32); + std::thread::sleep(std::time::Duration::from_millis(100)); + + // cache_delete must return true even though the entry is expired. + assert!( + c.cache_delete(&1u32), + "cache_delete must return true when entry deleted, even if expired" + ); + + // Entry is now gone. + assert!( + !c.cache_delete(&1u32), + "cache_delete returns false when key absent" + ); + } + + #[test] + fn cache_remove_entry_fires_on_evict() { + let count = Arc::new(AtomicUsize::new(0)); + let count2 = count.clone(); + let mut c = TtlCache::builder() + .ttl(crate::time::Duration::from_millis(50)) + .on_evict(move |_k: &u32, _v: &u32| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + c.cache_set(1u32, 10u32); + std::thread::sleep(std::time::Duration::from_millis(100)); + + // Even for an expired entry, on_evict must fire. + c.cache_remove_entry(&1u32); + assert_eq!(count.load(Ordering::Relaxed), 1); + + // No fire for absent key. + c.cache_remove_entry(&999u32); + assert_eq!(count.load(Ordering::Relaxed), 1); + } + + #[test] + fn cache_remove_entry_increments_eviction_counter() { + let mut c = TtlCache::builder() + .ttl(crate::time::Duration::from_millis(10)) + .build(); + 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 + assert_eq!( + c.cache_evictions().expect("evictions are always tracked") - before, + 1, + "cache_remove_entry must increment evictions for present key only" + ); } } diff --git a/src/stores/ttl_sorted.rs b/src/stores/ttl_sorted.rs index 2f616d3c..b2d9aa8e 100644 --- a/src/stores/ttl_sorted.rs +++ b/src/stores/ttl_sorted.rs @@ -2,12 +2,13 @@ use crate::time::Duration; use crate::time::Instant; use crate::{CacheEvict, CacheTtl, Cached, CachedIter, CachedPeek, CachedRead, CloneCached}; +use super::StripedCounter; use std::borrow::Borrow; use std::cmp::Ordering as CmpOrdering; use std::collections::BTreeSet; use std::hash::{Hash, Hasher}; use std::ops::Bound::{Excluded, Included}; -use std::sync::atomic::Ordering; +use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering}; use std::sync::Arc; #[cfg(feature = "async_core")] use {super::CachedAsync, std::future::Future}; @@ -192,9 +193,9 @@ pub struct TtlSortedCache { pub(crate) ttl: Duration, pub(crate) size_limit: Option, - pub(super) hits: std::sync::atomic::AtomicU64, - pub(super) misses: std::sync::atomic::AtomicU64, - pub(super) evictions: std::sync::atomic::AtomicU64, + pub(super) hits: StripedCounter, + pub(super) misses: StripedCounter, + pub(super) evictions: AtomicU64, pub(super) on_evict: Option>, } @@ -203,18 +204,9 @@ impl std::fmt::Debug for TtlSortedCache { f.debug_struct("TtlSortedCache") .field("ttl", &self.ttl) .field("size_limit", &self.size_limit) - .field( - "hits", - &self.hits.load(std::sync::atomic::Ordering::Relaxed), - ) - .field( - "misses", - &self.misses.load(std::sync::atomic::Ordering::Relaxed), - ) - .field( - "evictions", - &self.evictions.load(std::sync::atomic::Ordering::Relaxed), - ) + .field("hits", &self.hits.load()) + .field("misses", &self.misses.load()) + .field("evictions", &self.evictions.load(AtomicOrdering::Relaxed)) .field("on_evict", &self.on_evict.as_ref().map(|_| "on_evict")) .finish() } @@ -232,15 +224,9 @@ where keys: self.keys.clone(), ttl: self.ttl, size_limit: self.size_limit, - hits: std::sync::atomic::AtomicU64::new( - self.hits.load(std::sync::atomic::Ordering::Relaxed), - ), - misses: std::sync::atomic::AtomicU64::new( - self.misses.load(std::sync::atomic::Ordering::Relaxed), - ), - evictions: std::sync::atomic::AtomicU64::new( - self.evictions.load(std::sync::atomic::Ordering::Relaxed), - ), + hits: self.hits.snapshot(), + misses: self.misses.snapshot(), + evictions: AtomicU64::new(self.evictions.load(AtomicOrdering::Relaxed)), on_evict: self.on_evict.clone(), } } @@ -256,9 +242,11 @@ pub struct TtlSortedCacheBuilder { impl TtlSortedCacheBuilder { /// Set the maximum number of entries. + #[doc(alias = "size")] + #[doc(alias = "capacity")] #[must_use] - pub fn size(mut self, size: usize) -> Self { - self.size = Some(size); + pub fn max_size(mut self, max_size: usize) -> Self { + self.size = Some(max_size); self } @@ -269,7 +257,16 @@ impl TtlSortedCacheBuilder { self } - /// Set a callback to be invoked when an entry is evicted. + /// 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). + /// - Lazy expiry removal during [`cache_get`](crate::Cached::cache_get) / [`cache_get_mut`](crate::Cached::cache_get_mut). + /// - Explicit [`cache_remove`](crate::Cached::cache_remove), including when the removed entry was already expired. + /// + /// Does **not** fire on [`cache_clear`](crate::Cached::cache_clear) / [`cache_reset`](crate::Cached::cache_reset). + /// Use [`cache_clear_with_on_evict`](TtlSortedCache::cache_clear_with_on_evict) + /// instead of [`cache_clear`](crate::Cached::cache_clear) to opt into callback + /// firing and eviction counter increments when clearing all entries. #[must_use] pub fn on_evict(mut self, on_evict: impl Fn(&K, &V) + Send + Sync + 'static) -> Self { self.on_evict = Some(Arc::new(on_evict)); @@ -280,40 +277,30 @@ impl TtlSortedCacheBuilder { /// /// # Panics /// - /// Panics if `ttl` was not set or the size limit is 0. + /// Panics if `ttl` was not set or is zero, or if the size limit is 0. #[must_use] pub fn build(self) -> TtlSortedCache where K: Hash + Eq + Ord + Clone, { - let ttl = self - .ttl - .expect("`TtlSortedCacheBuilder` requires `ttl` to be set"); - if self.size == Some(0) { - panic!("size limit must be greater than zero"); - } - let mut cache = match self.size { - Some(size) => TtlSortedCache::with_capacity(ttl, size.saturating_add(1)), - None => TtlSortedCache::new(ttl), - }; - cache.size_limit = self.size; - cache.on_evict = self.on_evict; - cache + self.try_build() + .unwrap_or_else(|e| panic!("TtlSortedCache build failed: {e}")) } /// Build the cache, returning an error instead of panicking. /// /// # Errors /// - /// Returns [`BuildError`](super::BuildError) if `ttl` is not set or `size` is `0`. + /// Returns [`BuildError`](super::BuildError) if `ttl` is not set or is zero, or if `size` is `0`. pub fn try_build(self) -> Result, super::BuildError> where K: Hash + Eq + Ord + Clone, { let ttl = self.ttl.ok_or(super::BuildError::MissingRequired("ttl"))?; + super::validate_ttl(ttl)?; if self.size == Some(0) { return Err(super::BuildError::InvalidValue { - field: "size", + field: "max_size", reason: "must be greater than zero", }); } @@ -345,9 +332,9 @@ impl TtlSortedCache { keys: BTreeSet::new(), ttl, size_limit: None, - hits: std::sync::atomic::AtomicU64::new(0), - misses: std::sync::atomic::AtomicU64::new(0), - evictions: std::sync::atomic::AtomicU64::new(0), + hits: StripedCounter::new(), + misses: StripedCounter::new(), + evictions: AtomicU64::new(0), on_evict: None, } } @@ -429,7 +416,7 @@ impl TtlSortedCache { if let Some(on_evict) = &self.on_evict { on_evict(key.0.as_ref(), &entry.value); } - self.evictions.fetch_add(1, Ordering::Relaxed); + self.evictions.fetch_add(1, AtomicOrdering::Relaxed); } count += 1; } @@ -469,7 +456,7 @@ impl TtlSortedCache { if let Some(on_evict) = &self.on_evict { on_evict(key.0.as_ref(), &entry.value); } - self.evictions.fetch_add(1, Ordering::Relaxed); + self.evictions.fetch_add(1, AtomicOrdering::Relaxed); } count += 1; } @@ -629,6 +616,44 @@ impl TtlSortedCache { ) .value } + + fn remove_expired_entry(&mut self, key: &Q) + where + K: Borrow, + Q: Hash + Eq + ?Sized, + { + if let Some(entry) = self.map.remove(key) { + self.keys.remove(&entry.as_stamped()); + if let Some(on_evict) = &self.on_evict { + on_evict(entry.key.0.as_ref(), &entry.value); + } + self.evictions.fetch_add(1, AtomicOrdering::Relaxed); + } + } + + /// Remove all entries and fire the `on_evict` callback for each one, incrementing the + /// evictions counter. + /// + /// Unlike [`cache_clear`](crate::Cached::cache_clear) (which removes entries silently), + /// this method invokes `on_evict` for every removed entry (whether or not they had expired) + /// and increments `evictions`. If no `on_evict` callback was configured, it falls back to + /// the plain `cache_clear`. + pub fn cache_clear_with_on_evict(&mut self) { + if self.on_evict.is_none() { + return self.cache_clear(); + } + let entries: Vec<(K, Entry)> = self.map.drain().collect(); + self.keys.clear(); + let count = entries.len() as u64; + if count > 0 { + self.evictions.fetch_add(count, AtomicOrdering::Relaxed); + } + if let Some(on_evict) = &self.on_evict { + for (_k, entry) in &entries { + on_evict(entry.key.0.as_ref(), &entry.value); + } + } + } } impl Cached for TtlSortedCache { @@ -637,30 +662,22 @@ impl Cached for TtlSortedCache { K: Borrow, Q: Hash + Eq + ?Sized, { - // Two lookups: the first (immutable) checks expiry and releases its borrow so - // the expired path can call map.remove(); the second (immutable) returns the live - // value. Collapsing to one lookup would require the borrow to extend past the - // mutable map.remove() call, which the borrow checker disallows in safe Rust. - let expired = match self.map.get(key) { + let is_expired = match self.map.get(key) { None => { - self.misses.fetch_add(1, Ordering::Relaxed); + self.misses.increment(); return None; } Some(entry) => entry.is_expired(), }; - if expired { - self.misses.fetch_add(1, Ordering::Relaxed); - if let Some(entry) = self.map.remove(key) { - self.keys.remove(&entry.as_stamped()); - if let Some(on_evict) = &self.on_evict { - on_evict(entry.key.0.as_ref(), &entry.value); - } - self.evictions.fetch_add(1, Ordering::Relaxed); - } + + if is_expired { + self.misses.increment(); + self.remove_expired_entry(key); return None; } - self.hits.fetch_add(1, Ordering::Relaxed); - self.map.get(key).map(|entry| &entry.value) + + self.hits.increment(); + self.map.get(key).map(|e| &e.value) } fn cache_get_mut(&mut self, key: &Q) -> Option<&mut V> @@ -668,28 +685,22 @@ impl Cached for TtlSortedCache { K: Borrow, Q: Hash + Eq + ?Sized, { - // Two lookups: same reasoning as cache_get above; additionally the second - // lookup must be mutable to return &mut V. - let expired = match self.map.get(key) { + let is_expired = match self.map.get(key) { None => { - self.misses.fetch_add(1, Ordering::Relaxed); + self.misses.increment(); return None; } Some(entry) => entry.is_expired(), }; - if expired { - self.misses.fetch_add(1, Ordering::Relaxed); - if let Some(entry) = self.map.remove(key) { - self.keys.remove(&entry.as_stamped()); - if let Some(on_evict) = &self.on_evict { - on_evict(entry.key.0.as_ref(), &entry.value); - } - self.evictions.fetch_add(1, Ordering::Relaxed); - } + + if is_expired { + self.misses.increment(); + self.remove_expired_entry(key); return None; } - self.hits.fetch_add(1, Ordering::Relaxed); - self.map.get_mut(key).map(|entry| &mut entry.value) + + self.hits.increment(); + self.map.get_mut(key).map(|e| &mut e.value) } fn cache_set(&mut self, key: K, value: V) -> Option { @@ -742,8 +753,14 @@ impl Cached for TtlSortedCache { match self.map.remove(key) { None => None, Some(removed) => { + let expired = removed.is_expired(); self.keys.remove(&removed.as_stamped()); - if removed.is_expired() { + let stored_k = (*removed.key.0).clone(); + if let Some(on_evict) = &self.on_evict { + on_evict(&stored_k, &removed.value); + } + self.evictions.fetch_add(1, AtomicOrdering::Relaxed); + if expired { None } else { Some(removed.value) @@ -752,6 +769,25 @@ impl Cached for TtlSortedCache { } } + fn cache_remove_entry(&mut self, key: &Q) -> Option<(K, V)> + where + K: Borrow, + Q: Hash + Eq + ?Sized, + { + match self.map.remove(key) { + None => None, + Some(removed) => { + self.keys.remove(&removed.as_stamped()); + let stored_k = (*removed.key.0).clone(); + if let Some(on_evict) = &self.on_evict { + on_evict(&stored_k, &removed.value); + } + self.evictions.fetch_add(1, AtomicOrdering::Relaxed); + Some((stored_k, removed.value)) + } + } + } + fn cache_clear(&mut self) { // Inline rather than delegate to a `self.clear()` shim — the `Cached` // short alias `clear` defaults to `cache_clear`, so going through it @@ -769,9 +805,9 @@ impl Cached for TtlSortedCache { } fn cache_reset_metrics(&mut self) { - self.misses.store(0, Ordering::Relaxed); - self.hits.store(0, Ordering::Relaxed); - self.evictions.store(0, Ordering::Relaxed); + self.misses.reset(); + self.hits.reset(); + self.evictions.store(0, AtomicOrdering::Relaxed); } /// Reports raw entry count without sweeping; the count may include @@ -783,15 +819,15 @@ impl Cached for TtlSortedCache { } fn cache_hits(&self) -> Option { - Some(self.hits.load(Ordering::Relaxed)) + Some(self.hits.load()) } fn cache_misses(&self) -> Option { - Some(self.misses.load(Ordering::Relaxed)) + Some(self.misses.load()) } fn cache_evictions(&self) -> Option { - Some(self.evictions.load(Ordering::Relaxed)) + Some(self.evictions.load(AtomicOrdering::Relaxed)) } fn cache_capacity(&self) -> Option { @@ -853,10 +889,10 @@ impl CachedRead for TtlSortedCache { Q: Hash + Eq + ?Sized, { if let Some(value) = self.cache_peek(key) { - self.hits.fetch_add(1, Ordering::Relaxed); + self.hits.increment(); Some(value) } else { - self.misses.fetch_add(1, Ordering::Relaxed); + self.misses.increment(); None } } @@ -870,15 +906,15 @@ impl CloneCached for TtlSortedCache< { match self.map.get(k) { None => { - self.misses.fetch_add(1, Ordering::Relaxed); + self.misses.increment(); (None, false) } Some(entry) if entry.is_expired() => { - self.misses.fetch_add(1, Ordering::Relaxed); + self.misses.increment(); (Some(entry.value.clone()), true) } Some(entry) => { - self.hits.fetch_add(1, Ordering::Relaxed); + self.hits.increment(); (Some(entry.value.clone()), false) } } @@ -956,6 +992,52 @@ mod test { use crate::stores::TtlSortedCache; use crate::time::Duration; use crate::{Cached, CachedRead}; + use std::cmp::Ordering as CmpOrdering; + use std::hash::{Hash, Hasher}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + #[derive(Clone, Debug)] + struct CountingKey { + label: &'static str, + hash_calls: Arc, + } + + impl CountingKey { + fn new(label: &'static str) -> Self { + Self { + label, + hash_calls: Arc::new(AtomicUsize::new(0)), + } + } + } + + impl Hash for CountingKey { + fn hash(&self, state: &mut H) { + self.hash_calls.fetch_add(1, Ordering::Relaxed); + self.label.hash(state); + } + } + + impl PartialEq for CountingKey { + fn eq(&self, other: &Self) -> bool { + self.label == other.label + } + } + + impl Eq for CountingKey {} + + impl PartialOrd for CountingKey { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } + } + + impl Ord for CountingKey { + fn cmp(&self, other: &Self) -> CmpOrdering { + self.label.cmp(other.label) + } + } #[test] fn borrow_keys() { @@ -968,6 +1050,93 @@ mod test { assert_eq!(cache.get([0].as_slice()).unwrap(), &"a"); } + #[test] + fn cache_get_live_hit_increments_hits() { + let key = CountingKey::new("live"); + let mut cache = TtlSortedCache::with_capacity(Duration::from_secs(60), 1); + cache.insert(key.clone(), 10).unwrap(); + + assert_eq!(cache.cache_get(&key), Some(&10)); + assert_eq!(cache.cache_hits(), Some(1)); + assert_eq!(cache.cache_misses(), Some(0)); + assert_eq!(cache.cache_size(), 1); + assert_eq!(cache.keys.len(), 1); + } + + #[test] + fn cache_get_mut_live_hit_updates_value() { + let key = CountingKey::new("live-mut"); + let mut cache = TtlSortedCache::with_capacity(Duration::from_secs(60), 1); + cache.insert(key.clone(), 10).unwrap(); + + let value = cache.cache_get_mut(&key).expect("entry should be live"); + *value = 11; + + assert_eq!(cache.cache_hits(), Some(1)); + assert_eq!(cache.cache_misses(), Some(0)); + assert_eq!(cache.cache_get(&key), Some(&11)); + } + + #[test] + fn cache_get_expired_hit_removes_map_and_ttl_index() { + let evicted = Arc::new(AtomicUsize::new(0)); + let evicted_clone = evicted.clone(); + let mut cache = TtlSortedCache::builder() + .ttl(Duration::from_secs(60)) + .on_evict(move |k: &&str, v: &u32| { + assert_eq!(*k, "expired"); + assert_eq!(*v, 10); + evicted_clone.fetch_add(1, Ordering::Relaxed); + }) + .try_build() + .expect("cache should build"); + + cache + .insert_ttl("expired", 10, Duration::from_nanos(0)) + .unwrap(); + assert_eq!(cache.cache_size(), 1); + assert_eq!(cache.keys.len(), 1); + + assert_eq!(cache.cache_get(&"expired"), None); + + assert_eq!(cache.cache_size(), 0); + assert_eq!(cache.keys.len(), 0); + assert_eq!(cache.cache_hits(), Some(0)); + assert_eq!(cache.cache_misses(), Some(1)); + assert_eq!(cache.cache_evictions(), Some(1)); + assert_eq!(evicted.load(Ordering::Relaxed), 1); + } + + #[test] + fn cache_get_mut_expired_hit_removes_map_and_ttl_index() { + let evicted = Arc::new(AtomicUsize::new(0)); + let evicted_clone = evicted.clone(); + let mut cache = TtlSortedCache::builder() + .ttl(Duration::from_secs(60)) + .on_evict(move |k: &&str, v: &u32| { + assert_eq!(*k, "expired-mut"); + assert_eq!(*v, 20); + evicted_clone.fetch_add(1, Ordering::Relaxed); + }) + .try_build() + .expect("cache should build"); + + cache + .insert_ttl("expired-mut", 20, Duration::from_nanos(0)) + .unwrap(); + assert_eq!(cache.cache_size(), 1); + assert_eq!(cache.keys.len(), 1); + + assert_eq!(cache.cache_get_mut(&"expired-mut"), None); + + assert_eq!(cache.cache_size(), 0); + assert_eq!(cache.keys.len(), 0); + assert_eq!(cache.cache_hits(), Some(0)); + assert_eq!(cache.cache_misses(), Some(1)); + assert_eq!(cache.cache_evictions(), Some(1)); + assert_eq!(evicted.load(Ordering::Relaxed), 1); + } + #[test] fn kitchen_sink() { let mut cache = TtlSortedCache::with_capacity(Duration::from_millis(100), 100); @@ -1102,7 +1271,7 @@ mod test { fn builder_rejects_zero_size_limit() { let cache = TtlSortedCache::::builder() .ttl(Duration::from_millis(1_000)) - .size(0) + .max_size(0) .try_build(); match cache { Ok(_) => panic!("zero size limit should fail"), @@ -1114,7 +1283,7 @@ mod test { } #[test] - fn get_or_set_with_size_limit_short_ttl_does_not_panic() { + fn get_or_set_with_max_size_limit_short_ttl_does_not_panic() { // Regression: when the just-inserted entry expires before existing entries, // `retain_latest` must evict the existing entry, not the one we're returning. let mut cache = TtlSortedCache::new(Duration::from_millis(1)); @@ -1132,8 +1301,8 @@ mod test { } #[test] - fn try_get_or_set_with_size_limit_short_ttl_does_not_panic() { - // Regression: same scenario as `get_or_set_with_size_limit_short_ttl_does_not_panic` + fn try_get_or_set_with_max_size_limit_short_ttl_does_not_panic() { + // Regression: same scenario as `get_or_set_with_max_size_limit_short_ttl_does_not_panic` // but via the fallible `cache_try_get_or_set_with` path, which also routes through // `set_and_get_mut`. let mut cache = TtlSortedCache::new(Duration::from_millis(1)); @@ -1151,7 +1320,7 @@ mod test { #[cfg(feature = "async")] #[tokio::test] - async fn async_get_or_set_with_size_limit_short_ttl_does_not_panic() { + async fn async_get_or_set_with_max_size_limit_short_ttl_does_not_panic() { use crate::CachedAsync; let mut cache = TtlSortedCache::new(Duration::from_millis(1)); cache.size_limit(1); @@ -1163,7 +1332,55 @@ mod test { .await; assert_eq!(*v, 2); assert_eq!(cache.cache_size(), 1); - assert_eq!(cache.cache_get("short"), Some(&2u32)); + // "long" was evicted by the size limit (not by TTL expiry); verify it is gone. + // Asserting cache_get("short") would be racy: the 1ms TTL can expire between + // the .await resumption and this line under a loaded CI runner. + assert_eq!( + cache.cache_get("long"), + None, + "long entry should have been evicted" + ); + } + + #[test] + fn cache_clear_with_on_evict_fires_for_all_entries() { + let count = Arc::new(AtomicUsize::new(0)); + let count2 = count.clone(); + let mut cache = TtlSortedCache::::builder() + .ttl(Duration::from_secs(60)) + .on_evict(move |_k: &u32, _v: &u32| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + cache.cache_set(1, 10); + cache.cache_set(2, 20); + cache.cache_set(3, 30); + cache.cache_clear_with_on_evict(); + assert_eq!(cache.cache_size(), 0); + assert_eq!(cache.keys.len(), 0); + assert_eq!(count.load(Ordering::Relaxed), 3); + assert_eq!(cache.cache_evictions(), Some(3)); + } + + #[test] + fn cache_clear_does_not_fire_on_evict() { + let count = Arc::new(AtomicUsize::new(0)); + let count2 = count.clone(); + let mut cache = TtlSortedCache::::builder() + .ttl(Duration::from_secs(60)) + .on_evict(move |_k: &u32, _v: &u32| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + cache.cache_set(1, 10); + cache.cache_set(2, 20); + cache.cache_clear(); + assert_eq!(cache.cache_size(), 0); + assert_eq!( + count.load(Ordering::Relaxed), + 0, + "cache_clear must not fire on_evict" + ); } #[test] @@ -1176,7 +1393,7 @@ mod test { let mut cache = TtlSortedCache::::builder() .ttl(Duration::from_secs(60)) - .size(2) + .max_size(2) .on_evict(move |_k: &u8, _v: &u8| { evicted_clone.fetch_add(1, Ordering::Relaxed); }) @@ -1204,7 +1421,7 @@ mod test { fn test_diagnostics_and_traits() { let mut cache = TtlSortedCache::builder() .ttl(Duration::from_secs(60)) - .size(3) + .max_size(3) .build(); cache.cache_set(1, 100); cache.cache_set(2, 200); @@ -1229,8 +1446,110 @@ mod test { let builder = TtlSortedCache::::builder() .ttl(Duration::from_secs(60)) - .size(0); + .max_size(0); let try_built = builder.try_build(); assert!(try_built.is_err()); // Size limit 0 is invalid + + let builder = TtlSortedCache::::builder().ttl(Duration::ZERO); + let try_built = builder.try_build(); + assert!(try_built.is_err()); // Zero ttl is invalid + } + + #[test] + fn cache_remove_entry_returns_some_for_live_entry() { + let mut c = TtlSortedCache::::builder() + .ttl(Duration::from_secs(60)) + .build(); + c.cache_set(1u32, 100u32); + let removed = c.cache_remove_entry(&1u32); + assert_eq!(removed, Some((1u32, 100u32))); + assert_eq!(c.cache_size(), 0); + } + + #[test] + fn cache_remove_entry_returns_some_for_expired_entry() { + let mut c = TtlSortedCache::::builder() + .ttl(Duration::from_millis(50)) + .build(); + c.cache_set(1u32, 100u32); + std::thread::sleep(std::time::Duration::from_millis(100)); + + // cache_remove returns None for expired. + assert_eq!(c.cache_remove(&1u32), None); + + // cache_remove_entry returns Some even for expired. + c.cache_set(2u32, 200u32); + std::thread::sleep(std::time::Duration::from_millis(100)); + let removed = c.cache_remove_entry(&2u32); + assert_eq!( + removed.expect("cache_remove_entry must return Some for expired entry"), + (2u32, 200u32) + ); + } + + #[test] + fn cache_delete_returns_true_for_expired_entry() { + let mut c = TtlSortedCache::::builder() + .ttl(Duration::from_millis(50)) + .build(); + c.cache_set(1u32, 100u32); + std::thread::sleep(std::time::Duration::from_millis(100)); + assert!( + c.cache_delete(&1u32), + "cache_delete must return true even for expired entry" + ); + assert!( + !c.cache_delete(&1u32), + "cache_delete returns false when absent" + ); + } + + #[test] + fn cache_remove_entry_fires_on_evict_for_expired() { + let count = Arc::new(AtomicUsize::new(0)); + let count2 = count.clone(); + let mut c = TtlSortedCache::::builder() + .ttl(Duration::from_millis(50)) + .on_evict(move |_k, _v| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + c.cache_set(1u32, 10u32); + std::thread::sleep(std::time::Duration::from_millis(100)); + + c.cache_remove_entry(&1u32); + assert_eq!( + count.load(Ordering::Relaxed), + 1, + "on_evict fires for expired entries" + ); + + c.cache_remove_entry(&999u32); + assert_eq!(count.load(Ordering::Relaxed), 1, "no fire for absent key"); + } + + #[test] + fn cache_remove_entry_absent_returns_none() { + let mut c = TtlSortedCache::::builder() + .ttl(Duration::from_secs(60)) + .build(); + assert_eq!(c.cache_remove_entry(&42u32), None); + } + + #[test] + fn cache_remove_entry_increments_eviction_counter() { + let mut c = TtlSortedCache::::builder() + .ttl(Duration::from_millis(10)) + .build(); + 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 + assert_eq!( + c.cache_evictions().expect("evictions are always tracked") - before, + 1, + "cache_remove_entry must increment evictions for present key only" + ); } } diff --git a/src/stores/unbound.rs b/src/stores/unbound.rs index 74bf9f9d..9a183d0b 100644 --- a/src/stores/unbound.rs +++ b/src/stores/unbound.rs @@ -15,7 +15,7 @@ use std::collections::{hash_map::Entry, HashMap}; #[cfg(feature = "async_core")] use {super::CachedAsync, std::future::Future}; -use std::sync::atomic::{AtomicU64, Ordering}; +use super::StripedCounter; /// Default unbounded cache /// @@ -24,8 +24,8 @@ use std::sync::atomic::{AtomicU64, Ordering}; /// Note: This cache is in-memory only pub struct UnboundCache { pub(super) store: HashMap, - pub(super) hits: AtomicU64, - pub(super) misses: AtomicU64, + pub(super) hits: StripedCounter, + pub(super) misses: StripedCounter, pub(super) initial_capacity: Option, pub(super) on_evict: Option>, } @@ -33,8 +33,8 @@ pub struct 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(Ordering::Relaxed)) - .field("misses", &self.misses.load(Ordering::Relaxed)) + .field("hits", &self.hits.load()) + .field("misses", &self.misses.load()) .field("on_evict", &self.on_evict.as_ref().map(|_| "on_evict")) .finish() } @@ -48,8 +48,8 @@ where fn clone(&self) -> Self { Self { store: self.store.clone(), - hits: AtomicU64::new(self.hits.load(Ordering::Relaxed)), - misses: AtomicU64::new(self.misses.load(Ordering::Relaxed)), + hits: self.hits.snapshot(), + misses: self.misses.snapshot(), initial_capacity: self.initial_capacity, on_evict: self.on_evict.clone(), } @@ -101,6 +101,9 @@ impl UnboundCacheBuilder { /// /// Note: because `UnboundCache` has no eviction policy, `on_evict` will /// 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. #[must_use] pub fn on_evict(mut self, on_evict: impl Fn(&K, &V) + Send + Sync + 'static) -> Self { self.on_evict = Some(std::sync::Arc::new(on_evict)); @@ -150,8 +153,8 @@ impl UnboundCache { pub fn new() -> UnboundCache { UnboundCache { store: Self::new_store(None), - hits: AtomicU64::new(0), - misses: AtomicU64::new(0), + hits: StripedCounter::new(), + misses: StripedCounter::new(), initial_capacity: None, on_evict: None, } @@ -162,8 +165,8 @@ impl UnboundCache { pub fn with_capacity(size: usize) -> UnboundCache { UnboundCache { store: Self::new_store(Some(size)), - hits: AtomicU64::new(0), - misses: AtomicU64::new(0), + hits: StripedCounter::new(), + misses: StripedCounter::new(), initial_capacity: Some(size), on_evict: None, } @@ -181,6 +184,23 @@ impl UnboundCache { pub fn store(&self) -> &HashMap { &self.store } + + /// Remove all entries and fire the `on_evict` callback for each one. + /// + /// Unlike [`cache_clear`](crate::Cached::cache_clear) (which removes entries silently), + /// this method invokes `on_evict` for every removed entry. If no `on_evict` callback was + /// configured, it falls back to the plain `cache_clear`. + pub fn cache_clear_with_on_evict(&mut self) { + if self.on_evict.is_none() { + return self.cache_clear(); + } + let entries: Vec<(K, V)> = self.store.drain().collect(); + if let Some(on_evict) = &self.on_evict { + for (k, v) in &entries { + on_evict(k, v); + } + } + } } impl Cached for UnboundCache { @@ -190,10 +210,10 @@ impl Cached for UnboundCache { Q: std::hash::Hash + Eq + ?Sized, { if let Some(v) = self.store.get(key) { - self.hits.fetch_add(1, Ordering::Relaxed); + self.hits.increment(); Some(v) } else { - self.misses.fetch_add(1, Ordering::Relaxed); + self.misses.increment(); None } } @@ -203,10 +223,10 @@ impl Cached for UnboundCache { Q: std::hash::Hash + Eq + ?Sized, { if let Some(v) = self.store.get_mut(key) { - self.hits.fetch_add(1, Ordering::Relaxed); + self.hits.increment(); Some(v) } else { - self.misses.fetch_add(1, Ordering::Relaxed); + self.misses.increment(); None } } @@ -216,12 +236,12 @@ impl Cached for UnboundCache { fn cache_get_or_set_with V>(&mut self, key: K, f: F) -> &mut V { match self.store.entry(key) { Entry::Occupied(occupied) => { - self.hits.fetch_add(1, Ordering::Relaxed); + self.hits.increment(); occupied.into_mut() } Entry::Vacant(vacant) => { - self.misses.fetch_add(1, Ordering::Relaxed); + self.misses.increment(); vacant.insert(f()) } } @@ -233,49 +253,58 @@ impl Cached for UnboundCache { ) -> Result<&mut V, E> { match self.store.entry(key) { Entry::Occupied(occupied) => { - self.hits.fetch_add(1, Ordering::Relaxed); + self.hits.increment(); Ok(occupied.into_mut()) } Entry::Vacant(vacant) => { - self.misses.fetch_add(1, Ordering::Relaxed); + self.misses.increment(); Ok(vacant.insert(f()?)) } } } fn cache_remove(&mut self, k: &Q) -> Option + where + K: std::borrow::Borrow, + Q: std::hash::Hash + Eq + ?Sized, + { + self.cache_remove_entry(k).map(|(_, v)| v) + } + + fn cache_remove_entry(&mut self, k: &Q) -> Option<(K, V)> where K: std::borrow::Borrow, Q: std::hash::Hash + Eq + ?Sized, { let removed = self.store.remove_entry(k); - if let Some((ref k, ref v)) = removed { + if let Some((ref stored_k, ref v)) = removed { if let Some(on_evict) = &self.on_evict { - on_evict(k, v); + on_evict(stored_k, v); } } - removed.map(|(_, v)| v) + removed } + fn cache_clear(&mut self) { self.store.clear(); } fn cache_reset(&mut self) { - // Entries are dropped in-place. UnboundCache has no `on_evict` callback. + // Entries are dropped in-place; `on_evict` is not called during reset. self.store = Self::new_store(self.initial_capacity); self.cache_reset_metrics(); } fn cache_reset_metrics(&mut self) { - self.misses.store(0, Ordering::Relaxed); - self.hits.store(0, Ordering::Relaxed); + self.misses.reset(); + self.hits.reset(); } fn cache_size(&self) -> usize { self.store.len() } fn cache_hits(&self) -> Option { - Some(self.hits.load(Ordering::Relaxed)) + Some(self.hits.load()) } fn cache_misses(&self) -> Option { - Some(self.misses.load(Ordering::Relaxed)) + Some(self.misses.load()) } } @@ -306,10 +335,10 @@ impl CachedRead for UnboundCache { Q: std::hash::Hash + Eq + ?Sized, { if let Some(value) = self.cache_peek(k) { - self.hits.fetch_add(1, Ordering::Relaxed); + self.hits.increment(); Some(value) } else { - self.misses.fetch_add(1, Ordering::Relaxed); + self.misses.increment(); None } } @@ -334,11 +363,11 @@ where async move { match self.store.entry(key) { Entry::Occupied(occupied) => { - self.hits.fetch_add(1, Ordering::Relaxed); + self.hits.increment(); occupied.into_mut() } Entry::Vacant(vacant) => { - self.misses.fetch_add(1, Ordering::Relaxed); + self.misses.increment(); vacant.insert(f().await) } } @@ -360,11 +389,11 @@ where async move { let v = match self.store.entry(key) { Entry::Occupied(occupied) => { - self.hits.fetch_add(1, Ordering::Relaxed); + self.hits.increment(); occupied.into_mut() } Entry::Vacant(vacant) => { - self.misses.fetch_add(1, Ordering::Relaxed); + self.misses.increment(); vacant.insert(f().await?) } }; @@ -377,6 +406,7 @@ where /// Cache store tests mod tests { use super::*; + use crate::Cached; #[test] fn basic_cache() { @@ -552,6 +582,47 @@ mod tests { assert_eq!(res.unwrap(), &1); } + #[test] + fn cache_clear_with_on_evict_fires_for_all_entries() { + use std::sync::atomic::{AtomicUsize, Ordering as AOrdering}; + use std::sync::Arc; + let count = Arc::new(AtomicUsize::new(0)); + let count2 = count.clone(); + let mut c = UnboundCache::builder() + .on_evict(move |_k: &u32, _v: &u32| { + count2.fetch_add(1, AOrdering::Relaxed); + }) + .build(); + c.cache_set(1, 10); + c.cache_set(2, 20); + c.cache_set(3, 30); + c.cache_clear_with_on_evict(); + assert_eq!(c.cache_size(), 0); + assert_eq!(count.load(AOrdering::Relaxed), 3); + } + + #[test] + fn cache_clear_does_not_fire_on_evict() { + use std::sync::atomic::{AtomicUsize, Ordering as AOrdering}; + use std::sync::Arc; + let count = Arc::new(AtomicUsize::new(0)); + let count2 = count.clone(); + let mut c = UnboundCache::builder() + .on_evict(move |_k: &u32, _v: &u32| { + count2.fetch_add(1, AOrdering::Relaxed); + }) + .build(); + c.cache_set(1, 10); + c.cache_set(2, 20); + c.cache_clear(); + assert_eq!(c.cache_size(), 0); + assert_eq!( + count.load(AOrdering::Relaxed), + 0, + "cache_clear must not fire on_evict" + ); + } + #[test] fn test_diagnostics_and_traits() { let mut cache = UnboundCache::builder().capacity(10).build(); @@ -579,4 +650,99 @@ mod tests { let try_built = builder.try_build(); assert!(try_built.is_ok()); } + + #[test] + fn cache_remove_entry_basic() { + let mut c = UnboundCache::new(); + c.cache_set(1u32, 100u32); + + // Returns None when key absent. + assert_eq!(c.cache_remove_entry(&999u32), None); + + // Returns stored key and value. + let removed = c.cache_remove_entry(&1u32); + assert_eq!(removed, Some((1u32, 100u32))); + + // Entry is gone. + assert_eq!(c.cache_get(&1u32), None); + } + + #[test] + fn cache_remove_entry_fires_on_evict() { + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::Arc; + let count = Arc::new(AtomicU32::new(0)); + let count2 = count.clone(); + let mut c = UnboundCache::builder() + .on_evict(move |_, _| { + count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + c.cache_set(1u32, 10u32); + c.cache_remove_entry(&1u32); + assert_eq!(count.load(Ordering::Relaxed), 1); + + // No fire for absent key. + c.cache_remove_entry(&999u32); + assert_eq!(count.load(Ordering::Relaxed), 1); + } + + #[test] + fn cache_delete_uses_cache_remove_entry() { + let mut c = UnboundCache::::new(); + c.cache_set(1, 10); + assert!( + c.cache_delete(&1), + "cache_delete must return true for existing entry" + ); + assert!( + !c.cache_delete(&1), + "cache_delete must return false for absent entry" + ); + } + + #[test] + fn cache_remove_entry_returns_stored_key_not_lookup_key() { + // Verify the doc promise: cache_remove_entry returns the *stored* key, + // not the lookup key. Uses a key type where Hash+Eq only check `lower` + // so two instances can be "equal" but have different `original` fields. + use std::hash::{Hash, Hasher}; + #[derive(Clone, Debug)] + struct CaseKey { + lower: String, + original: String, + } + impl PartialEq for CaseKey { + fn eq(&self, other: &Self) -> bool { + self.lower == other.lower + } + } + impl Eq for CaseKey {} + impl Hash for CaseKey { + fn hash(&self, state: &mut H) { + self.lower.hash(state); + } + } + + let stored = CaseKey { + lower: "hello".to_string(), + original: "Hello".to_string(), + }; + let lookup = CaseKey { + lower: "hello".to_string(), + original: "HELLO".to_string(), + }; + + let mut c = UnboundCache::::new(); + c.cache_set(stored, 42); + + let (returned_key, returned_val) = + c.cache_remove_entry(&lookup).expect("key must be found"); + assert_eq!(returned_val, 42); + // The *stored* original casing must come back, not the lookup's casing. + assert_eq!( + returned_key.original, "Hello", + "cache_remove_entry must return the stored key instance" + ); + } } diff --git a/tests/cached.rs b/tests/cached.rs index 6d618857..1e4a9014 100644 --- a/tests/cached.rs +++ b/tests/cached.rs @@ -50,10 +50,6 @@ fn compile_fail_macro_arg_validation() { // ---- #[cached] ---- t.compile_fail("tests/ui/cached_self_method.rs"); t.compile_fail("tests/ui/cached_with_cached_flag_no_return.rs"); - t.compile_fail("tests/ui/cached_result_option_exclusive.rs"); - t.compile_fail("tests/ui/cached_result_no_return.rs"); - t.compile_fail("tests/ui/cached_result_no_inner_type.rs"); - t.compile_fail("tests/ui/cached_result_complex_return.rs"); t.compile_fail("tests/ui/cached_key_without_convert.rs"); t.compile_fail("tests/ui/cached_convert_without_key.rs"); t.compile_fail("tests/ui/cached_ty_without_create.rs"); @@ -70,17 +66,25 @@ fn compile_fail_macro_arg_validation() { 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_expires_cache_none_exclusive.rs"); + t.compile_fail("tests/ui/cached_cache_err_requires_result_return.rs"); + t.compile_fail("tests/ui/cached_cache_none_requires_option_return.rs"); + t.compile_fail("tests/ui/cached_cache_err_result_fallback_exclusive.rs"); + t.compile_fail("tests/ui/cached_cache_none_with_cached_flag_exclusive.rs"); + t.compile_fail("tests/ui/cached_size_max_size_exclusive.rs"); + t.compile_fail("tests/ui/cached_size_attr_deprecated.rs"); // ---- #[once] ---- t.compile_fail("tests/ui/once_self_method.rs"); 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_result_option_exclusive.rs"); t.compile_fail("tests/ui/once_expires_ttl_exclusive.rs"); t.compile_fail("tests/ui/once_expires_non_expires_type.rs"); t.compile_fail("tests/ui/once_expires_with_cached_flag_exclusive.rs"); - t.compile_fail("tests/ui/once_result_no_return.rs"); t.compile_fail("tests/ui/once_sync_writes_buckets_zero.rs"); + t.compile_fail("tests/ui/once_cache_err_requires_result_return.rs"); + t.compile_fail("tests/ui/once_cache_none_requires_option_return.rs"); + t.compile_fail("tests/ui/once_cache_none_with_cached_flag_exclusive.rs"); // ---- #[concurrent_cached] ---- t.compile_fail("tests/ui/concurrent_cached_self_method.rs"); @@ -90,14 +94,51 @@ fn compile_fail_macro_arg_validation() { t.compile_fail("tests/ui/concurrent_cached_complex_return.rs"); t.compile_fail("tests/ui/concurrent_cached_non_result_return.rs"); t.compile_fail("tests/ui/concurrent_cached_redis_create_conflict.rs"); + t.compile_fail("tests/ui/concurrent_cached_redis_disk_exclusive.rs"); 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_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"); - t.compile_fail("tests/ui/concurrent_cached_custom_ty_required.rs"); + t.compile_fail("tests/ui/concurrent_cached_result_attr_unsupported.rs"); + t.compile_fail("tests/ui/concurrent_cached_option_attr_unsupported.rs"); + t.compile_fail("tests/ui/concurrent_cached_sync_writes_attr_unsupported.rs"); t.compile_fail("tests/ui/concurrent_cached_custom_create_required.rs"); + t.compile_fail("tests/ui/concurrent_cached_shards_zero.rs"); + t.compile_fail("tests/ui/concurrent_cached_size_zero.rs"); + t.compile_fail("tests/ui/concurrent_cached_size_max_size_exclusive.rs"); + t.compile_fail("tests/ui/concurrent_cached_size_attr_deprecated.rs"); + t.compile_fail("tests/ui/concurrent_cached_ttl_zero.rs"); + t.compile_fail("tests/ui/concurrent_cached_shards_with_redis.rs"); + t.compile_fail("tests/ui/concurrent_cached_shards_with_disk.rs"); + t.compile_fail("tests/ui/concurrent_cached_size_with_redis.rs"); + t.compile_fail("tests/ui/concurrent_cached_size_with_disk.rs"); + t.compile_fail("tests/ui/concurrent_cached_size_with_redis_ty.rs"); + t.compile_fail("tests/ui/concurrent_cached_size_with_disk_ty.rs"); 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_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"); + t.compile_fail("tests/ui/concurrent_cached_expires_create_exclusive.rs"); + t.compile_fail("tests/ui/concurrent_cached_expires_refresh_exclusive.rs"); + t.compile_fail("tests/ui/concurrent_cached_expires_cache_none_exclusive.rs"); + t.compile_fail("tests/ui/concurrent_cached_expires_cache_err_exclusive.rs"); + t.compile_fail("tests/ui/concurrent_cached_result_fallback_expires_exclusive.rs"); + t.compile_fail("tests/ui/concurrent_cached_result_fallback_requires_ttl.rs"); + t.compile_fail("tests/ui/concurrent_cached_with_cached_flag_option.rs"); + t.compile_fail("tests/ui/concurrent_cached_option_with_redis.rs"); + t.compile_fail("tests/ui/concurrent_cached_cache_none_with_redis.rs"); + t.compile_fail("tests/ui/concurrent_cached_cache_err_result_fallback_exclusive.rs"); + t.compile_fail("tests/ui/concurrent_cached_result_fallback_with_cached_flag_exclusive.rs"); + t.compile_fail("tests/ui/cached_result_attr_removed.rs"); + t.compile_fail("tests/ui/cached_option_attr_removed.rs"); + t.compile_fail("tests/ui/once_result_attr_removed.rs"); + t.compile_fail("tests/ui/once_option_attr_removed.rs"); + t.compile_fail("tests/ui/concurrent_cached_option_attr_removed.rs"); + t.compile_fail("tests/ui/concurrent_cached_map_error_on_infallible.rs"); } #[cfg(feature = "proc_macro")] @@ -125,7 +166,7 @@ fn unsync_double_sync_writes(n: u32) -> u32 { } #[cfg(feature = "proc_macro")] -#[cached(result = true)] +#[cached] fn proc_cached_result(n: u32) -> Result, NoClone> { if n < 5 { Ok(vec![n]) @@ -152,7 +193,7 @@ fn test_proc_cached_result() { } #[cfg(feature = "proc_macro")] -#[cached(option = true)] +#[cached] fn proc_cached_option(n: u32) -> Option> { if n < 5 { Some(vec![n]) @@ -180,6 +221,106 @@ fn test_proc_cached_option() { } } +#[cfg(feature = "proc_macro")] +static CACHED_CACHE_ERR_TRUE_CALLS: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + +#[cfg(feature = "proc_macro")] +#[cached(cache_err = true)] +fn cached_cache_err_true(n: u32) -> Result { + CACHED_CACHE_ERR_TRUE_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Err(n) +} + +#[cfg(feature = "proc_macro")] +#[test] +fn test_cached_cache_err_true_caches_err() { + let before = CACHED_CACHE_ERR_TRUE_CALLS.load(std::sync::atomic::Ordering::SeqCst); + assert_eq!(cached_cache_err_true(7), Err(7)); + assert_eq!(cached_cache_err_true(7), Err(7)); + assert_eq!( + CACHED_CACHE_ERR_TRUE_CALLS.load(std::sync::atomic::Ordering::SeqCst), + before + 1 + ); +} + +#[cfg(feature = "proc_macro")] +static CACHED_CACHE_NONE_TRUE_CALLS: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + +#[cfg(feature = "proc_macro")] +#[cached(cache_none = true)] +fn cached_cache_none_true(n: u32) -> Option { + CACHED_CACHE_NONE_TRUE_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + None + } else { + Some(n) + } +} + +#[cfg(feature = "proc_macro")] +#[test] +fn test_cached_cache_none_true_caches_none() { + let before = CACHED_CACHE_NONE_TRUE_CALLS.load(std::sync::atomic::Ordering::SeqCst); + assert_eq!(cached_cache_none_true(0), None); + assert_eq!(cached_cache_none_true(0), None); + assert_eq!( + CACHED_CACHE_NONE_TRUE_CALLS.load(std::sync::atomic::Ordering::SeqCst), + before + 1 + ); +} + +#[cfg(feature = "proc_macro")] +static CONCURRENT_CACHED_CACHE_ERR_TRUE_CALLS: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + +#[cfg(feature = "proc_macro")] +#[cached::macros::concurrent_cached(cache_err = true)] +fn concurrent_cached_cache_err_true(n: u32) -> Result { + CONCURRENT_CACHED_CACHE_ERR_TRUE_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Err(n) +} + +#[cfg(feature = "proc_macro")] +#[test] +fn test_concurrent_cached_cache_err_true_caches_err() { + let before = CONCURRENT_CACHED_CACHE_ERR_TRUE_CALLS.load(std::sync::atomic::Ordering::SeqCst); + assert_eq!(concurrent_cached_cache_err_true(7), Err(7)); + assert_eq!(concurrent_cached_cache_err_true(7), Err(7)); + assert_eq!( + CONCURRENT_CACHED_CACHE_ERR_TRUE_CALLS.load(std::sync::atomic::Ordering::SeqCst), + before + 1 + ); +} + +#[cfg(feature = "proc_macro")] +static CONCURRENT_CACHED_CACHE_NONE_TRUE_CALLS: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + +#[cfg(feature = "proc_macro")] +#[cached::macros::concurrent_cached(cache_none = true)] +fn concurrent_cached_cache_none_true(n: u32) -> Option { + CONCURRENT_CACHED_CACHE_NONE_TRUE_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + None + } else { + Some(n) + } +} + +#[cfg(feature = "proc_macro")] +#[test] +fn test_concurrent_cached_cache_none_true_caches_none() { + let before = CONCURRENT_CACHED_CACHE_NONE_TRUE_CALLS.load(std::sync::atomic::Ordering::SeqCst); + assert_eq!(concurrent_cached_cache_none_true(0), None); + assert_eq!(concurrent_cached_cache_none_true(0), None); + assert_eq!( + CONCURRENT_CACHED_CACHE_NONE_TRUE_CALLS.load(std::sync::atomic::Ordering::SeqCst), + before + 1 + ); +} + #[cfg(feature = "proc_macro")] #[cached(with_cached_flag = true)] fn cached_return_flag(n: i32) -> cached::Return { @@ -205,7 +346,7 @@ fn test_cached_return_flag() { } #[cfg(feature = "proc_macro")] -#[cached(result = true, with_cached_flag = true)] +#[cached(with_cached_flag = true)] fn cached_return_flag_result(n: i32) -> Result, ()> { if n == 10 { return Err(()); @@ -235,7 +376,7 @@ fn test_cached_return_flag_result() { } #[cfg(feature = "proc_macro")] -#[cached(option = true, with_cached_flag = true)] +#[cached(with_cached_flag = true)] fn cached_return_flag_option(n: i32) -> Option> { if n == 10 { return None; @@ -267,7 +408,7 @@ fn test_cached_return_flag_option() { /// should only cache the _first_ `Ok` returned. /// all arguments are ignored for subsequent calls. #[cfg(feature = "proc_macro")] -#[once(result = true)] +#[once] fn only_cached_result_once(s: String, error: bool) -> std::result::Result, u32> { if error { Err(1) @@ -288,10 +429,33 @@ fn test_only_cached_result_once() { assert_eq!(a, b); } +#[cfg(feature = "proc_macro")] +static ONCE_CACHE_ERR_TRUE_CALLS: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + +#[cfg(feature = "proc_macro")] +#[once(cache_err = true)] +fn once_cache_err_true(code: u32) -> Result { + ONCE_CACHE_ERR_TRUE_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Err(code) +} + +#[cfg(feature = "proc_macro")] +#[test] +fn test_once_cache_err_true_caches_err() { + let before = ONCE_CACHE_ERR_TRUE_CALLS.load(std::sync::atomic::Ordering::SeqCst); + assert_eq!(once_cache_err_true(7), Err(7)); + assert_eq!(once_cache_err_true(9), Err(7)); + assert_eq!( + ONCE_CACHE_ERR_TRUE_CALLS.load(std::sync::atomic::Ordering::SeqCst), + before + 1 + ); +} + /// should only cache the _first_ `Some` returned . /// all arguments are ignored for subsequent calls #[cfg(feature = "proc_macro")] -#[once(option = true)] +#[once] fn only_cached_option_once(s: String, none: bool) -> Option> { if none { None @@ -313,7 +477,34 @@ fn test_only_cached_option_once() { } #[cfg(feature = "proc_macro")] -#[cached(size = 2)] +static ONCE_CACHE_NONE_TRUE_CALLS: std::sync::atomic::AtomicUsize = + std::sync::atomic::AtomicUsize::new(0); + +#[cfg(feature = "proc_macro")] +#[once(cache_none = true)] +fn once_cache_none_true(n: u32) -> Option { + ONCE_CACHE_NONE_TRUE_CALLS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + if n == 0 { + None + } else { + Some(n) + } +} + +#[cfg(feature = "proc_macro")] +#[test] +fn test_once_cache_none_true_caches_none() { + let before = ONCE_CACHE_NONE_TRUE_CALLS.load(std::sync::atomic::Ordering::SeqCst); + assert_eq!(once_cache_none_true(0), None); + assert_eq!(once_cache_none_true(1), None); + assert_eq!( + ONCE_CACHE_NONE_TRUE_CALLS.load(std::sync::atomic::Ordering::SeqCst), + before + 1 + ); +} + +#[cfg(feature = "proc_macro")] +#[cached(max_size = 2)] fn cached_smartstring(s: smartstring::alias::String) -> smartstring::alias::String { if s == "very stringy" { smartstring::alias::String::from("equal") @@ -352,7 +543,7 @@ fn test_cached_smartstring() { #[cfg(feature = "proc_macro")] #[cached( - size = 2, + max_size = 2, key = "smartstring::alias::String", convert = r#"{ smartstring::alias::String::from(s) }"# )] @@ -360,6 +551,90 @@ fn cached_smartstring_from_str(s: &str) -> bool { s == "true" } +// `max_size` is an alias for `size`: it must set the LRU bound identically. +#[cfg(feature = "proc_macro")] +#[cached(max_size = 2)] +fn cached_max_size_alias(n: u32) -> u32 { + n * 2 +} + +#[cfg(feature = "proc_macro")] +#[test] +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(); + // capacity reflects the `max_size = 2` bound, and the store never exceeds it + assert_eq!(cache.capacity(), 2); + assert_eq!(cache.cache_size(), 2); +} + +// Regression coverage for the deprecated `size` attribute spelling. `size` is a +// deprecated alias for `max_size`: it still compiles and sets the LRU bound +// identically, but emits a deprecation warning at the `size` token. The module +// carries `#[allow(deprecated)]` because the macro expands the deprecation marker +// as a *sibling const* of the function (module scope), not inside the fn body — +// so a function-level allow would not suppress it. +#[cfg(feature = "proc_macro")] +#[allow(deprecated)] +mod deprecated_size_alias { + use super::*; + use cached::macros::concurrent_cached; + use std::sync::atomic::{AtomicU64, Ordering}; + + #[cached(size = 2)] + fn cached_size_alias(n: u32) -> u32 { + n * 2 + } + + #[test] + fn deprecated_size_attr_still_sets_bound() { + assert_eq!(cached_size_alias(1), 2); + assert_eq!(cached_size_alias(2), 4); + assert_eq!(cached_size_alias(3), 6); // evicts the LRU entry + let cache = CACHED_SIZE_ALIAS.read(); + // capacity reflects the `size = 2` bound, identical to `max_size = 2` + assert_eq!(cache.capacity(), 2); + assert_eq!(cache.cache_size(), 2); + } + + static SIZE_ALIAS_CALLS: AtomicU64 = AtomicU64::new(0); + + #[concurrent_cached(size = 100)] + fn concurrent_size_alias(x: u64) -> u64 { + SIZE_ALIAS_CALLS.fetch_add(1, Ordering::Relaxed); + x * 2 + } + + #[test] + fn deprecated_size_attr_routes_to_sharded_lru() { + SIZE_ALIAS_CALLS.store(0, Ordering::Relaxed); + assert_eq!(concurrent_size_alias(10), 20); + assert_eq!(concurrent_size_alias(10), 20); // cached — no second call + assert_eq!(concurrent_size_alias(11), 22); // different key + assert_eq!(SIZE_ALIAS_CALLS.load(Ordering::Relaxed), 2); + } +} + +// The sync `Cached` trait exposes `remove_entry` / `delete` short aliases, matching +// the `ConcurrentCached` trait. They delegate to `cache_remove_entry` / `cache_delete`. +#[test] +fn sync_cached_remove_entry_and_delete_aliases() { + let mut cache: UnboundCache = UnboundCache::new(); + cache.cache_set("a".to_string(), 1); + cache.cache_set("b".to_string(), 2); + + // `remove_entry` returns the stored key and value, like `cache_remove_entry`. + assert_eq!(cache.remove_entry("a"), Some(("a".to_string(), 1))); + assert_eq!(cache.remove_entry("a"), None); // already removed + + // `delete` returns true when an entry was physically removed, false if absent. + assert!(cache.delete("b")); + assert!(!cache.delete("b")); + assert_eq!(cache.cache_size(), 0); +} + #[cfg(feature = "proc_macro")] #[test] fn test_cached_smartstring_from_str() { @@ -471,7 +746,7 @@ mod expires_macro_tests { Val { v: k, expired } } - #[cached(expires = true, size = 4, key = "u32", convert = "{ k }")] + #[cached(expires = true, max_size = 4, key = "u32", convert = "{ k }")] fn sm_cached_expires_lru(k: u32, expired: bool) -> Val { Val { v: k, expired } } @@ -604,7 +879,7 @@ mod time_store_tests { assert_eq!(1, slow_once_timestamp_after_body(2)); } - #[cached(size = 1, ttl = 1)] + #[cached(max_size = 1, ttl = 1)] fn proc_timed_sized_sleeper(n: u64) -> u64 { sleep(Duration::new(1, 0)); n @@ -673,7 +948,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(result = true, ttl = 1)] + #[once(ttl = 1)] fn only_cached_result_once_per_second( s: String, error: bool, @@ -699,7 +974,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(option = true, ttl = 1)] + #[once(ttl = 1)] fn only_cached_option_once_per_second(s: String, none: bool) -> Option> { if none { None @@ -825,7 +1100,7 @@ mod time_store_tests { } #[cached( - size = 2, + max_size = 2, ttl = 1, refresh = true, key = "String", @@ -865,7 +1140,7 @@ mod time_store_tests { } #[cached( - size = 2, + max_size = 2, ttl = 1, refresh = true, key = "String", @@ -906,7 +1181,12 @@ mod time_store_tests { } } - #[cached(size = 2, ttl = 1, key = "String", convert = r#"{ String::from(s) }"#)] + #[cached( + max_size = 2, + ttl = 1, + key = "String", + convert = r#"{ String::from(s) }"# + )] fn cached_timed_sized_prime(s: &str) -> bool { s == "true" } @@ -946,7 +1226,7 @@ mod time_store_tests { } } - #[cached::macros::cached(result = true, ttl = 1, result_fallback = true)] + #[cached::macros::cached(ttl = 1, result_fallback = true)] fn always_failing() -> Result { Err(()) } @@ -987,6 +1267,51 @@ mod time_store_tests { } } + // --- concurrent_cached result_fallback --- + + #[cfg(feature = "proc_macro")] + static CONCURRENT_RESULT_FALLBACK_SHOULD_SUCCEED: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(true); + + #[cfg(feature = "proc_macro")] + #[cached::macros::concurrent_cached(ttl = 1, result_fallback = true)] + fn concurrent_result_fallback_fn() -> Result { + if CONCURRENT_RESULT_FALLBACK_SHOULD_SUCCEED.load(std::sync::atomic::Ordering::SeqCst) { + Ok(42) + } else { + Err("backend down") + } + } + + #[cfg(feature = "proc_macro")] + #[test] + fn test_concurrent_cached_result_fallback() { + // Ensure any prior cached entry has expired before the test starts. + std::thread::sleep(Duration::from_millis(1100)); + + // No cached Ok yet; function returns Err → raw Err returned to caller. + CONCURRENT_RESULT_FALLBACK_SHOULD_SUCCEED.store(false, std::sync::atomic::Ordering::SeqCst); + assert!( + concurrent_result_fallback_fn().is_err(), + "no prior Ok → Err returned directly" + ); + + // Now succeed: Ok(42) is cached. + CONCURRENT_RESULT_FALLBACK_SHOULD_SUCCEED.store(true, std::sync::atomic::Ordering::SeqCst); + assert_eq!(concurrent_result_fallback_fn(), Ok(42)); + + // Wait for TTL to expire. + std::thread::sleep(Duration::from_millis(1500)); + + // Function returns Err again; fallback returns the stale Ok(42). + CONCURRENT_RESULT_FALLBACK_SHOULD_SUCCEED.store(false, std::sync::atomic::Ordering::SeqCst); + assert_eq!( + concurrent_result_fallback_fn(), + Ok(42), + "expired Ok entry should be returned via fallback" + ); + } + #[derive(Clone, Debug)] struct OnceExpiredValue { val: u32, @@ -1025,7 +1350,7 @@ mod time_store_tests { assert_eq!(r4.val, 4); } - #[once(result = true, expires = true)] + #[once(expires = true)] fn get_once_result_expired(val: u32, expired: bool) -> Result { Ok(OnceExpiredValue { val, expired }) } @@ -1052,7 +1377,7 @@ mod time_store_tests { assert_eq!(get_once_result_expired(5, false).unwrap().val, 4); } - #[once(option = true, expires = true)] + #[once(expires = true)] fn get_once_option_expired(val: u32, expired: bool) -> Option { Some(OnceExpiredValue { val, expired }) } @@ -1079,7 +1404,7 @@ mod time_store_tests { assert_eq!(get_once_option_expired(5, false).unwrap().val, 4); } - #[once(result = true, expires = true)] + #[once(expires = true)] fn get_once_result_expired_or_err( val: u32, expired: bool, @@ -1108,7 +1433,7 @@ mod time_store_tests { ); } - #[once(option = true, expires = true)] + #[once(expires = true)] fn get_once_option_expired_or_none( val: u32, expired: bool, @@ -1160,7 +1485,7 @@ mod time_store_tests { assert_eq!(vec!["b".to_string()], b); } - #[once(result = true, ttl = 1)] + #[once(ttl = 1)] async fn only_cached_result_once_per_second_a( s: String, error: bool, @@ -1191,7 +1516,7 @@ mod time_store_tests { assert_eq!(vec!["b".to_string()], b); } - #[once(option = true, ttl = 1)] + #[once(ttl = 1)] async fn only_cached_option_once_per_second_a( s: String, none: bool, @@ -1319,7 +1644,7 @@ mod time_store_tests { assert_eq!(r4.val, 4); } - #[once(result = true, expires = true)] + #[once(expires = true)] async fn get_once_result_expired_async( val: u32, expired: bool, @@ -1362,7 +1687,7 @@ mod time_store_tests { ); } - #[once(option = true, expires = true)] + #[once(expires = true)] async fn get_once_option_expired_async( val: u32, expired: bool, @@ -1492,7 +1817,7 @@ mod time_store_tests { assert_eq!(async_cached_expires_basic(1, false).await.val, 1); } - #[cached(expires = true, result = true, key = "u32", convert = "{ k }")] + #[cached(expires = true, key = "u32", convert = "{ k }")] async fn async_cached_expires_result( k: u32, expired: bool, @@ -1531,7 +1856,7 @@ mod time_store_tests { assert!(!r.expired); } - #[cached(expires = true, option = true, key = "u32", convert = "{ k }")] + #[cached(expires = true, key = "u32", convert = "{ k }")] async fn async_cached_expires_option( k: u32, expired: bool, @@ -1570,13 +1895,7 @@ mod time_store_tests { assert!(!r.expired); } - #[cached( - expires = true, - result = true, - result_fallback = true, - key = "u32", - convert = "{ k }" - )] + #[cached(expires = true, result_fallback = true, key = "u32", convert = "{ k }")] async fn async_cached_expires_result_fallback( k: u32, expired: bool, @@ -1657,7 +1976,7 @@ mod time_store_tests { assert_eq!(cached_expires_basic(1, false).val, 1); } - #[cached(expires = true, size = 4, key = "u32", convert = "{ k }")] + #[cached(expires = true, max_size = 4, key = "u32", convert = "{ k }")] fn cached_expires_lru(k: u32, expired: bool) -> CachedExpiresVal { CachedExpiresVal { val: k, expired } } @@ -1685,7 +2004,7 @@ mod time_store_tests { } } - #[cached(expires = true, result = true, key = "u32", convert = "{ k }")] + #[cached(expires = true, key = "u32", convert = "{ k }")] fn cached_expires_result(k: u32, expired: bool, err: bool) -> Result { if err { Err("forced error".to_string()) @@ -1715,7 +2034,7 @@ mod time_store_tests { } } - #[cached(expires = true, option = true, key = "u32", convert = "{ k }")] + #[cached(expires = true, key = "u32", convert = "{ k }")] fn cached_expires_option(k: u32, expired: bool, none: bool) -> Option { if none { None @@ -1745,13 +2064,7 @@ mod time_store_tests { } } - #[cached( - expires = true, - result = true, - result_fallback = true, - key = "u32", - convert = "{ k }" - )] + #[cached(expires = true, result_fallback = true, key = "u32", convert = "{ k }")] fn cached_expires_result_fallback( k: u32, expired: bool, @@ -1820,11 +2133,202 @@ mod time_store_tests { } } +#[cfg(feature = "time_stores")] +mod sharded_ttl_tests { + // Verify that `refresh_on_hit = true` actually extends entry lifetime. + #[test] + fn sharded_ttl_refresh_on_hit_extends_lifetime() { + use cached::time::Duration; + use cached::ConcurrentCached; + use cached::ShardedTtlCache; + + let cache: ShardedTtlCache = ShardedTtlCache::builder() + .ttl(Duration::from_millis(3_000)) + .shards(4) + .refresh_on_hit(true) + .try_build() + .expect("valid config"); + + let _ = ConcurrentCached::cache_set(&cache, 1u32, 42u32); + // Sleep 500ms; entry should still be well inside its 3s TTL. + std::thread::sleep(Duration::from_millis(500)); + assert_eq!( + ConcurrentCached::cache_get(&cache, &1u32).expect("infallible"), + Some(42), + "entry should still be alive before TTL expires" + ); + // Sleep another 1_500ms. This is past the original expiry, but inside the + // refreshed TTL window from the previous get (~1_500ms margin to refreshed expiry). + std::thread::sleep(Duration::from_millis(1_500)); + assert_eq!( + ConcurrentCached::cache_get(&cache, &1u32).expect("infallible"), + Some(42), + "entry should still be alive after TTL was refreshed on the previous get" + ); + // Sleep past the last refresh; entry should now be expired. + std::thread::sleep(Duration::from_millis(3_200)); + assert_eq!( + ConcurrentCached::cache_get(&cache, &1u32).expect("infallible"), + None, + "entry should be expired after TTL elapsed with no further refresh" + ); + } + + #[test] + fn sharded_ttl_stores_implement_cache_evict_trait() { + use cached::time::Duration; + use cached::{CacheEvict, ShardedLruTtlCache, ShardedTtlCache}; + + fn assert_cache_evict(cache: &mut C) -> usize { + cache.evict() + } + + let mut ttl: ShardedTtlCache = ShardedTtlCache::builder() + .ttl(Duration::from_secs(60)) + .try_build() + .expect("valid config"); + let mut lru_ttl: ShardedLruTtlCache = ShardedLruTtlCache::builder() + .max_size(16) + .ttl(Duration::from_secs(60)) + .try_build() + .expect("valid config"); + + assert_eq!(assert_cache_evict(&mut ttl), 0); + assert_eq!(assert_cache_evict(&mut lru_ttl), 0); + } + + #[test] + fn sharded_ttl_builders_accept_refresh_alias() { + use cached::time::Duration; + use cached::{ShardedLruTtlCache, ShardedTtlCache}; + + let ttl = ShardedTtlCache::::builder() + .ttl(Duration::from_secs(60)) + .refresh(true) + .try_build() + .expect("valid config"); + assert!(ttl.refresh_on_hit()); + + let lru_ttl = ShardedLruTtlCache::::builder() + .max_size(64) + .ttl(Duration::from_secs(60)) + .refresh(true) + .try_build() + .expect("valid config"); + assert!(lru_ttl.refresh_on_hit()); + } + + // The non-sharded TTL builders now expose `refresh_on_hit(..)` as the primary + // setter (matching the sharded builders) with `refresh(..)` retained as an alias. + #[test] + fn non_sharded_ttl_builders_accept_refresh_on_hit_and_refresh_alias() { + use cached::time::Duration; + use cached::{LruTtlCache, TtlCache}; + + // Primary `.refresh_on_hit(true)` setter. + let ttl = TtlCache::::builder() + .ttl(Duration::from_secs(60)) + .refresh_on_hit(true) + .try_build() + .expect("valid config"); + assert!(ttl.refresh_on_hit()); + + let lru_ttl = LruTtlCache::::builder() + .max_size(64) + .ttl(Duration::from_secs(60)) + .refresh_on_hit(true) + .try_build() + .expect("valid config"); + assert!(lru_ttl.refresh_on_hit()); + + // `.refresh(true)` alias sets the same flag. + let ttl_alias = TtlCache::::builder() + .ttl(Duration::from_secs(60)) + .refresh(true) + .try_build() + .expect("valid config"); + assert!(ttl_alias.refresh_on_hit()); + + let lru_ttl_alias = LruTtlCache::::builder() + .max_size(64) + .ttl(Duration::from_secs(60)) + .refresh(true) + .try_build() + .expect("valid config"); + assert!(lru_ttl_alias.refresh_on_hit()); + + // Both setters default to / can clear the flag. + let ttl_off = TtlCache::::builder() + .ttl(Duration::from_secs(60)) + .refresh_on_hit(false) + .try_build() + .expect("valid config"); + assert!(!ttl_off.refresh_on_hit()); + } + + #[test] + fn sharded_lru_ttl_evict_does_not_double_count_evictions_or_double_fire_on_evict() { + use cached::time::Duration; + use cached::ShardedLruTtlCache; + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::Arc; + + let fired = Arc::new(AtomicU32::new(0)); + let fired_clone = fired.clone(); + let cache = ShardedLruTtlCache::builder() + .max_size(16) + .ttl(Duration::from_millis(50)) + .on_evict(move |_k, _v| { + fired_clone.fetch_add(1, Ordering::Relaxed); + }) + .build(); + + cache.cache_set(1u32, 10u32).expect("infallible"); + cache.cache_set(2u32, 20u32).expect("infallible"); + cache.cache_set(3u32, 30u32).expect("infallible"); + + std::thread::sleep(Duration::from_millis(100)); + + // evict() should report 3, fire on_evict exactly 3 times (not 6), and + // metrics().evictions should return 3 (not 6). + assert_eq!(cache.evict(), 3); + assert_eq!(fired.load(Ordering::Relaxed), 3); + assert_eq!(cache.metrics().evictions, Some(3)); + } + + #[test] + fn sharded_ttl_evict_fires_on_evict_and_increments_evictions_counter() { + use cached::time::Duration; + use cached::ShardedTtlCache; + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::Arc; + + let fired = Arc::new(AtomicU32::new(0)); + let fired_clone = fired.clone(); + let cache = ShardedTtlCache::builder() + .ttl(Duration::from_millis(50)) + .on_evict(move |_k, _v| { + fired_clone.fetch_add(1, Ordering::Relaxed); + }) + .build(); + + cache.cache_set(1u32, 10u32).expect("infallible"); + cache.cache_set(2u32, 20u32).expect("infallible"); + cache.cache_set(3u32, 30u32).expect("infallible"); + + std::thread::sleep(Duration::from_millis(100)); + + assert_eq!(cache.evict(), 3); + assert_eq!(fired.load(Ordering::Relaxed), 3); + assert_eq!(cache.metrics().evictions, Some(3)); + } +} + #[cfg(all(feature = "async", feature = "proc_macro"))] mod async_tests { use super::*; - #[once(result = true)] + #[once] async fn only_cached_result_once_a( s: String, error: bool, @@ -1855,7 +2359,7 @@ mod async_tests { assert_eq!(a, b); } - #[once(option = true)] + #[once] async fn only_cached_option_once_a(s: String, none: bool) -> Option> { if none { None @@ -1902,197 +2406,1689 @@ mod async_tests { assert_eq!(a, b); assert_eq!("a", a); - // cache function was executed for a — inner string was consumed - assert_eq!("consumed", a_mutex.lock().await.to_string()); - // cache inner was NOT executed for b (cached after first call) - assert_eq!("b", b_mutex.lock().await.to_string()); + // cache function was executed for a — inner string was consumed + assert_eq!("consumed", a_mutex.lock().await.to_string()); + // cache inner was NOT executed for b (cached after first call) + assert_eq!("b", b_mutex.lock().await.to_string()); + } +} + +#[cfg(all(feature = "disk_store", feature = "proc_macro"))] +mod disk_tests { + use super::*; + use cached::macros::concurrent_cached; + use cached::DiskCache; + use thiserror::Error; + + #[derive(Error, Debug, PartialEq, Clone)] + enum TestError { + #[error("error with disk cache `{0}`")] + DiskError(String), + #[error("count `{0}`")] + Count(u32), + } + + #[concurrent_cached( + disk = true, + ttl = 1, + map_error = r##"|e| TestError::DiskError(format!("{:?}", e))"## + )] + fn cached_disk(n: u32) -> Result { + if n < 5 { + Ok(n) + } else { + Err(TestError::Count(n)) + } + } + + #[test] + fn test_cached_disk() { + assert_eq!(cached_disk(1), Ok(1)); + assert_eq!(cached_disk(1), Ok(1)); + assert_eq!(cached_disk(5), Err(TestError::Count(5))); + assert_eq!(cached_disk(6), Err(TestError::Count(6))); + } + + #[concurrent_cached( + disk = true, + ttl = 1, + with_cached_flag = true, + map_error = r##"|e| TestError::DiskError(format!("{:?}", e))"## + )] + fn cached_disk_cached_flag(n: u32) -> Result, TestError> { + if n < 5 { + Ok(cached::Return::new(n)) + } else { + Err(TestError::Count(n)) + } + } + + #[test] + fn test_cached_disk_cached_flag() { + assert!(!cached_disk_cached_flag(1).unwrap().was_cached); + assert!(cached_disk_cached_flag(1).unwrap().was_cached); + assert!(cached_disk_cached_flag(5).is_err()); + assert!(cached_disk_cached_flag(6).is_err()); + } + + #[concurrent_cached( + map_error = r##"|e| TestError::DiskError(format!("{:?}", e))"##, + ty = "cached::DiskCache", + create = r##" { DiskCache::new("cached_disk_cache_create").ttl(Duration::from_secs(1)).refresh(true).build().expect("error building disk cache") } "## + )] + fn cached_disk_cache_create(n: u32) -> Result { + if n < 5 { + Ok(n) + } else { + Err(TestError::Count(n)) + } + } + + #[test] + fn test_cached_disk_cache_create() { + assert_eq!(cached_disk_cache_create(1), Ok(1)); + assert_eq!(cached_disk_cache_create(1), Ok(1)); + assert_eq!(cached_disk_cache_create(5), Err(TestError::Count(5))); + assert_eq!(cached_disk_cache_create(6), Err(TestError::Count(6))); + } + + /// Just calling the macro with connection_config to test it doesn't break with an expected string + /// for connection_config. + /// There are no simple tests to test this here + #[concurrent_cached( + disk = true, + map_error = r##"|e| TestError::DiskError(format!("{:?}", e))"##, + connection_config = r##"sled::Config::new().flush_every_ms(None)"## + )] + fn cached_disk_connection_config(n: u32) -> Result { + if n < 5 { + Ok(n) + } else { + Err(TestError::Count(n)) + } + } + + /// Just calling the macro with sync_to_disk_on_cache_change to test it doesn't break with an expected value + /// There are no simple tests to test this here + #[concurrent_cached( + disk = true, + map_error = r##"|e| TestError::DiskError(format!("{:?}", e))"##, + sync_to_disk_on_cache_change = true + )] + fn cached_disk_sync_to_disk_on_cache_change(n: u32) -> Result { + if n < 5 { + Ok(n) + } else { + Err(TestError::Count(n)) + } + } + + #[cfg(all(feature = "async", feature = "proc_macro"))] + mod async_test { + use super::*; + + #[concurrent_cached( + disk = true, + map_error = r##"|e| TestError::DiskError(format!("{:?}", e))"## + )] + async fn async_cached_disk(n: u32) -> Result { + if n < 5 { + Ok(n) + } else { + Err(TestError::Count(n)) + } + } + + #[tokio::test] + async fn test_async_cached_disk() { + assert_eq!(async_cached_disk(1).await, Ok(1)); + assert_eq!(async_cached_disk(1).await, Ok(1)); + assert_eq!(async_cached_disk(5).await, Err(TestError::Count(5))); + assert_eq!(async_cached_disk(6).await, Err(TestError::Count(6))); + } + + // Regression: a value that is `Send` + `Serialize` + `Clone` but **not + // `Sync`** (it contains a `Cell`) must be usable with async disk + // caching. Before relaxing the async `DiskCache` impl (fn-pointer + // phantom + dropping the `V: Sync` bound) this failed to compile with + // "future cannot be sent between threads safely". + #[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] + struct NotSyncValue { + c: std::cell::Cell, + } + + #[concurrent_cached( + disk = true, + map_error = r##"|e| TestError::DiskError(format!("{:?}", e))"## + )] + async fn async_cached_disk_not_sync(n: u32) -> Result { + Ok(NotSyncValue { + c: std::cell::Cell::new(n), + }) + } + + #[tokio::test] + async fn test_async_cached_disk_not_sync_value() { + fn assert_send() {} + assert_send::(); // Send but (via Cell) !Sync + assert_eq!( + async_cached_disk_not_sync(7).await.unwrap(), + NotSyncValue { + c: std::cell::Cell::new(7) + } + ); + // second call is served from the disk cache + assert_eq!( + async_cached_disk_not_sync(7).await.unwrap(), + NotSyncValue { + c: std::cell::Cell::new(7) + } + ); + } + } +} + +// Regression (P2): a value that is `Send + Serialize + Clone` but **not +// `Sync`** (contains a `Cell`) must be usable with Redis-backed caches. Before +// the fn-pointer `PhantomData` on `RedisCache`/`AsyncRedisCache` and the +// dropped `V: Sync` bound on the async `AsyncRedisCache::new` / `impl +// ConcurrentCachedAsync` blocks, the sync path failed because the macro-emitted +// `LazyLock>>` static required `RedisCache: Sync` +// (which `PhantomData<(K, V)>` propagated from `V: Sync`), and the async path +// failed at the explicit `V: Send + Sync` bound. Compile-only — no server +// required. +// Plain (non-Result) return types for `#[concurrent_cached]` on the default +// in-memory sharded store. The macro generates code that calls `.unwrap()` on +// the infallible cache operations instead of wrapping in `Ok(...)`. +#[cfg(feature = "proc_macro")] +mod concurrent_cached_plain_return { + use cached::macros::concurrent_cached; + use std::collections::HashMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static PLAIN_CALLS: AtomicUsize = AtomicUsize::new(0); + static PLAIN_OPTION_CALLS: AtomicUsize = AtomicUsize::new(0); + static PLAIN_OPTION_NONE_CALLS: AtomicUsize = AtomicUsize::new(0); + static PLAIN_MAX_SIZE_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[concurrent_cached] + fn plain_double(x: u64) -> u64 { + PLAIN_CALLS.fetch_add(1, Ordering::Relaxed); + x * 2 + } + + #[concurrent_cached(max_size = 100)] + fn plain_double_lru(x: u64) -> u64 { + x * 2 + } + + // `max_size` is an alias for `size` on #[concurrent_cached] too: it must + // route to the sharded LRU store identically. + #[concurrent_cached(max_size = 100)] + fn plain_double_lru_max_size(x: u64) -> u64 { + PLAIN_MAX_SIZE_CALLS.fetch_add(1, Ordering::Relaxed); + x * 2 + } + + /// Default: Option skips None (smart-option). Only Some(T) is cached. + #[concurrent_cached] + fn plain_option(x: u64) -> Option { + PLAIN_OPTION_CALLS.fetch_add(1, Ordering::Relaxed); + if x == 0 { + None + } else { + Some(x * 2) + } + } + + /// Opt-in: cache_none = true stores None in the cache too. + #[concurrent_cached(cache_none = true)] + fn plain_option_cache_none(x: u64) -> Option { + PLAIN_OPTION_NONE_CALLS.fetch_add(1, Ordering::Relaxed); + if x == 0 { + None + } else { + Some(x * 2) + } + } + + #[concurrent_cached] + fn plain_hash_map(x: u64) -> HashMap { + let mut map = HashMap::new(); + map.insert(x, x * 2); + map + } + + #[test] + fn plain_return_compiles_and_caches() { + PLAIN_CALLS.store(0, Ordering::Relaxed); + assert_eq!(plain_double(21), 42); + assert_eq!(plain_double(21), 42); // cached — no second call + assert_eq!(plain_double(22), 44); // different key + assert_eq!(PLAIN_CALLS.load(Ordering::Relaxed), 2); + } + + #[test] + fn plain_return_lru_compiles_and_caches() { + assert_eq!(plain_double_lru(10), 20); + assert_eq!(plain_double_lru(10), 20); // cached + } + + #[test] + fn plain_return_max_size_alias_compiles_and_caches() { + PLAIN_MAX_SIZE_CALLS.store(0, Ordering::Relaxed); + assert_eq!(plain_double_lru_max_size(10), 20); + assert_eq!(plain_double_lru_max_size(10), 20); // cached — no second call + assert_eq!(plain_double_lru_max_size(11), 22); // different key + assert_eq!(PLAIN_MAX_SIZE_CALLS.load(Ordering::Relaxed), 2); + } + + #[test] + fn plain_option_return_skips_none_caches_some() { + PLAIN_OPTION_CALLS.store(0, Ordering::Relaxed); + // None is NOT cached — function runs again each time + assert_eq!(plain_option(0), None); + assert_eq!(plain_option(0), None); + assert_eq!( + PLAIN_OPTION_CALLS.load(Ordering::Relaxed), + 2, + "None should NOT be cached by default" + ); + // Some(T) IS cached + assert_eq!(plain_option(5), Some(10)); + assert_eq!(plain_option(5), Some(10)); + assert_eq!( + PLAIN_OPTION_CALLS.load(Ordering::Relaxed), + 3, + "Some should be cached" + ); + } + + #[test] + fn plain_option_cache_none_caches_none_and_some() { + PLAIN_OPTION_NONE_CALLS.store(0, Ordering::Relaxed); + // With cache_none = true, None IS cached + assert_eq!(plain_option_cache_none(0), None); + assert_eq!(plain_option_cache_none(0), None); + assert_eq!( + PLAIN_OPTION_NONE_CALLS.load(Ordering::Relaxed), + 1, + "None should be cached with cache_none = true" + ); + // Some(T) is also cached + assert_eq!(plain_option_cache_none(5), Some(10)); + assert_eq!(plain_option_cache_none(5), Some(10)); + assert_eq!( + PLAIN_OPTION_NONE_CALLS.load(Ordering::Relaxed), + 2, + "Some should be cached" + ); + } + + #[test] + fn plain_generic_return_is_not_misclassified_as_result() { + assert_eq!(plain_hash_map(7).get(&7), Some(&14)); + assert_eq!(plain_hash_map(7).get(&7), Some(&14)); + } +} + +#[cfg(all(feature = "proc_macro", feature = "time_stores"))] +mod concurrent_cached_plain_return_ttl { + use cached::macros::concurrent_cached; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static TTL_PLAIN_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[concurrent_cached(ttl = 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)] + fn plain_double_lru_ttl(x: u64) -> u64 { + x * 2 + } + + #[test] + fn plain_ttl_compiles_and_caches() { + TTL_PLAIN_CALLS.store(0, Ordering::Relaxed); + assert_eq!(plain_double_ttl(21), 42); + assert_eq!(plain_double_ttl(21), 42); // cached + assert_eq!(TTL_PLAIN_CALLS.load(Ordering::Relaxed), 1); + } + + #[test] + fn plain_lru_ttl_compiles_and_caches() { + assert_eq!(plain_double_lru_ttl(21), 42); + assert_eq!(plain_double_lru_ttl(21), 42); // cached + } +} + +// Sharded in-memory default for `#[concurrent_cached]`. No `ty`, `create`, +// `map_error`, `redis`, or `disk` — the macro defaults to `ShardedCache`. +#[cfg(feature = "proc_macro")] +mod concurrent_cached_default_in_memory { + use cached::macros::concurrent_cached; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static SLOW_DOUBLE_CALLS: AtomicUsize = AtomicUsize::new(0); + static FALLIBLE_CALLS: AtomicUsize = AtomicUsize::new(0); + static CUSTOM_RESULT_CALLS: AtomicUsize = AtomicUsize::new(0); + static PLAIN_ALIAS_CALLS: AtomicUsize = AtomicUsize::new(0); + static BARE_RESULT_ALIAS_CALLS: AtomicUsize = AtomicUsize::new(0); + // Plain return type — no boilerplate required. + #[concurrent_cached] + fn slow_double(x: u64) -> u64 { + SLOW_DOUBLE_CALLS.fetch_add(1, Ordering::Relaxed); + x * 2 + } + + // Result: only Ok values are cached; Err is returned but not stored. + #[concurrent_cached] + fn slow_double_fallible(x: u64) -> Result { + FALLIBLE_CALLS.fetch_add(1, Ordering::Relaxed); + if x == 0 { + Err("zero is not cacheable".to_string()) + } else { + Ok(x * 2) + } + } + + // Type aliases are not resolved at macro expansion time. Only a last path segment + // of exactly `Result` is treated as a Result return; any alias — even one named + // `MyResult` — is treated as a plain value and its `Err` variant is cached. + type MyResult = Result; + + #[concurrent_cached] + fn slow_double_custom_result(x: u64) -> MyResult { + CUSTOM_RESULT_CALLS.fetch_add(1, Ordering::Relaxed); + if x == 0 { + Err("zero is cached (plain alias)".to_string()) + } else { + Ok(x * 2) + } + } + + // Same: `Api` does not resolve to `Result` at macro time, so Err is cached. + type Api = Result; + + #[concurrent_cached] + fn slow_double_plain_alias(x: u64) -> Api { + PLAIN_ALIAS_CALLS.fetch_add(1, Ordering::Relaxed); + if x == 0 { + Err("zero is cached for this plain alias".to_string()) + } else { + Ok(x * 2) + } + } + + // `BareResult` has no type arguments, so it cannot match `Result` at + // macro-expansion time; it is treated as a plain value (Err is cached). + type BareResult = Result; + + #[concurrent_cached] + fn slow_double_bare_result_alias(x: u64) -> BareResult { + BARE_RESULT_ALIAS_CALLS.fetch_add(1, Ordering::Relaxed); + if x == 0 { + Err("zero is cached for this bare alias".to_string()) + } else { + Ok(x * 2) + } + } + + #[test] + fn bare_default_compiles_and_caches() { + SLOW_DOUBLE_CALLS.store(0, Ordering::Relaxed); + assert_eq!(slow_double(21), 42); + assert_eq!(slow_double(21), 42); // cached — no second call + assert_eq!(slow_double(22), 44); // different key + assert_eq!(SLOW_DOUBLE_CALLS.load(Ordering::Relaxed), 2); // 21 and 22, not a third for 21 + } + + #[test] + fn result_return_skips_caching_on_err() { + FALLIBLE_CALLS.store(0, Ordering::Relaxed); + // Err is not cached; each call to the 0 key hits the function body. + assert!(slow_double_fallible(0).is_err()); + assert!(slow_double_fallible(0).is_err()); + assert_eq!(FALLIBLE_CALLS.load(Ordering::Relaxed), 2); + // Ok is cached normally. + assert_eq!(slow_double_fallible(5), Ok(10)); + assert_eq!(slow_double_fallible(5), Ok(10)); // cached + assert_eq!(FALLIBLE_CALLS.load(Ordering::Relaxed), 3); + } + + #[test] + fn custom_result_alias_treated_as_plain_return() { + CUSTOM_RESULT_CALLS.store(0, Ordering::Relaxed); + // MyResult is a type alias; the macro sees `MyResult`, not `Result`, + // so Err is cached just like any other plain value. + assert!(slow_double_custom_result(0).is_err()); + assert!(slow_double_custom_result(0).is_err()); // served from cache + assert_eq!(CUSTOM_RESULT_CALLS.load(Ordering::Relaxed), 1); + + assert_eq!(slow_double_custom_result(21), Ok(42)); + assert_eq!(slow_double_custom_result(21), Ok(42)); // cached + assert_eq!(CUSTOM_RESULT_CALLS.load(Ordering::Relaxed), 2); + } + + #[test] + fn result_alias_without_result_suffix_is_treated_as_plain_return() { + PLAIN_ALIAS_CALLS.store(0, Ordering::Relaxed); + assert!(slow_double_plain_alias(0).is_err()); + assert!(slow_double_plain_alias(0).is_err()); + assert_eq!(PLAIN_ALIAS_CALLS.load(Ordering::Relaxed), 1); + + assert_eq!(slow_double_plain_alias(21), Ok(42)); + assert_eq!(slow_double_plain_alias(21), Ok(42)); + assert_eq!(PLAIN_ALIAS_CALLS.load(Ordering::Relaxed), 2); + } + + #[test] + fn bare_result_alias_without_type_args_is_treated_as_plain_return() { + BARE_RESULT_ALIAS_CALLS.store(0, Ordering::Relaxed); + // `BareResult` has ident `BareResult`, not `Result`, so the exact-ident check + // rejects it and it is treated as a plain value — `Err` is cached. + assert!(slow_double_bare_result_alias(0).is_err()); + assert!(slow_double_bare_result_alias(0).is_err()); + assert_eq!(BARE_RESULT_ALIAS_CALLS.load(Ordering::Relaxed), 1); + + assert_eq!(slow_double_bare_result_alias(21), Ok(42)); + assert_eq!(slow_double_bare_result_alias(21), Ok(42)); + assert_eq!(BARE_RESULT_ALIAS_CALLS.load(Ordering::Relaxed), 2); + } +} + +#[cfg(all(feature = "proc_macro", feature = "async_core"))] +mod concurrent_cached_default_with_both_traits_in_scope { + use cached::macros::concurrent_cached; + #[allow(unused_imports)] + use cached::{ConcurrentCached, ConcurrentCachedAsync}; + + #[concurrent_cached] + fn double_with_both_traits_in_scope(x: u64) -> u64 { + x * 2 + } + + #[test] + fn sync_macro_uses_ufcs_to_avoid_trait_method_ambiguity() { + assert_eq!(double_with_both_traits_in_scope(21), 42); + } +} + +// `size = N` selects `ShardedLruCache`. +#[cfg(feature = "proc_macro")] +mod concurrent_cached_default_with_max_size { + use cached::macros::concurrent_cached; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static SLOW_TRIPLE_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[concurrent_cached(max_size = 100)] + fn slow_triple(x: u64) -> u64 { + SLOW_TRIPLE_CALLS.fetch_add(1, Ordering::Relaxed); + x * 3 + } + + #[test] + fn size_attr_compiles_and_caches() { + SLOW_TRIPLE_CALLS.store(0, Ordering::Relaxed); + assert_eq!(slow_triple(21), 63); + assert_eq!(slow_triple(21), 63); // cached + assert_eq!(SLOW_TRIPLE_CALLS.load(Ordering::Relaxed), 1); + } +} + +// `ttl = T` selects `ShardedTtlCache`. +#[cfg(all(feature = "proc_macro", feature = "time_stores"))] +mod concurrent_cached_default_with_ttl { + use cached::macros::concurrent_cached; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static SLOW_QUAD_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[concurrent_cached(ttl = 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)] + fn slow_quad_refresh(x: u64) -> u64 { + x * 4 + } + + #[test] + fn ttl_attr_compiles_and_caches() { + SLOW_QUAD_CALLS.store(0, Ordering::Relaxed); + assert_eq!(slow_quad(21), 84); + assert_eq!(slow_quad(21), 84); // cached + assert_eq!(SLOW_QUAD_CALLS.load(Ordering::Relaxed), 1); + } + + #[test] + fn ttl_refresh_attr_wired() { + // Verify the store has refresh enabled; if `refresh` were silently dropped + // `refresh_on_hit()` would return false. + assert_eq!(slow_quad_refresh(5), 20); + assert!(SLOW_QUAD_REFRESH.refresh_on_hit()); + } +} + +// `size = N, ttl = T` selects `ShardedLruTtlCache`. +#[cfg(all(feature = "proc_macro", feature = "time_stores"))] +mod concurrent_cached_default_with_max_size_and_ttl { + use cached::macros::concurrent_cached; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static SLOW_QUINT_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[concurrent_cached(max_size = 50, ttl = 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)] + fn slow_quint_refresh(x: u64) -> u64 { + x * 5 + } + + #[test] + fn size_and_ttl_compiles_and_caches() { + SLOW_QUINT_CALLS.store(0, Ordering::Relaxed); + assert_eq!(slow_quint(21), 105); + assert_eq!(slow_quint(21), 105); // cached + assert_eq!(SLOW_QUINT_CALLS.load(Ordering::Relaxed), 1); + } + + #[test] + fn size_and_ttl_refresh_attr_wired() { + assert_eq!(slow_quint_refresh(5), 25); + assert!(SLOW_QUINT_REFRESH.refresh_on_hit()); + } +} + +// `shards = N` propagates through every default variant. +#[cfg(feature = "proc_macro")] +mod concurrent_cached_default_with_shards { + use cached::macros::concurrent_cached; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static DOUBLE_WITH_SHARDS_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[concurrent_cached(shards = 32)] + fn double_with_shards(x: u64) -> u64 { + DOUBLE_WITH_SHARDS_CALLS.fetch_add(1, Ordering::Relaxed); + x * 2 + } + + #[concurrent_cached(max_size = 100, shards = 32)] + fn double_with_max_size_shards(x: u64) -> u64 { + x * 2 + } + + #[test] + fn shards_attr_compiles_and_caches() { + DOUBLE_WITH_SHARDS_CALLS.store(0, Ordering::Relaxed); + assert_eq!(double_with_shards(21), 42); + assert_eq!(double_with_shards(21), 42); // cached + assert_eq!(DOUBLE_WITH_SHARDS_CALLS.load(Ordering::Relaxed), 1); + assert_eq!(double_with_max_size_shards(21), 42); + } + + #[test] + fn shards_attr_produces_correct_shard_count() { + // `shards = 32` must produce a cache with exactly 32 shards (32 is already a power of 2). + assert_eq!(DOUBLE_WITH_SHARDS.shards(), 32); + assert_eq!(DOUBLE_WITH_MAX_SIZE_SHARDS.shards(), 32); + } +} + +// `ttl = T, shards = N` selects `ShardedTtlCache::with_ttl_and_shards`. +#[cfg(all(feature = "proc_macro", feature = "time_stores"))] +mod concurrent_cached_default_with_ttl_and_shards { + use cached::macros::concurrent_cached; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static TTL_SHARDS_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[concurrent_cached(ttl = 60, shards = 16)] + fn ttl_shards_double(x: u64) -> u64 { + TTL_SHARDS_CALLS.fetch_add(1, Ordering::Relaxed); + x * 2 + } + + #[test] + fn ttl_shards_compiles_and_caches() { + TTL_SHARDS_CALLS.store(0, Ordering::Relaxed); + assert_eq!(ttl_shards_double(21), 42); + assert_eq!(ttl_shards_double(21), 42); // cached + assert_eq!(TTL_SHARDS_CALLS.load(Ordering::Relaxed), 1); + } +} + +// `size = N, ttl = T, shards = S` selects `ShardedLruTtlCache::with_max_size_and_ttl_and_shards`. +#[cfg(all(feature = "proc_macro", feature = "time_stores"))] +mod concurrent_cached_default_with_max_size_and_ttl_and_shards { + use cached::macros::concurrent_cached; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static SIZE_TTL_SHARDS_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[concurrent_cached(max_size = 100, ttl = 60, shards = 16)] + fn size_ttl_shards_double(x: u64) -> u64 { + SIZE_TTL_SHARDS_CALLS.fetch_add(1, Ordering::Relaxed); + x * 2 + } + + #[test] + fn size_ttl_shards_compiles_and_caches() { + SIZE_TTL_SHARDS_CALLS.store(0, Ordering::Relaxed); + assert_eq!(size_ttl_shards_double(21), 42); + assert_eq!(size_ttl_shards_double(21), 42); // cached + assert_eq!(SIZE_TTL_SHARDS_CALLS.load(Ordering::Relaxed), 1); + } +} + +// `result_fallback = true` on the default sharded ttl path: last-known-good Ok +// value is returned when the function subsequently returns Err (after TTL expiry). +// The stale value is held in the primary cache slot (via ConcurrentCloneCached) +// and re-cached with a fresh TTL window — no separate _FALLBACK store. +#[cfg(all(feature = "proc_macro", feature = "time_stores"))] +mod concurrent_cached_result_fallback { + use cached::macros::concurrent_cached; + use cached::time::Duration; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::thread::sleep; + + static FAIL: AtomicBool = AtomicBool::new(false); + + #[concurrent_cached(ttl = 1, result_fallback = true)] + fn maybe_double(x: u32) -> Result { + if FAIL.load(Ordering::Relaxed) { + Err("injected failure".to_string()) + } else { + Ok(x * 2) + } + } + + #[test] + fn result_fallback_returns_stale_ok_after_ttl_expiry() { + FAIL.store(false, Ordering::Relaxed); + // Populate the TTL cache. + assert_eq!(maybe_double(1), Ok(2)); + // Make the function always fail from here. + FAIL.store(true, Ordering::Relaxed); + // Within TTL: served from main cache; function body not called. + assert_eq!(maybe_double(1), Ok(2)); + // Wait for TTL to expire. + sleep(Duration::from_millis(1100)); + // After TTL: function returns Err; stale value is returned from the + // primary cache slot (ConcurrentCloneCached) and re-cached. + assert_eq!(maybe_double(1), Ok(2)); + // Key with no prior success: Err is propagated. + assert_eq!(maybe_double(99), Err("injected failure".to_string())); + FAIL.store(false, Ordering::Relaxed); + } + + // Metric check: the expired→Err→stale path counts a miss but no eviction. + // 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)] + fn maybe_triple(x: u32) -> Result { + if FAIL_METRIC.load(Ordering::Relaxed) { + Err("metric test failure".to_string()) + } else { + Ok(x * 3) + } + } + + #[test] + fn result_fallback_expired_err_path_counts_miss_no_eviction() { + FAIL_METRIC.store(false, Ordering::Relaxed); + // Prime: miss + cache_set. + assert_eq!(maybe_triple(7), Ok(21)); + // Within TTL: hit. + assert_eq!(maybe_triple(7), Ok(21)); + // Wait for TTL to expire. + sleep(Duration::from_millis(1100)); + // Expired + Err: cache_get_with_expiry_status returns (Some(21), true) → + // misses++; the expired entry is NOT evicted; stale Ok(21) is returned. + FAIL_METRIC.store(true, Ordering::Relaxed); + assert_eq!(maybe_triple(7), Ok(21)); + // LazyLock is initialized on first call; deref to access store. + let m = MAYBE_TRIPLE.metrics(); + // miss for initial absent lookup + miss for expired-entry lookup = 2 + assert_eq!(m.misses, Some(2), "expected 2 misses (absent + expired)"); + // within-TTL hit = 1 + assert_eq!(m.hits, Some(1), "expected 1 hit (within-TTL)"); + // the expired entry is held in place — no eviction on the fallback path + assert_eq!( + m.evictions, + Some(0), + "no eviction must occur on expired→Err→stale path" + ); + FAIL_METRIC.store(false, Ordering::Relaxed); + } + + // Non-Copy key: previously a use-after-move bug caused compile failure when + // the key type was not Copy. + static FAIL_STR: AtomicBool = AtomicBool::new(false); + + #[concurrent_cached( + ttl = 1, + result_fallback = true, + key = "String", + convert = r#"{ x.to_string() }"# + )] + fn maybe_echo(x: &str) -> Result { + if FAIL_STR.load(Ordering::Relaxed) { + Err("injected failure".to_string()) + } else { + Ok(x.to_uppercase()) + } + } + + #[test] + fn result_fallback_non_copy_key_compiles_and_works() { + FAIL_STR.store(false, Ordering::Relaxed); + assert_eq!(maybe_echo("hello"), Ok("HELLO".to_string())); + FAIL_STR.store(true, Ordering::Relaxed); + sleep(Duration::from_millis(1100)); + assert_eq!(maybe_echo("hello"), Ok("HELLO".to_string())); + assert_eq!(maybe_echo("unknown"), Err("injected failure".to_string())); + FAIL_STR.store(false, Ordering::Relaxed); + } + + // prime_cache must NOT use the stale-fallback path — it unconditionally reruns the + // 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)] + fn prime_fallback_fn(x: u32) -> Result { + if FAIL_PRIME.load(Ordering::Relaxed) { + Err("prime failure".to_string()) + } else { + Ok(x * 2) + } + } + + #[test] + fn result_fallback_prime_cache_skips_stale_fallback() { + FAIL_PRIME.store(false, Ordering::Relaxed); + // Populate cache with Ok. + assert_eq!(prime_fallback_fn(10), Ok(20)); + // Wait for TTL to expire. + sleep(Duration::from_millis(1100)); + // prime_cache runs the function directly with no stale-fallback substitution. + // The raw Err must be returned, not the stale Ok. + FAIL_PRIME.store(true, Ordering::Relaxed); + assert_eq!( + prime_fallback_fn_prime_cache(10), + Err("prime failure".to_string()), + "prime_cache must not substitute stale Ok for Err" + ); + // The regular path (result_fallback) still serves the stale Ok because + // prime on Err does not overwrite the cache entry. + assert_eq!(prime_fallback_fn(10), Ok(20)); + FAIL_PRIME.store(false, Ordering::Relaxed); + } +} + +// `result_fallback = true` with size+ttl selects ShardedLruTtlCache; verify the stale-ok +// path works identically on the LRU-TTL store. +#[cfg(all(feature = "proc_macro", feature = "time_stores"))] +mod concurrent_cached_result_fallback_lru_ttl { + use cached::macros::concurrent_cached; + use cached::time::Duration; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::thread::sleep; + + static FAIL_LRU: AtomicBool = AtomicBool::new(false); + + #[concurrent_cached(ttl = 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()) + } else { + Ok(x * 2) + } + } + + #[test] + fn result_fallback_lru_ttl_returns_stale_ok_after_expiry() { + FAIL_LRU.store(false, Ordering::Relaxed); + // Populate via ShardedLruTtlCache. + assert_eq!(lru_ttl_maybe_double(5), Ok(10)); + // Within TTL: served from cache. + assert_eq!(lru_ttl_maybe_double(5), Ok(10)); + // Wait for TTL to expire. + sleep(Duration::from_millis(1100)); + // Expired + Err: stale Ok must be returned, entry held in place. + FAIL_LRU.store(true, Ordering::Relaxed); + assert_eq!(lru_ttl_maybe_double(5), Ok(10)); + // Metrics before any new-key calls: 2 misses (initial absent + expired), + // 1 hit (within-TTL), 0 evictions. + let m = LRU_TTL_MAYBE_DOUBLE.metrics(); + assert_eq!(m.misses, Some(2), "expected 2 misses (absent + expired)"); + assert_eq!(m.hits, Some(1), "expected 1 hit (within-TTL)"); + assert_eq!( + m.evictions, + Some(0), + "no eviction must occur on expired→Err→stale path" + ); + // Key with no prior Ok: Err propagated. + assert_eq!(lru_ttl_maybe_double(99), Err("lru_ttl failure".to_string())); + FAIL_LRU.store(false, Ordering::Relaxed); + } +} + +// 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" +))] +mod concurrent_cached_result_fallback_async { + use cached::macros::concurrent_cached; + use cached::time::Duration; + use std::sync::atomic::{AtomicBool, Ordering}; + use tokio::time::sleep; + + static FAIL_ASYNC: AtomicBool = AtomicBool::new(false); + + #[concurrent_cached(ttl = 1, result_fallback = true)] + async fn maybe_double_async(x: u32) -> Result { + if FAIL_ASYNC.load(Ordering::Relaxed) { + Err("async failure".to_string()) + } else { + Ok(x * 2) + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn result_fallback_async_returns_stale_ok_after_ttl_expiry() { + FAIL_ASYNC.store(false, Ordering::Relaxed); + assert_eq!(maybe_double_async(5).await, Ok(10)); + FAIL_ASYNC.store(true, Ordering::Relaxed); + sleep(Duration::from_millis(1100)).await; + // After TTL expiry, fallback returns last Ok instead of propagating Err. + assert_eq!(maybe_double_async(5).await, Ok(10)); + // Key with no prior success: Err is propagated. + assert_eq!( + maybe_double_async(99).await, + Err("async failure".to_string()) + ); + FAIL_ASYNC.store(false, Ordering::Relaxed); + } +} + +// 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" +))] +mod concurrent_cached_result_fallback_async_non_copy_key { + use cached::macros::concurrent_cached; + use cached::time::Duration; + use std::sync::atomic::{AtomicBool, Ordering}; + use tokio::time::sleep; + + static FAIL_ASYNC_STR: AtomicBool = AtomicBool::new(false); + + #[concurrent_cached( + ttl = 1, + result_fallback = true, + key = "String", + convert = r#"{ x.to_string() }"# + )] + async fn maybe_echo_async(x: &str) -> Result { + if FAIL_ASYNC_STR.load(Ordering::Relaxed) { + Err("async failure".to_string()) + } else { + Ok(x.to_uppercase()) + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn result_fallback_async_non_copy_key_returns_stale_ok_after_ttl_expiry() { + FAIL_ASYNC_STR.store(false, Ordering::Relaxed); + assert_eq!(maybe_echo_async("hello").await, Ok("HELLO".to_string())); + FAIL_ASYNC_STR.store(true, Ordering::Relaxed); + sleep(Duration::from_millis(1100)).await; + // After TTL expiry with a non-Copy String key, fallback returns last Ok. + assert_eq!(maybe_echo_async("hello").await, Ok("HELLO".to_string())); + // Key with no prior success: Err is propagated. + assert_eq!( + maybe_echo_async("unknown").await, + Err("async failure".to_string()) + ); + FAIL_ASYNC_STR.store(false, Ordering::Relaxed); + } +} + +// `cache_err = true`: errors are cached — subsequent calls with the same key return +// the cached Err without re-invoking the function body. +#[cfg(feature = "proc_macro")] +mod concurrent_cached_cache_err { + use cached::macros::concurrent_cached; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static ERR_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[concurrent_cached(cache_err = true)] + fn err_double(x: u32) -> Result { + ERR_CALLS.fetch_add(1, Ordering::Relaxed); + Err(x) + } + + #[test] + fn cache_err_caches_error_result() { + ERR_CALLS.store(0, Ordering::Relaxed); + // First call: function executes and returns Err. + assert_eq!(err_double(7), Err(7)); + assert_eq!(ERR_CALLS.load(Ordering::Relaxed), 1); + // Second call with same key: served from cache, function not called again. + assert_eq!(err_double(7), Err(7)); + assert_eq!(ERR_CALLS.load(Ordering::Relaxed), 1); + // Different key: function executes again. + assert_eq!(err_double(8), Err(8)); + assert_eq!(ERR_CALLS.load(Ordering::Relaxed), 2); + } +} + +// Async path uses `OnceCell`. +#[cfg(all(feature = "proc_macro", feature = "async_tokio_rt_multi_thread"))] +mod concurrent_cached_default_async { + use cached::macros::concurrent_cached; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static SLOW_DOUBLE_ASYNC_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[concurrent_cached] + async fn slow_double_async(x: u64) -> u64 { + SLOW_DOUBLE_ASYNC_CALLS.fetch_add(1, Ordering::Relaxed); + x * 2 + } + + #[tokio::test] + async fn async_default_compiles_and_caches() { + SLOW_DOUBLE_ASYNC_CALLS.store(0, Ordering::Relaxed); + assert_eq!(slow_double_async(21).await, 42); + assert_eq!(slow_double_async(21).await, 42); // cached + assert_eq!(SLOW_DOUBLE_ASYNC_CALLS.load(Ordering::Relaxed), 1); + } +} + +// `with_cached_flag = true` on the sharded default path: `was_cached` is false on first +// call and true on subsequent hits. +#[cfg(feature = "proc_macro")] +mod concurrent_cached_default_with_cached_flag { + use cached::macros::concurrent_cached; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static FLAG_CALLS: AtomicUsize = AtomicUsize::new(0); + static PLAIN_FLAG_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[concurrent_cached(with_cached_flag = true)] + fn flagged_double(x: u64) -> Result, std::convert::Infallible> { + FLAG_CALLS.fetch_add(1, Ordering::Relaxed); + Ok(cached::Return::new(x * 2)) + } + + #[concurrent_cached(with_cached_flag = true)] + fn flagged_plain_double(x: u64) -> cached::Return { + PLAIN_FLAG_CALLS.fetch_add(1, Ordering::Relaxed); + cached::Return::new(x * 2) + } + + #[test] + fn with_cached_flag_reports_cache_state() { + FLAG_CALLS.store(0, Ordering::Relaxed); + let first = flagged_double(7).unwrap(); + assert_eq!(*first, 14); + assert!(!first.was_cached, "first call should not be cached"); + let second = flagged_double(7).unwrap(); + assert_eq!(*second, 14); + assert!(second.was_cached, "second call should be cached"); + assert_eq!(FLAG_CALLS.load(Ordering::Relaxed), 1); + } + + #[test] + fn plain_return_with_cached_flag_reports_cache_state() { + PLAIN_FLAG_CALLS.store(0, Ordering::Relaxed); + let first = flagged_plain_double(8); + assert_eq!(*first, 16); + assert!(!first.was_cached, "first call should not be cached"); + let second = flagged_plain_double(8); + assert_eq!(*second, 16); + assert!(second.was_cached, "second call should be cached"); + assert_eq!(PLAIN_FLAG_CALLS.load(Ordering::Relaxed), 1); + } +} + +// `option = true` on the sharded default path: None skips caching, Some(T) is cached. +#[cfg(feature = "proc_macro")] +mod concurrent_cached_option { + use cached::macros::concurrent_cached; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static OPT_CALLS: AtomicUsize = AtomicUsize::new(0); + static OPT_FLAG_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[concurrent_cached] + fn maybe_double(x: u64) -> Option { + OPT_CALLS.fetch_add(1, Ordering::Relaxed); + if x == 0 { + None + } else { + Some(x * 2) + } + } + + #[concurrent_cached(with_cached_flag = true)] + fn flagged_maybe_double(x: u64) -> Option> { + OPT_FLAG_CALLS.fetch_add(1, Ordering::Relaxed); + if x == 0 { + None + } else { + Some(cached::Return::new(x * 2)) + } + } + + #[test] + fn option_caches_some_not_none() { + OPT_CALLS.store(0, Ordering::Relaxed); + // None is not cached — subsequent calls still invoke the function. + assert_eq!(maybe_double(0), None); + assert_eq!(maybe_double(0), None); + assert_eq!( + OPT_CALLS.load(Ordering::Relaxed), + 2, + "None should not be cached" + ); + // Some(T) is cached — second call is a hit. + assert_eq!(maybe_double(3), Some(6)); + assert_eq!(maybe_double(3), Some(6)); + assert_eq!( + OPT_CALLS.load(Ordering::Relaxed), + 3, + "Some should be cached after first call" + ); + } + + #[test] + fn option_with_cached_flag_reports_cache_state() { + OPT_FLAG_CALLS.store(0, Ordering::Relaxed); + // None — not cached. + assert!(flagged_maybe_double(0).is_none()); + assert!(flagged_maybe_double(0).is_none()); + assert_eq!( + OPT_FLAG_CALLS.load(Ordering::Relaxed), + 2, + "None should not be cached" + ); + // Some — first call not cached, second is. + let first = flagged_maybe_double(5).expect("should return Some"); + assert_eq!(*first, 10); + assert!(!first.was_cached, "first Some call should not be cached"); + let second = flagged_maybe_double(5).expect("should return Some"); + assert_eq!(*second, 10); + assert!(second.was_cached, "second Some call should be cached"); + assert_eq!(OPT_FLAG_CALLS.load(Ordering::Relaxed), 3); + } +} + +// Async `option = true` on the sharded default path. +#[cfg(all(feature = "proc_macro", feature = "async_tokio_rt_multi_thread"))] +mod concurrent_cached_async_option { + use cached::macros::concurrent_cached; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static ASYNC_OPT_CALLS: AtomicUsize = AtomicUsize::new(0); + static ASYNC_OPT_FLAG_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[concurrent_cached] + async fn async_maybe_double(x: u64) -> Option { + ASYNC_OPT_CALLS.fetch_add(1, Ordering::Relaxed); + if x == 0 { + None + } else { + Some(x * 2) + } + } + + #[concurrent_cached(with_cached_flag = true)] + async fn async_flagged_maybe_double(x: u64) -> Option> { + ASYNC_OPT_FLAG_CALLS.fetch_add(1, Ordering::Relaxed); + if x == 0 { + None + } else { + Some(cached::Return::new(x * 2)) + } + } + + #[tokio::test] + async fn async_option_caches_some_not_none() { + ASYNC_OPT_CALLS.store(0, Ordering::Relaxed); + assert_eq!(async_maybe_double(0).await, None); + assert_eq!(async_maybe_double(0).await, None); + assert_eq!( + ASYNC_OPT_CALLS.load(Ordering::Relaxed), + 2, + "None should not be cached" + ); + assert_eq!(async_maybe_double(4).await, Some(8)); + assert_eq!(async_maybe_double(4).await, Some(8)); + assert_eq!( + ASYNC_OPT_CALLS.load(Ordering::Relaxed), + 3, + "Some should be cached after first call" + ); + } + + #[tokio::test] + async fn async_option_with_cached_flag_reports_cache_state() { + ASYNC_OPT_FLAG_CALLS.store(0, Ordering::Relaxed); + assert!(async_flagged_maybe_double(0).await.is_none()); + assert!(async_flagged_maybe_double(0).await.is_none()); + assert_eq!( + ASYNC_OPT_FLAG_CALLS.load(Ordering::Relaxed), + 2, + "None should not be cached" + ); + let first = async_flagged_maybe_double(6) + .await + .expect("should return Some"); + assert_eq!(*first, 12); + assert!(!first.was_cached, "first Some call should not be cached"); + let second = async_flagged_maybe_double(6) + .await + .expect("should return Some"); + assert_eq!(*second, 12); + assert!(second.was_cached, "second Some call should be cached"); + assert_eq!(ASYNC_OPT_FLAG_CALLS.load(Ordering::Relaxed), 3); + } +} + +// 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"))] +mod concurrent_cached_default_async_with_cached_flag { + use cached::macros::concurrent_cached; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static ASYNC_FLAG_CALLS: AtomicUsize = AtomicUsize::new(0); + static ASYNC_PLAIN_FLAG_CALLS: AtomicUsize = AtomicUsize::new(0); + + #[concurrent_cached(with_cached_flag = true)] + async fn async_flagged_double(x: u64) -> Result, std::convert::Infallible> { + ASYNC_FLAG_CALLS.fetch_add(1, Ordering::Relaxed); + Ok(cached::Return::new(x * 2)) + } + + #[concurrent_cached(with_cached_flag = true)] + async fn async_flagged_plain_double(x: u64) -> cached::Return { + ASYNC_PLAIN_FLAG_CALLS.fetch_add(1, Ordering::Relaxed); + cached::Return::new(x * 2) + } + + #[tokio::test] + async fn async_with_cached_flag_result_reports_cache_state() { + ASYNC_FLAG_CALLS.store(0, Ordering::Relaxed); + let first = async_flagged_double(7).await.unwrap(); + assert_eq!(*first, 14); + assert!(!first.was_cached, "first call should not be cached"); + let second = async_flagged_double(7).await.unwrap(); + assert_eq!(*second, 14); + assert!(second.was_cached, "second call should be cached"); + assert_eq!(ASYNC_FLAG_CALLS.load(Ordering::Relaxed), 1); + } + + #[tokio::test] + async fn async_plain_return_with_cached_flag_reports_cache_state() { + ASYNC_PLAIN_FLAG_CALLS.store(0, Ordering::Relaxed); + let first = async_flagged_plain_double(8).await; + assert_eq!(*first, 16); + assert!(!first.was_cached, "first call should not be cached"); + let second = async_flagged_plain_double(8).await; + assert_eq!(*second, 16); + assert!(second.was_cached, "second call should be cached"); + assert_eq!(ASYNC_PLAIN_FLAG_CALLS.load(Ordering::Relaxed), 1); + } +} + +// `Send + Sync` typecheck for the sharded stores (mirrors `redis_not_sync_typecheck`). +#[cfg(feature = "proc_macro")] +#[allow(dead_code)] +mod sharded_send_sync_typecheck { + fn _typecheck_sync() { + fn assert_send() {} + fn assert_sync() {} + assert_send::>(); + assert_sync::>(); + assert_send::>(); + assert_sync::>(); + } + + #[cfg(feature = "time_stores")] + fn _typecheck_sync_timed() { + fn assert_send() {} + fn assert_sync() {} + assert_send::>(); + assert_sync::>(); + assert_send::>(); + assert_sync::>(); + } +} + +#[test] +fn concurrent_cached_trait_short_aliases_work() { + use cached::{ConcurrentCached, ShardedCache}; + + let cache = ShardedCache::::new(); + 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()); +} + +// `cache_clear_with_on_evict` without a callback delegates to `clear()` and does NOT +// increment the evictions counter. This test guards against regressions where the counter +// 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}; + + // ShardedCache (unbounded) — no on_evict; evictions metric is not tracked (returns None) + let cache = ShardedCache::::new(); + ConcurrentCached::cache_set(&cache, 1, 10).expect("infallible ShardedCache set"); + ConcurrentCached::cache_set(&cache, 2, 20).expect("infallible ShardedCache 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) + assert_eq!(cache.metrics().evictions, None); + + // ShardedLruCache tracks evictions; no on_evict means the counter stays at zero + let lru = ShardedLruCache::::with_max_size(64); + ConcurrentCached::cache_set(&lru, 1, 10).expect("infallible ShardedLruCache set"); + ConcurrentCached::cache_set(&lru, 2, 20).expect("infallible ShardedLruCache set"); + lru.cache_clear_with_on_evict(); + assert_eq!( + lru.metrics().evictions, + Some(0), + "evictions should remain 0 when no on_evict callback is set" + ); + assert_eq!(lru.len(), 0); +} + +mod sharded_expiring_tests { + #[cfg(feature = "proc_macro")] + use cached::macros::concurrent_cached; + use cached::{ + CacheEvict, ConcurrentCached, Expires, ShardedExpiringCache, ShardedExpiringLruCache, + }; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::sync::Arc; + + #[derive(Clone, Debug)] + struct ExpiringItem { + val: u32, + expired: Arc, + } + + impl Expires for ExpiringItem { + fn is_expired(&self) -> bool { + self.expired.load(Ordering::Relaxed) + } + } + + #[test] + fn sharded_expiring_cache_basic_ops() { + let flag1 = Arc::new(AtomicBool::new(false)); + let flag2 = Arc::new(AtomicBool::new(true)); + + let cache = ShardedExpiringCache::::new(); + let _ = ConcurrentCached::cache_set( + &cache, + 1, + ExpiringItem { + val: 42, + expired: flag1.clone(), + }, + ); + let _ = ConcurrentCached::cache_set( + &cache, + 2, + ExpiringItem { + val: 99, + expired: flag2.clone(), + }, + ); + + assert_eq!( + ConcurrentCached::cache_get(&cache, &1) + .unwrap() + .map(|i| i.val), + Some(42) + ); + assert_eq!( + ConcurrentCached::cache_get(&cache, &2) + .unwrap() + .map(|i| i.val), + None + ); // expired + assert_eq!(cache.metrics().misses, Some(1)); + assert_eq!(cache.metrics().hits, Some(1)); + + let lru = ShardedExpiringLruCache::::with_max_size(64); + let _ = ConcurrentCached::cache_set( + &lru, + 1, + ExpiringItem { + val: 42, + expired: flag1.clone(), + }, + ); + let _ = ConcurrentCached::cache_set( + &lru, + 2, + ExpiringItem { + val: 99, + expired: flag2.clone(), + }, + ); + + assert_eq!( + ConcurrentCached::cache_get(&lru, &1) + .unwrap() + .map(|i| i.val), + Some(42) + ); + assert_eq!( + ConcurrentCached::cache_get(&lru, &2) + .unwrap() + .map(|i| i.val), + None + ); // expired + assert_eq!(lru.metrics().misses, Some(1)); + assert_eq!(lru.metrics().hits, Some(1)); + } + + #[test] + fn sharded_expiring_cache_evict() { + let flag = Arc::new(AtomicBool::new(true)); + let mut cache = ShardedExpiringCache::::new(); + let _ = ConcurrentCached::cache_set( + &cache, + 1, + ExpiringItem { + val: 42, + expired: flag.clone(), + }, + ); + let _ = ConcurrentCached::cache_set( + &cache, + 2, + ExpiringItem { + val: 99, + expired: flag.clone(), + }, + ); + + assert_eq!(cache.len(), 2); + let evicted = CacheEvict::evict(&mut cache); + assert_eq!(evicted, 2); + assert_eq!(cache.len(), 0); + + let mut lru = ShardedExpiringLruCache::::with_max_size(64); + let _ = ConcurrentCached::cache_set( + &lru, + 1, + ExpiringItem { + val: 42, + expired: flag.clone(), + }, + ); + let _ = ConcurrentCached::cache_set( + &lru, + 2, + ExpiringItem { + val: 99, + expired: flag.clone(), + }, + ); + + assert_eq!(lru.len(), 2); + let evicted_lru = CacheEvict::evict(&mut lru); + assert_eq!(evicted_lru, 2); + assert_eq!(lru.len(), 0); } -} -#[cfg(all(feature = "disk_store", feature = "proc_macro"))] -mod disk_tests { - use super::*; - use cached::macros::concurrent_cached; - use cached::DiskCache; - use thiserror::Error; + #[test] + fn sharded_expiring_evict_fires_on_evict_and_increments_evictions_counter() { + use std::sync::atomic::AtomicU32; - #[derive(Error, Debug, PartialEq, Clone)] - enum TestError { - #[error("error with disk cache `{0}`")] - DiskError(String), - #[error("count `{0}`")] - Count(u32), - } + let flag = Arc::new(AtomicBool::new(true)); - #[concurrent_cached( - disk = true, - ttl = 1, - map_error = r##"|e| TestError::DiskError(format!("{:?}", e))"## - )] - fn cached_disk(n: u32) -> Result { - if n < 5 { - Ok(n) - } else { - Err(TestError::Count(n)) - } + let fired = Arc::new(AtomicU32::new(0)); + let fired_clone = fired.clone(); + let cache = ShardedExpiringCache::::builder() + .on_evict(move |_k, _v| { + fired_clone.fetch_add(1, Ordering::Relaxed); + }) + .build(); + let _ = cache.cache_set( + 1, + ExpiringItem { + val: 10, + expired: flag.clone(), + }, + ); + let _ = cache.cache_set( + 2, + ExpiringItem { + val: 20, + expired: flag.clone(), + }, + ); + + assert_eq!(cache.evict(), 2); + assert_eq!(fired.load(Ordering::Relaxed), 2); + assert_eq!(cache.metrics().evictions, Some(2)); + + let fired_lru = Arc::new(AtomicU32::new(0)); + let fired_lru_clone = fired_lru.clone(); + let lru = ShardedExpiringLruCache::::builder() + .max_size(64) + .on_evict(move |_k, _v| { + fired_lru_clone.fetch_add(1, Ordering::Relaxed); + }) + .build(); + let _ = lru.cache_set( + 1, + ExpiringItem { + val: 10, + expired: flag.clone(), + }, + ); + let _ = lru.cache_set( + 2, + ExpiringItem { + val: 20, + expired: flag.clone(), + }, + ); + + assert_eq!(lru.evict(), 2); + assert_eq!(fired_lru.load(Ordering::Relaxed), 2); + assert_eq!(lru.metrics().evictions, Some(2)); } - #[test] - fn test_cached_disk() { - assert_eq!(cached_disk(1), Ok(1)); - assert_eq!(cached_disk(1), Ok(1)); - assert_eq!(cached_disk(5), Err(TestError::Count(5))); - assert_eq!(cached_disk(6), Err(TestError::Count(6))); + #[cfg(feature = "proc_macro")] + static BARE_EXPIRES_CALLS: AtomicUsize = AtomicUsize::new(0); + #[cfg(feature = "proc_macro")] + #[concurrent_cached(expires = true, key = "u32", convert = r#"{ x }"#)] + fn get_expiring_item(x: u32, flag: Arc) -> ExpiringItem { + BARE_EXPIRES_CALLS.fetch_add(1, Ordering::Relaxed); + ExpiringItem { + val: x * 10, + expired: flag, + } } - #[concurrent_cached( - disk = true, - ttl = 1, - with_cached_flag = true, - map_error = r##"|e| TestError::DiskError(format!("{:?}", e))"## - )] - fn cached_disk_cached_flag(n: u32) -> Result, TestError> { - if n < 5 { - Ok(cached::Return::new(n)) - } else { - Err(TestError::Count(n)) + #[cfg(feature = "proc_macro")] + static BARE_EXPIRES_LRU_CALLS: AtomicUsize = AtomicUsize::new(0); + #[cfg(feature = "proc_macro")] + #[concurrent_cached(expires = true, max_size = 64, key = "u32", convert = r#"{ x }"#)] + fn get_expiring_item_lru(x: u32, flag: Arc) -> ExpiringItem { + BARE_EXPIRES_LRU_CALLS.fetch_add(1, Ordering::Relaxed); + ExpiringItem { + val: x * 10, + expired: flag, } } + #[cfg(feature = "proc_macro")] #[test] - fn test_cached_disk_cached_flag() { - assert!(!cached_disk_cached_flag(1).unwrap().was_cached); - assert!(cached_disk_cached_flag(1).unwrap().was_cached); - assert!(cached_disk_cached_flag(5).is_err()); - assert!(cached_disk_cached_flag(6).is_err()); - } + fn concurrent_cached_expires_unbounded() { + BARE_EXPIRES_CALLS.store(0, Ordering::Relaxed); + let flag = Arc::new(AtomicBool::new(false)); - #[concurrent_cached( - map_error = r##"|e| TestError::DiskError(format!("{:?}", e))"##, - ty = "cached::DiskCache", - create = r##" { DiskCache::new("cached_disk_cache_create").ttl(Duration::from_secs(1)).refresh(true).build().expect("error building disk cache") } "## - )] - fn cached_disk_cache_create(n: u32) -> Result { - if n < 5 { - Ok(n) - } else { - Err(TestError::Count(n)) - } + let res1 = get_expiring_item(5, flag.clone()); + assert_eq!(res1.val, 50); + let res2 = get_expiring_item(5, flag.clone()); + assert_eq!(res2.val, 50); + assert_eq!(BARE_EXPIRES_CALLS.load(Ordering::Relaxed), 1); // cached + + // Expire + flag.store(true, Ordering::Relaxed); + let res3 = get_expiring_item(5, flag.clone()); + assert_eq!(res3.val, 50); + assert_eq!(BARE_EXPIRES_CALLS.load(Ordering::Relaxed), 2); // recalculated } + #[cfg(feature = "proc_macro")] #[test] - fn test_cached_disk_cache_create() { - assert_eq!(cached_disk_cache_create(1), Ok(1)); - assert_eq!(cached_disk_cache_create(1), Ok(1)); - assert_eq!(cached_disk_cache_create(5), Err(TestError::Count(5))); - assert_eq!(cached_disk_cache_create(6), Err(TestError::Count(6))); + fn concurrent_cached_expires_lru() { + BARE_EXPIRES_LRU_CALLS.store(0, Ordering::Relaxed); + let flag = Arc::new(AtomicBool::new(false)); + + let res1 = get_expiring_item_lru(5, flag.clone()); + assert_eq!(res1.val, 50); + let res2 = get_expiring_item_lru(5, flag.clone()); + assert_eq!(res2.val, 50); + assert_eq!(BARE_EXPIRES_LRU_CALLS.load(Ordering::Relaxed), 1); // cached + + // Expire + flag.store(true, Ordering::Relaxed); + let res3 = get_expiring_item_lru(5, flag.clone()); + assert_eq!(res3.val, 50); + assert_eq!(BARE_EXPIRES_LRU_CALLS.load(Ordering::Relaxed), 2); // recalculated } - /// Just calling the macro with connection_config to test it doesn't break with an expected string - /// for connection_config. - /// There are no simple tests to test this here - #[concurrent_cached( - disk = true, - map_error = r##"|e| TestError::DiskError(format!("{:?}", e))"##, - connection_config = r##"sled::Config::new().flush_every_ms(None)"## - )] - fn cached_disk_connection_config(n: u32) -> Result { - if n < 5 { - Ok(n) - } else { - Err(TestError::Count(n)) + #[test] + fn sharded_expiring_lru_on_evict_fires_on_lru_capacity_pressure() { + let evict_count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let evict_count2 = evict_count.clone(); + let not_expired = Arc::new(AtomicBool::new(false)); + + let cache = ShardedExpiringLruCache::::builder() + .max_size(8) + .shards(1) + .on_evict(move |_k, _v| { + evict_count2.fetch_add(1, Ordering::Relaxed); + }) + .build(); + + // Insert 16 entries into a cache with capacity 8 (1 shard) to force LRU evictions. + for i in 0..16 { + let _ = ConcurrentCached::cache_set( + &cache, + i, + ExpiringItem { + val: i, + expired: not_expired.clone(), + }, + ); } + + // At least 8 entries must have been evicted by LRU capacity pressure. + assert!( + evict_count.load(Ordering::Relaxed) >= 8, + "expected on_evict to fire for LRU evictions, got {}", + evict_count.load(Ordering::Relaxed) + ); + // metrics().evictions aggregates both LRU-shard capacity evictions and inner.evictions. + let total_evictions = cache.metrics().evictions.unwrap_or(0); + assert!( + total_evictions >= 8, + "expected metrics().evictions >= 8, got {}", + total_evictions + ); } - /// Just calling the macro with sync_to_disk_on_cache_change to test it doesn't break with an expected value - /// There are no simple tests to test this here - #[concurrent_cached( - disk = true, - map_error = r##"|e| TestError::DiskError(format!("{:?}", e))"##, - sync_to_disk_on_cache_change = true - )] - fn cached_disk_sync_to_disk_on_cache_change(n: u32) -> Result { - if n < 5 { - Ok(n) - } else { - Err(TestError::Count(n)) - } + #[test] + fn sharded_expiring_send_sync() { + fn assert_send_sync() {} + assert_send_sync::>(); + assert_send_sync::>(); } - #[cfg(all(feature = "async", feature = "proc_macro"))] - mod async_test { + // `expires = true` + `with_cached_flag = true`: `was_cached` is false on first call + // and on re-execution after expiry; true only for a genuine unexpired cache hit. + #[cfg(feature = "proc_macro")] + mod concurrent_cached_expires_with_cached_flag { use super::*; + use cached::macros::concurrent_cached; + use std::sync::atomic::AtomicUsize; - #[concurrent_cached( - disk = true, - map_error = r##"|e| TestError::DiskError(format!("{:?}", e))"## - )] - async fn async_cached_disk(n: u32) -> Result { - if n < 5 { - Ok(n) - } else { - Err(TestError::Count(n)) - } - } - - #[tokio::test] - async fn test_async_cached_disk() { - assert_eq!(async_cached_disk(1).await, Ok(1)); - assert_eq!(async_cached_disk(1).await, Ok(1)); - assert_eq!(async_cached_disk(5).await, Err(TestError::Count(5))); - assert_eq!(async_cached_disk(6).await, Err(TestError::Count(6))); - } - - // Regression: a value that is `Send` + `Serialize` + `Clone` but **not - // `Sync`** (it contains a `Cell`) must be usable with async disk - // caching. Before relaxing the async `DiskCache` impl (fn-pointer - // phantom + dropping the `V: Sync` bound) this failed to compile with - // "future cannot be sent between threads safely". - #[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] - struct NotSyncValue { - c: std::cell::Cell, - } + static EXPIRES_FLAG_CALLS: AtomicUsize = AtomicUsize::new(0); #[concurrent_cached( - disk = true, - map_error = r##"|e| TestError::DiskError(format!("{:?}", e))"## + expires = true, + key = "u32", + convert = r#"{ x }"#, + with_cached_flag = true )] - async fn async_cached_disk_not_sync(n: u32) -> Result { - Ok(NotSyncValue { - c: std::cell::Cell::new(n), - }) + fn get_flagged_expiring( + x: u32, + expired: Arc, + ) -> Result, std::convert::Infallible> { + EXPIRES_FLAG_CALLS.fetch_add(1, Ordering::Relaxed); + Ok(cached::Return::new(ExpiringItem { val: x, expired })) } - #[tokio::test] - async fn test_async_cached_disk_not_sync_value() { - fn assert_send() {} - assert_send::(); // Send but (via Cell) !Sync - assert_eq!( - async_cached_disk_not_sync(7).await.unwrap(), - NotSyncValue { - c: std::cell::Cell::new(7) - } - ); - // second call is served from the disk cache - assert_eq!( - async_cached_disk_not_sync(7).await.unwrap(), - NotSyncValue { - c: std::cell::Cell::new(7) - } - ); + #[test] + fn expires_with_cached_flag_reports_cache_state() { + EXPIRES_FLAG_CALLS.store(0, Ordering::Relaxed); + let flag = Arc::new(AtomicBool::new(false)); + + // First call: not cached. + let r1 = get_flagged_expiring(42, flag.clone()).unwrap(); + assert!(!r1.was_cached, "first call should not be cached"); + assert_eq!(r1.val, 42); + + // Second call: cached hit. + let r2 = get_flagged_expiring(42, flag.clone()).unwrap(); + assert!(r2.was_cached, "second call should be a cache hit"); + assert_eq!(EXPIRES_FLAG_CALLS.load(Ordering::Relaxed), 1); + + // After expiry: function re-executes, was_cached = false. + flag.store(true, Ordering::Relaxed); + let r3 = get_flagged_expiring(42, flag.clone()).unwrap(); + assert!(!r3.was_cached, "call after expiry should not be cached"); + assert_eq!(EXPIRES_FLAG_CALLS.load(Ordering::Relaxed), 2); } } } -// Regression (P2): a value that is `Send + Serialize + Clone` but **not -// `Sync`** (contains a `Cell`) must be usable with Redis-backed caches. Before -// the fn-pointer `PhantomData` on `RedisCache`/`AsyncRedisCache` and the -// dropped `V: Sync` bound on the async `AsyncRedisCache::new` / `impl -// ConcurrentCachedAsync` blocks, the sync path failed because the macro-emitted -// `LazyLock>>` static required `RedisCache: Sync` -// (which `PhantomData<(K, V)>` propagated from `V: Sync`), and the async path -// failed at the explicit `V: Send + Sync` bound. Compile-only — no server -// required. #[cfg(feature = "redis_store")] #[allow(dead_code)] mod redis_not_sync_typecheck { @@ -2146,6 +4142,9 @@ mod concurrent_cached_return_named_error { 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(&mut self, _r: bool) -> bool { false } @@ -2325,6 +4324,20 @@ mod redis_tests { Err(TestError::Count(6)) ); } + + #[tokio::test] + async fn async_redis_builder_aliases_and_zero_ttl_validation() { + let result = cached::AsyncRedisCache::::builder( + "async-zero-ttl", + Duration::ZERO, + ) + .try_build() + .await; + assert!(matches!( + result, + Err(cached::RedisCacheBuildError::InvalidTtl(..)) + )); + } } } @@ -2350,8 +4363,7 @@ const UNEXPIRED_SLUG: &str = "unexpired_slug"; #[cfg(feature = "proc_macro")] #[cached( ty = "ExpiringLruCache", - create = "{ ExpiringLruCache::with_size(3) }", - result = true + create = "{ ExpiringLruCache::with_max_size(3) }" )] fn fetch_article(slug: String) -> Result { match slug.as_str() { @@ -2442,7 +4454,7 @@ fn test_sized_cache_on_evict() { let evicted_count = Arc::new(AtomicU32::new(0)); let evicted_count_clone = evicted_count.clone(); let mut cache = cached::LruCache::builder() - .size(2) + .max_size(2) .on_evict(move |_k, _v| { evicted_count_clone.fetch_add(1, Ordering::Relaxed); }) @@ -2500,7 +4512,7 @@ fn test_expiring_sized_cache_on_evict() { let evicted_count = Arc::new(AtomicU32::new(0)); let evicted_count_clone = evicted_count.clone(); let mut cache = cached::stores::TtlSortedCache::builder() - .size(2) + .max_size(2) .ttl(cached::time::Duration::from_secs(10)) .on_evict(move |_k, _v| { evicted_count_clone.fetch_add(1, Ordering::Relaxed); @@ -2519,7 +4531,7 @@ fn test_expiring_sized_cache_on_evict() { #[cfg(feature = "time_stores")] fn test_timed_sized_expired_get_does_not_pollute_inner_metrics() { let mut cache = - cached::LruTtlCache::with_size_and_ttl(2, cached::time::Duration::from_millis(20)); + cached::LruTtlCache::with_max_size_and_ttl(2, cached::time::Duration::from_millis(20)); cache.cache_set(1, 10); cache.cache_set(2, 20); cache.cache_reset_metrics(); @@ -2542,7 +4554,7 @@ fn test_timed_sized_cache_expired_get_or_set_invokes_on_evict() { let evicted_count = Arc::new(AtomicU32::new(0)); let evicted_count_clone = evicted_count.clone(); let mut cache = cached::LruTtlCache::builder() - .size(4) + .max_size(4) .ttl(cached::time::Duration::from_millis(50)) .on_evict(move |_k, _v| { evicted_count_clone.fetch_add(1, Ordering::Relaxed); @@ -2591,7 +4603,7 @@ fn test_expiring_value_cache_expired_get_or_set_invokes_on_evict() { let evicted_count = Arc::new(AtomicU32::new(0)); let evicted_count_clone = evicted_count.clone(); let mut cache = cached::ExpiringLruCache::builder() - .size(4) + .max_size(4) .on_evict(move |_k: &i32, _v: &Expirable| { evicted_count_clone.fetch_add(1, Ordering::Relaxed); }) @@ -2643,7 +4655,7 @@ fn test_expiring_value_cache_get_mut_expired_invokes_on_evict() { let evicted_count = Arc::new(AtomicU32::new(0)); let evicted_count_clone = evicted_count.clone(); let mut cache = cached::ExpiringLruCache::builder() - .size(4) + .max_size(4) .on_evict(move |_k: &i32, _v: &Expirable| { evicted_count_clone.fetch_add(1, Ordering::Relaxed); }) @@ -2668,18 +4680,18 @@ fn test_fallible_builders_return_build_error() { assert!( matches!( sized.unwrap_err(), - cached::BuildError::MissingRequired("size") + cached::BuildError::MissingRequired("max_size") ), - "expected MissingRequired(size)" + "expected MissingRequired(max_size)" ); let expiring = cached::ExpiringLruCache::::builder().try_build(); assert!( matches!( expiring.unwrap_err(), - cached::BuildError::MissingRequired("size") + cached::BuildError::MissingRequired("max_size") ), - "expected MissingRequired(size)" + "expected MissingRequired(max_size)" ); #[cfg(feature = "time_stores")] @@ -2699,11 +4711,80 @@ fn test_fallible_builders_return_build_error() { assert!( matches!( timed_sized.unwrap_err(), - cached::BuildError::MissingRequired("size") + cached::BuildError::MissingRequired("max_size") + ), + "expected MissingRequired(max_size)" + ); + + let zero_ttl = cached::TtlCache::::builder() + .ttl(cached::time::Duration::ZERO) + .try_build(); + assert!( + matches!(zero_ttl.unwrap_err(), cached::BuildError::InvalidTtl { .. }), + "expected InvalidTtl" + ); + + let zero_lru_ttl = cached::LruTtlCache::::builder() + .max_size(4) + .ttl(cached::time::Duration::ZERO) + .try_build(); + assert!( + matches!( + zero_lru_ttl.unwrap_err(), + cached::BuildError::InvalidTtl { .. } + ), + "expected InvalidTtl" + ); + + let zero_sorted_ttl = cached::TtlSortedCache::::builder() + .ttl(cached::time::Duration::ZERO) + .try_build(); + assert!( + matches!( + zero_sorted_ttl.unwrap_err(), + cached::BuildError::InvalidTtl { .. } ), - "expected MissingRequired(size)" + "expected InvalidTtl" ); } + + let sharded_unbound = cached::ShardedCache::::builder() + .shards(0) + .try_build(); + assert!( + matches!( + sharded_unbound.unwrap_err(), + cached::BuildError::InvalidValue { + field: "shards", + .. + } + ), + "expected InvalidValue(shards) for shards(0)" + ); +} + +#[cfg(feature = "disk_store")] +#[test] +fn disk_cache_builder_aliases_and_zero_ttl_validation() { + let result = cached::DiskCache::::builder("zero-ttl") + .ttl(cached::time::Duration::ZERO) + .try_build(); + assert!(matches!( + result, + Err(cached::DiskCacheBuildError::InvalidTtl(..)) + )); +} + +#[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) + .try_build(); + assert!(matches!( + result, + Err(cached::RedisCacheBuildError::InvalidTtl(..)) + )); } #[test] @@ -2722,7 +4803,7 @@ fn test_expiring_value_cache_get_does_not_promote_expired_key() { // Size-2 cache: insert a live entry, then an expired entry. // Probing the expired entry via cache_get must not promote it to most-recent. - let mut cache = cached::ExpiringLruCache::builder().size(2).build(); + let mut cache = cached::ExpiringLruCache::builder().max_size(2).build(); cache.cache_set(1, Expirable { expired: false }); // live, inserted first (older) cache.cache_set(2, Expirable { expired: true }); // expired, inserted second (newer) @@ -2832,7 +4913,7 @@ fn test_expiring_sized_cache_get_evicts_expired_and_fires_on_evict() { let evicted_count = Arc::new(AtomicU32::new(0)); let evicted_count_clone = evicted_count.clone(); let mut cache = cached::stores::TtlSortedCache::builder() - .size(4) + .max_size(4) .ttl(cached::time::Duration::from_millis(50)) .on_evict(move |_k: &u32, _v: &u32| { evicted_count_clone.fetch_add(1, Ordering::Relaxed); @@ -2866,7 +4947,7 @@ fn test_timed_sized_cache_on_evict_fires_on_cache_get() { let evicted_count = Arc::new(AtomicU32::new(0)); let evicted_count_clone = evicted_count.clone(); let mut cache = cached::LruTtlCache::builder() - .size(4) + .max_size(4) .ttl(cached::time::Duration::from_millis(50)) .on_evict(move |_k: &u32, _v: &u32| { evicted_count_clone.fetch_add(1, Ordering::Relaxed); @@ -2903,7 +4984,7 @@ fn test_expiring_value_cache_on_evict_fires_on_cache_get() { let evicted_count = Arc::new(AtomicU32::new(0)); let evicted_count_clone = evicted_count.clone(); let mut cache = cached::ExpiringLruCache::builder() - .size(4) + .max_size(4) .on_evict(move |_k: &i32, _v: &Expirable| { evicted_count_clone.fetch_add(1, Ordering::Relaxed); }) @@ -3003,7 +5084,7 @@ fn test_ttl_cache_zero_ttl() { #[test] fn test_lru_ttl_cache_zero_ttl() { use cached::LruTtlCache; - let mut cache = LruTtlCache::with_size_and_ttl(4, Duration::from_nanos(0)); + let mut cache = LruTtlCache::with_max_size_and_ttl(4, Duration::from_nanos(0)); cache.cache_set(1u32, "hello"); assert!(cache.cache_get(&1u32).is_none()); assert_eq!(cache.cache_misses(), Some(1)); @@ -3042,7 +5123,7 @@ fn test_cache_reset_also_resets_metrics() { assert_eq!(c.cache_misses(), Some(0)); assert_eq!(c.cache_size(), 0); - let mut lru = LruCache::with_size(4); + let mut lru = LruCache::with_max_size(4); lru.cache_set(1u32, 1u32); lru.cache_get(&1u32); lru.cache_get(&99u32); @@ -3068,7 +5149,7 @@ fn test_cache_reset_also_resets_metrics_time_stores() { assert_eq!(tc.cache_misses(), Some(0)); assert_eq!(tc.cache_size(), 0); - let mut ltu = LruTtlCache::::with_size_and_ttl(4, Duration::from_secs(60)); + let mut ltu = LruTtlCache::::with_max_size_and_ttl(4, Duration::from_secs(60)); ltu.cache_set(1, 1); ltu.cache_get(&1); ltu.cache_get(&99); @@ -3093,7 +5174,7 @@ fn test_cache_clear_preserves_metrics() { assert_eq!(c.cache_hits(), Some(1)); assert_eq!(c.cache_misses(), Some(1)); - let mut lru = LruCache::with_size(4); + let mut lru = LruCache::with_max_size(4); lru.cache_set(1u32, 1u32); lru.cache_get(&1u32); lru.cache_get(&99u32); @@ -3136,7 +5217,7 @@ fn test_unbound_cache_on_evict_fires_on_remove() { fn test_lru_ttl_cache_retain() { use cached::{Cached, LruTtlCache}; - let mut cache = LruTtlCache::::with_size_and_ttl(10, Duration::from_secs(60)); + let mut cache = LruTtlCache::::with_max_size_and_ttl(10, Duration::from_secs(60)); cache.cache_set(1, 11); // odd cache.cache_set(2, 20); // even cache.cache_set(3, 31); // odd @@ -3152,6 +5233,56 @@ fn test_lru_ttl_cache_retain() { assert_eq!(cache.cache_size(), 2); } +#[test] +fn test_lru_retain_fires_on_evict_and_increments_evictions() { + use cached::{Cached, LruCache}; + use std::sync::atomic::{AtomicU32, Ordering}; + use std::sync::Arc; + + let fired = Arc::new(AtomicU32::new(0)); + let fired_clone = fired.clone(); + let mut cache = LruCache::builder() + .max_size(10) + .on_evict(move |_k, _v| { + fired_clone.fetch_add(1, Ordering::Relaxed); + }) + .build(); + + cache.cache_set(1u32, 10u32); + cache.cache_set(2u32, 20u32); + cache.cache_set(3u32, 30u32); + cache.cache_set(4u32, 40u32); + + // Remove odd keys via retain + cache.retain(|k, _v| k % 2 == 0); + + assert_eq!(fired.load(Ordering::Relaxed), 2); // keys 1 and 3 removed + assert_eq!(cache.cache_evictions(), Some(2)); + assert_eq!(cache.cache_size(), 2); + assert!(cache.cache_get(&1u32).is_none()); + assert!(cache.cache_get(&2u32).is_some()); + assert!(cache.cache_get(&3u32).is_none()); + assert!(cache.cache_get(&4u32).is_some()); +} + +#[cfg(feature = "time_stores")] +#[test] +fn test_lru_ttl_evict_does_not_double_count_evictions() { + use cached::{Cached, LruTtlCache}; + + let mut cache = LruTtlCache::::with_max_size_and_ttl(10, Duration::from_millis(50)); + cache.cache_set(1u32, 10u32); + cache.cache_set(2u32, 20u32); + cache.cache_set(3u32, 30u32); + + std::thread::sleep(Duration::from_millis(100)); + + // evict() uses retain_silent internally; cache_evictions() = outer + inner counters. + // With retain_silent the inner counter stays 0, so total == 3, not 6. + assert_eq!(cache.evict(), 3); + assert_eq!(cache.cache_evictions(), Some(3)); +} + #[cfg(feature = "time_stores")] #[test] fn test_ttl_sorted_cache_clone_cached() { @@ -3201,7 +5332,7 @@ async fn test_expiring_lru_cache_cached_async() { } } - let mut cache = ExpiringLruCache::::with_size(4); + let mut cache = ExpiringLruCache::::with_max_size(4); let val = CachedAsync::async_get_or_set_with(&mut cache, 1u32, || async { NeverExpires(42) }).await; @@ -3221,7 +5352,7 @@ async fn test_expiring_lru_cache_cached_async() { #[test] fn test_lru_cache_builder_build() { use cached::Cached; - let mut cache = LruCache::::builder().size(4).build(); + let mut cache = LruCache::::builder().max_size(4).build(); cache.cache_set(1, 10); assert_eq!(cache.cache_get(&1), Some(&10)); assert_eq!(cache.cache_capacity(), Some(4)); @@ -3246,7 +5377,7 @@ fn test_expiring_lru_cache_builder_build() { } } let mut cache = ExpiringLruCache::::builder() - .size(4) + .max_size(4) .build(); cache.cache_set(1, AlwaysFresh(42)); assert_eq!(cache.cache_get(&1).map(|v| v.0), Some(42)); @@ -3268,7 +5399,7 @@ fn test_ttl_cache_builder_build() { fn test_lru_ttl_cache_builder_build() { use cached::{Cached, LruTtlCache}; let mut cache = LruTtlCache::::builder() - .size(4) + .max_size(4) .ttl(Duration::from_secs(60)) .refresh(true) .build(); @@ -3283,7 +5414,7 @@ fn test_ttl_sorted_cache_builder_build() { use cached::{stores::TtlSortedCache, Cached}; let mut cache = TtlSortedCache::::builder() .ttl(Duration::from_secs(60)) - .size(4) + .max_size(4) .build(); cache.cache_set(1, 10); assert_eq!(cache.cache_get(&1), Some(&10)); @@ -3341,7 +5472,7 @@ fn test_cached_iter_unbound() { #[test] fn test_cached_iter_lru() { use cached::{Cached, CachedIter}; - let mut cache = LruCache::::with_size(4); + let mut cache = LruCache::::with_max_size(4); cache.cache_set(1, 10); cache.cache_set(2, 20); let mut pairs: Vec<_> = CachedIter::iter(&cache).collect(); @@ -3383,7 +5514,7 @@ fn test_cached_iter_expiring_lru() { false } } - let mut cache = ExpiringLruCache::::with_size(4); + let mut cache = ExpiringLruCache::::with_max_size(4); cache.cache_set(1, Fresh); cache.cache_set(2, Fresh); let mut keys: Vec<_> = CachedIter::iter(&cache).map(|(k, _)| *k).collect(); @@ -3415,7 +5546,7 @@ fn test_cached_peek_ttl_cache() { #[cfg(feature = "time_stores")] fn test_cached_peek_lru_ttl_cache() { use cached::{Cached, CachedPeek, LruTtlCache}; - let mut cache = LruTtlCache::::with_size_and_ttl(4, Duration::from_secs(60)); + let mut cache = LruTtlCache::::with_max_size_and_ttl(4, Duration::from_secs(60)); cache.cache_set(1, 10); cache.cache_reset_metrics(); @@ -3440,7 +5571,7 @@ fn test_cached_peek_ttl_sorted_cache() { #[test] fn test_cached_peek_lru_cache() { use cached::{Cached, CachedPeek}; - let mut cache = LruCache::::with_size(4); + let mut cache = LruCache::::with_max_size(4); cache.cache_set(1, 10); cache.cache_reset_metrics(); @@ -3467,7 +5598,7 @@ fn test_cached_peek_expiring_lru_cache() { } } - let mut cache = ExpiringLruCache::::with_size(4); + let mut cache = ExpiringLruCache::::with_max_size(4); cache.cache_set(1, AlwaysFresh(10)); cache.cache_reset_metrics(); @@ -3480,7 +5611,7 @@ fn test_cached_peek_expiring_lru_cache() { assert_eq!(cache.cache_misses(), Some(0)); // peek on a logically-expired entry returns None - let mut cache2 = ExpiringLruCache::::with_size(4); + let mut cache2 = ExpiringLruCache::::with_max_size(4); cache2.cache_set(1, AlwaysExpired); cache2.cache_reset_metrics(); assert!(cache2.cache_peek(&1).is_none()); @@ -3564,7 +5695,7 @@ fn test_expiring_lru_cache_clone_cached() { } } - let mut cache = ExpiringLruCache::::with_size(4); + let mut cache = ExpiringLruCache::::with_max_size(4); cache.cache_set( 1, Article { @@ -3616,7 +5747,7 @@ fn test_cache_evict_ttl_cache() { #[cfg(feature = "time_stores")] fn test_cache_evict_lru_ttl_cache() { use cached::{CacheEvict, Cached, LruTtlCache}; - let mut cache = LruTtlCache::::with_size_and_ttl(4, Duration::from_millis(20)); + let mut cache = LruTtlCache::::with_max_size_and_ttl(4, Duration::from_millis(20)); cache.cache_set(1, 10); cache.cache_set(2, 20); sleep(Duration::from_millis(40)); @@ -3721,6 +5852,9 @@ mod generic_where_tests { fn cache_remove(&self, k: &String) -> Result, Self::Error> { Ok(self.inner.lock().unwrap().remove(k)) } + fn cache_remove_entry(&self, k: &String) -> Result, Self::Error> { + Ok(self.inner.lock().unwrap().remove_entry(k)) + } fn set_refresh_on_hit(&mut self, _refresh: bool) -> bool { false } @@ -3796,7 +5930,7 @@ fn test_cache_metrics_and_hit_ratio() { assert!((ratio - 2.0 / 3.0).abs() < 1e-9); // LruCache: bounded, so capacity is Some - let mut lru = LruCache::::with_size(4); + let mut lru = LruCache::::with_max_size(4); lru.cache_set(1, 10); lru.cache_get(&1); lru.cache_get(&99); @@ -3855,7 +5989,7 @@ mod result_fallback_async_tests { use super::sleep; use cached::time::Duration; - #[cached::macros::cached(result = true, ttl = 1, result_fallback = true)] + #[cached::macros::cached(ttl = 1, result_fallback = true)] async fn async_always_failing() -> Result { Err(()) } @@ -3932,7 +6066,7 @@ fn test_cache_evict_expiring_lru_cache() { let evicted_count = Arc::new(AtomicU32::new(0)); let evicted_clone = evicted_count.clone(); let mut cache = ExpiringLruCache::::builder() - .size(10) + .max_size(10) .on_evict(move |_k: &u32, _v: &Expirable| { evicted_clone.fetch_add(1, Ordering::Relaxed); }) @@ -3962,7 +6096,7 @@ fn test_expiring_lru_cache_get_does_not_inflate_inner_metrics() { } } - let mut cache = ExpiringLruCache::::with_size(4); + let mut cache = ExpiringLruCache::::with_max_size(4); cache.cache_set(1, Fresh); cache.cache_reset_metrics(); @@ -3988,7 +6122,7 @@ fn test_expiring_lru_cache_evictions_sum_lru_and_expiry() { } } - let mut cache = ExpiringLruCache::::with_size(2); + let mut cache = ExpiringLruCache::::with_max_size(2); // Fill to capacity then insert a third entry: LRU evicts key 1 via the // inner LruCache's check_capacity path (inner store eviction counter = 1). @@ -4032,7 +6166,7 @@ mod macro_arg_pairwise { } // size + sync_lock = "mutex": LruCache behind a Mutex, read via `.lock()`. - #[cached(size = 2, sync_lock = "mutex")] + #[cached(max_size = 2, sync_lock = "mutex")] fn sized_mutex(n: u32) -> u32 { n * 2 } @@ -4126,7 +6260,7 @@ mod macro_arg_pairwise { } // once + result + with_cached_flag (pairwise). - #[once(result = true, with_cached_flag = true)] + #[once(with_cached_flag = true)] fn once_result_flag(ok: bool) -> Result, ()> { if ok { Ok(cached::Return::new(1)) @@ -4146,7 +6280,7 @@ mod macro_arg_pairwise { } // once + option + with_cached_flag (pairwise). - #[once(option = true, with_cached_flag = true)] + #[once(with_cached_flag = true)] fn once_option_flag(some: bool) -> Option> { if some { Some(cached::Return::new(2)) @@ -4268,7 +6402,7 @@ mod async_cache_store_tests { let evicted = Arc::new(AtomicUsize::new(0)); let evicted_clone = evicted.clone(); let mut cache = LruTtlCache::builder() - .size(2) + .max_size(2) .ttl(Duration::from_millis(50)) .on_evict(move |_, _| { evicted_clone.fetch_add(1, Ordering::Relaxed); @@ -4338,7 +6472,7 @@ mod async_cache_store_tests { let evicted = Arc::new(AtomicUsize::new(0)); let evicted_clone = evicted.clone(); let mut cache = ExpiringLruCache::builder() - .size(2) + .max_size(2) .on_evict(move |_, _| { evicted_clone.fetch_add(1, Ordering::Relaxed); }) diff --git a/tests/ui/cached_result_no_inner_type.rs b/tests/ui/cached_cache_err_requires_result_return.rs similarity index 73% rename from tests/ui/cached_result_no_inner_type.rs rename to tests/ui/cached_cache_err_requires_result_return.rs index a82f7816..f66bf6cd 100644 --- a/tests/ui/cached_result_no_inner_type.rs +++ b/tests/ui/cached_cache_err_requires_result_return.rs @@ -1,6 +1,6 @@ use cached::macros::cached; -#[cached(result = true)] +#[cached(cache_err = true)] fn my_fn(k: i32) -> i32 { k } diff --git a/tests/ui/cached_cache_err_requires_result_return.stderr b/tests/ui/cached_cache_err_requires_result_return.stderr new file mode 100644 index 00000000..76bc2070 --- /dev/null +++ b/tests/ui/cached_cache_err_requires_result_return.stderr @@ -0,0 +1,5 @@ +error: `cache_err = true` requires the function to return `Result` + --> tests/ui/cached_cache_err_requires_result_return.rs:4:4 + | +4 | fn my_fn(k: i32) -> i32 { + | ^^^^^ diff --git a/tests/ui/cached_result_option_exclusive.rs b/tests/ui/cached_cache_err_result_fallback_exclusive.rs similarity index 60% rename from tests/ui/cached_result_option_exclusive.rs rename to tests/ui/cached_cache_err_result_fallback_exclusive.rs index 0bfcc14f..c8f3b4ce 100644 --- a/tests/ui/cached_result_option_exclusive.rs +++ b/tests/ui/cached_cache_err_result_fallback_exclusive.rs @@ -1,6 +1,6 @@ use cached::macros::cached; -#[cached(result = true, option = true)] +#[cached(ttl = 1, cache_err = true, result_fallback = true)] fn my_fn(k: i32) -> Result { Ok(k) } diff --git a/tests/ui/cached_cache_err_result_fallback_exclusive.stderr b/tests/ui/cached_cache_err_result_fallback_exclusive.stderr new file mode 100644 index 00000000..2f0e3c32 --- /dev/null +++ b/tests/ui/cached_cache_err_result_fallback_exclusive.stderr @@ -0,0 +1,5 @@ +error: `cache_err` and `result_fallback` are mutually exclusive + --> tests/ui/cached_cache_err_result_fallback_exclusive.rs:4:4 + | +4 | fn my_fn(k: i32) -> Result { + | ^^^^^ diff --git a/tests/ui/cached_cache_none_requires_option_return.rs b/tests/ui/cached_cache_none_requires_option_return.rs new file mode 100644 index 00000000..23ce8607 --- /dev/null +++ b/tests/ui/cached_cache_none_requires_option_return.rs @@ -0,0 +1,8 @@ +use cached::macros::cached; + +#[cached(cache_none = true)] +fn my_fn(k: i32) -> i32 { + k +} + +fn main() {} diff --git a/tests/ui/cached_cache_none_requires_option_return.stderr b/tests/ui/cached_cache_none_requires_option_return.stderr new file mode 100644 index 00000000..b68dbc6f --- /dev/null +++ b/tests/ui/cached_cache_none_requires_option_return.stderr @@ -0,0 +1,5 @@ +error: `cache_none = true` requires the function to return `Option` + --> tests/ui/cached_cache_none_requires_option_return.rs:4:4 + | +4 | fn my_fn(k: i32) -> i32 { + | ^^^^^ diff --git a/tests/ui/cached_cache_none_with_cached_flag_exclusive.rs b/tests/ui/cached_cache_none_with_cached_flag_exclusive.rs new file mode 100644 index 00000000..3bfea443 --- /dev/null +++ b/tests/ui/cached_cache_none_with_cached_flag_exclusive.rs @@ -0,0 +1,8 @@ +use cached::macros::cached; + +#[cached(cache_none = true, with_cached_flag = true)] +fn my_fn(k: i32) -> Option> { + Some(cached::Return::new(k)) +} + +fn main() {} diff --git a/tests/ui/cached_cache_none_with_cached_flag_exclusive.stderr b/tests/ui/cached_cache_none_with_cached_flag_exclusive.stderr new file mode 100644 index 00000000..eceb372e --- /dev/null +++ b/tests/ui/cached_cache_none_with_cached_flag_exclusive.stderr @@ -0,0 +1,5 @@ +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_expires_cache_none_exclusive.rs b/tests/ui/cached_expires_cache_none_exclusive.rs new file mode 100644 index 00000000..113e4930 --- /dev/null +++ b/tests/ui/cached_expires_cache_none_exclusive.rs @@ -0,0 +1,14 @@ +use cached::macros::cached; + +#[derive(Clone)] +struct Val; +impl cached::Expires for Val { + fn is_expired(&self) -> bool { false } +} + +#[cached(expires = true, cache_none = true)] +fn my_fn(x: u32) -> Option { + Some(Val) +} + +fn main() {} diff --git a/tests/ui/cached_expires_cache_none_exclusive.stderr b/tests/ui/cached_expires_cache_none_exclusive.stderr new file mode 100644 index 00000000..eefd6a8d --- /dev/null +++ b/tests/ui/cached_expires_cache_none_exclusive.stderr @@ -0,0 +1,5 @@ +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_option_attr_removed.rs b/tests/ui/cached_option_attr_removed.rs new file mode 100644 index 00000000..3562c426 --- /dev/null +++ b/tests/ui/cached_option_attr_removed.rs @@ -0,0 +1,8 @@ +use cached::macros::cached; + +#[cached(option = true)] +fn find(id: u64) -> Option { + Some(id) +} + +fn main() {} diff --git a/tests/ui/cached_option_attr_removed.stderr b/tests/ui/cached_option_attr_removed.stderr new file mode 100644 index 00000000..b9ded984 --- /dev/null +++ b/tests/ui/cached_option_attr_removed.stderr @@ -0,0 +1,5 @@ +error: the `option` attribute has been removed. `Option` returns now skip caching `None` by default. Remove `option = true` (or `option = false`), or use `cache_none = true` to force-cache `None` values. + --> tests/ui/cached_option_attr_removed.rs:4:4 + | +4 | fn find(id: u64) -> Option { + | ^^^^ diff --git a/tests/ui/cached_result_complex_return.rs b/tests/ui/cached_result_attr_removed.rs similarity index 56% rename from tests/ui/cached_result_complex_return.rs rename to tests/ui/cached_result_attr_removed.rs index 6b01f1ed..8ebcbece 100644 --- a/tests/ui/cached_result_complex_return.rs +++ b/tests/ui/cached_result_attr_removed.rs @@ -1,8 +1,8 @@ use cached::macros::cached; #[cached(result = true)] -fn my_fn(k: i32) -> (i32, i32) { - (k, k) +fn load(id: u64) -> Result { + Ok(id) } fn main() {} diff --git a/tests/ui/cached_result_attr_removed.stderr b/tests/ui/cached_result_attr_removed.stderr new file mode 100644 index 00000000..632e1cb8 --- /dev/null +++ b/tests/ui/cached_result_attr_removed.stderr @@ -0,0 +1,5 @@ +error: the `result` attribute has been removed. `Result` returns now skip caching `Err` by default. Remove `result = true` (or `result = false`), or use `cache_err = true` to force-cache `Err` values. + --> tests/ui/cached_result_attr_removed.rs:4:4 + | +4 | fn load(id: u64) -> Result { + | ^^^^ diff --git a/tests/ui/cached_result_complex_return.stderr b/tests/ui/cached_result_complex_return.stderr deleted file mode 100644 index 036521ab..00000000 --- a/tests/ui/cached_result_complex_return.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: function return type is too complex - --> tests/ui/cached_result_complex_return.rs:4:21 - | -4 | fn my_fn(k: i32) -> (i32, i32) { - | ^^^^^^^^^^ diff --git a/tests/ui/cached_result_fallback_sync_writes.rs b/tests/ui/cached_result_fallback_sync_writes.rs index 1f221400..2a1d8c3a 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(result = true, ttl = 1, sync_writes = "default", result_fallback = true)] +#[cached(ttl = 1, sync_writes = "default", result_fallback = true)] fn my_fn(k: i32) -> Result { Ok(k) } diff --git a/tests/ui/cached_result_no_inner_type.stderr b/tests/ui/cached_result_no_inner_type.stderr deleted file mode 100644 index 342f1272..00000000 --- a/tests/ui/cached_result_no_inner_type.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: function return type has no inner type - --> tests/ui/cached_result_no_inner_type.rs:4:21 - | -4 | fn my_fn(k: i32) -> i32 { - | ^^^ diff --git a/tests/ui/cached_result_no_return.rs b/tests/ui/cached_result_no_return.rs deleted file mode 100644 index 42f71d00..00000000 --- a/tests/ui/cached_result_no_return.rs +++ /dev/null @@ -1,8 +0,0 @@ -use cached::macros::cached; - -#[cached(result = true)] -fn my_fn(k: i32) { - let _ = k; -} - -fn main() {} diff --git a/tests/ui/cached_result_no_return.stderr b/tests/ui/cached_result_no_return.stderr deleted file mode 100644 index 6db38cd9..00000000 --- a/tests/ui/cached_result_no_return.stderr +++ /dev/null @@ -1,7 +0,0 @@ -error: function must return something when `result` or `option` is set - --> tests/ui/cached_result_no_return.rs:3:1 - | -3 | #[cached(result = true)] - | ^^^^^^^^^^^^^^^^^^^^^^^^ - | - = 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_result_option_exclusive.stderr b/tests/ui/cached_result_option_exclusive.stderr deleted file mode 100644 index 5471a140..00000000 --- a/tests/ui/cached_result_option_exclusive.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: the `result` and `option` attributes are mutually exclusive - --> tests/ui/cached_result_option_exclusive.rs:4:21 - | -4 | fn my_fn(k: i32) -> Result { - | ^^^^^^ diff --git a/tests/ui/cached_size_attr_deprecated.rs b/tests/ui/cached_size_attr_deprecated.rs new file mode 100644 index 00000000..39177f69 --- /dev/null +++ b/tests/ui/cached_size_attr_deprecated.rs @@ -0,0 +1,12 @@ +// The `size` attribute is a deprecated alias for `max_size`. Using it must emit a +// deprecation warning, which `#![deny(deprecated)]` promotes to a hard error here. +#![deny(deprecated)] + +use cached::macros::cached; + +#[cached(size = 2)] +fn my_fn(x: u32) -> u32 { + x +} + +fn main() {} diff --git a/tests/ui/cached_size_attr_deprecated.stderr b/tests/ui/cached_size_attr_deprecated.stderr new file mode 100644 index 00000000..55781ffa --- /dev/null +++ b/tests/ui/cached_size_attr_deprecated.stderr @@ -0,0 +1,11 @@ +error: use of deprecated constant `cached::__DEPRECATED_SIZE_ATTR`: the `size` macro attribute is deprecated; use `max_size` instead (same meaning) + --> tests/ui/cached_size_attr_deprecated.rs:7:10 + | +7 | #[cached(size = 2)] + | ^^^^ + | +note: the lint level is defined here + --> tests/ui/cached_size_attr_deprecated.rs:3:9 + | +3 | #![deny(deprecated)] + | ^^^^^^^^^^ diff --git a/tests/ui/cached_size_max_size_exclusive.rs b/tests/ui/cached_size_max_size_exclusive.rs new file mode 100644 index 00000000..39f70b08 --- /dev/null +++ b/tests/ui/cached_size_max_size_exclusive.rs @@ -0,0 +1,8 @@ +use cached::macros::cached; + +#[cached(size = 2, max_size = 2)] +fn my_fn(x: u32) -> u32 { + x +} + +fn main() {} diff --git a/tests/ui/cached_size_max_size_exclusive.stderr b/tests/ui/cached_size_max_size_exclusive.stderr new file mode 100644 index 00000000..8a8ef5ca --- /dev/null +++ b/tests/ui/cached_size_max_size_exclusive.stderr @@ -0,0 +1,7 @@ +error: cannot specify both `size` and `max_size` — they are aliases; use one + --> tests/ui/cached_size_max_size_exclusive.rs:3:1 + | +3 | #[cached(size = 2, max_size = 2)] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = 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/concurrent_cached_cache_err_result_fallback_exclusive.rs b/tests/ui/concurrent_cached_cache_err_result_fallback_exclusive.rs new file mode 100644 index 00000000..4533ba0b --- /dev/null +++ b/tests/ui/concurrent_cached_cache_err_result_fallback_exclusive.rs @@ -0,0 +1,8 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached(ttl = 1, cache_err = true, result_fallback = true)] +fn my_fn(k: i32) -> Result { + Ok(k) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_cache_err_result_fallback_exclusive.stderr b/tests/ui/concurrent_cached_cache_err_result_fallback_exclusive.stderr new file mode 100644 index 00000000..a0d6b84c --- /dev/null +++ b/tests/ui/concurrent_cached_cache_err_result_fallback_exclusive.stderr @@ -0,0 +1,5 @@ +error: `result_fallback` and `cache_err` are mutually exclusive + --> tests/ui/concurrent_cached_cache_err_result_fallback_exclusive.rs:4:4 + | +4 | fn my_fn(k: i32) -> Result { + | ^^^^^ diff --git a/tests/ui/concurrent_cached_cache_none_with_redis.rs b/tests/ui/concurrent_cached_cache_none_with_redis.rs new file mode 100644 index 00000000..a4f5cd0c --- /dev/null +++ b/tests/ui/concurrent_cached_cache_none_with_redis.rs @@ -0,0 +1,9 @@ +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)] +fn my_fn(k: i32) -> Option { + Some(k) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_cache_none_with_redis.stderr b/tests/ui/concurrent_cached_cache_none_with_redis.stderr new file mode 100644 index 00000000..c17c17a4 --- /dev/null +++ b/tests/ui/concurrent_cached_cache_none_with_redis.stderr @@ -0,0 +1,5 @@ +error: `cache_none = true` is only supported for the default in-memory sharded stores + --> tests/ui/concurrent_cached_cache_none_with_redis.rs:5:4 + | +5 | fn my_fn(k: i32) -> Option { + | ^^^^^ diff --git a/tests/ui/concurrent_cached_complex_return.stderr b/tests/ui/concurrent_cached_complex_return.stderr index ebbae7a9..a35f4526 100644 --- a/tests/ui/concurrent_cached_complex_return.stderr +++ b/tests/ui/concurrent_cached_complex_return.stderr @@ -1,4 +1,4 @@ -error: #[concurrent_cached] functions must return `Result`s, found "(i32,i32)" +error: #[concurrent_cached] plain return types are only supported for the default in-memory sharded stores. Use `Result` when specifying `redis`, `disk`, or a custom `ty`/`create`. --> tests/ui/concurrent_cached_complex_return.rs:4:21 | 4 | fn my_fn(k: i32) -> (i32, i32) { diff --git a/tests/ui/concurrent_cached_custom_ty_required.stderr b/tests/ui/concurrent_cached_custom_ty_required.stderr deleted file mode 100644 index b4d301bf..00000000 --- a/tests/ui/concurrent_cached_custom_ty_required.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: #[concurrent_cached] cache `ty` must be specified - --> tests/ui/concurrent_cached_custom_ty_required.rs:4:4 - | -4 | fn my_fn(k: i32) -> Result { - | ^^^^^ diff --git a/tests/ui/concurrent_cached_expires_cache_err_exclusive.rs b/tests/ui/concurrent_cached_expires_cache_err_exclusive.rs new file mode 100644 index 00000000..22ffe651 --- /dev/null +++ b/tests/ui/concurrent_cached_expires_cache_err_exclusive.rs @@ -0,0 +1,14 @@ +use cached::macros::concurrent_cached; + +#[derive(Clone)] +struct Val; +impl cached::Expires for Val { + fn is_expired(&self) -> bool { false } +} + +#[concurrent_cached(expires = true, cache_err = true)] +fn my_fn(x: u32) -> Result { + Ok(Val) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_expires_cache_err_exclusive.stderr b/tests/ui/concurrent_cached_expires_cache_err_exclusive.stderr new file mode 100644 index 00000000..0b8ca2f6 --- /dev/null +++ b/tests/ui/concurrent_cached_expires_cache_err_exclusive.stderr @@ -0,0 +1,5 @@ +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.rs b/tests/ui/concurrent_cached_expires_cache_none_exclusive.rs new file mode 100644 index 00000000..66832351 --- /dev/null +++ b/tests/ui/concurrent_cached_expires_cache_none_exclusive.rs @@ -0,0 +1,8 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached(expires = true, cache_none = true)] +fn my_fn(x: u32) -> Option { + Some(x) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_expires_cache_none_exclusive.stderr b/tests/ui/concurrent_cached_expires_cache_none_exclusive.stderr new file mode 100644 index 00000000..f02e6afa --- /dev/null +++ b/tests/ui/concurrent_cached_expires_cache_none_exclusive.stderr @@ -0,0 +1,5 @@ +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 new file mode 100644 index 00000000..50b8fe14 --- /dev/null +++ b/tests/ui/concurrent_cached_expires_create_exclusive.rs @@ -0,0 +1,8 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached(expires = true, create = "{ ShardedCache::new() }")] +fn my_fn(x: u32) -> Result { + Ok(x) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_expires_create_exclusive.stderr b/tests/ui/concurrent_cached_expires_create_exclusive.stderr new file mode 100644 index 00000000..7e20af0f --- /dev/null +++ b/tests/ui/concurrent_cached_expires_create_exclusive.stderr @@ -0,0 +1,5 @@ +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.rs b/tests/ui/concurrent_cached_expires_disk_exclusive.rs new file mode 100644 index 00000000..3de1b195 --- /dev/null +++ b/tests/ui/concurrent_cached_expires_disk_exclusive.rs @@ -0,0 +1,8 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached(expires = true, disk = true)] +fn my_fn(x: u32) -> Result { + Ok(x) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_expires_disk_exclusive.stderr b/tests/ui/concurrent_cached_expires_disk_exclusive.stderr new file mode 100644 index 00000000..c0f7d1f7 --- /dev/null +++ b/tests/ui/concurrent_cached_expires_disk_exclusive.stderr @@ -0,0 +1,5 @@ +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_redis_exclusive.rs b/tests/ui/concurrent_cached_expires_redis_exclusive.rs new file mode 100644 index 00000000..f582500d --- /dev/null +++ b/tests/ui/concurrent_cached_expires_redis_exclusive.rs @@ -0,0 +1,8 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached(expires = true, redis = true)] +fn my_fn(x: u32) -> Result { + Ok(x) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_expires_redis_exclusive.stderr b/tests/ui/concurrent_cached_expires_redis_exclusive.stderr new file mode 100644 index 00000000..972b44da --- /dev/null +++ b/tests/ui/concurrent_cached_expires_redis_exclusive.stderr @@ -0,0 +1,5 @@ +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.rs b/tests/ui/concurrent_cached_expires_refresh_exclusive.rs new file mode 100644 index 00000000..cd97468b --- /dev/null +++ b/tests/ui/concurrent_cached_expires_refresh_exclusive.rs @@ -0,0 +1,8 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached(expires = true, refresh = true)] +fn my_fn(x: u32) -> Result { + Ok(x) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_expires_refresh_exclusive.stderr b/tests/ui/concurrent_cached_expires_refresh_exclusive.stderr new file mode 100644 index 00000000..0885e2e3 --- /dev/null +++ b/tests/ui/concurrent_cached_expires_refresh_exclusive.stderr @@ -0,0 +1,5 @@ +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 new file mode 100644 index 00000000..9fbcbd22 --- /dev/null +++ b/tests/ui/concurrent_cached_expires_ttl_exclusive.rs @@ -0,0 +1,8 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached(expires = true, ttl = 60)] +fn my_fn(x: u32) -> Result { + Ok(x) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_expires_ttl_exclusive.stderr b/tests/ui/concurrent_cached_expires_ttl_exclusive.stderr new file mode 100644 index 00000000..95091bac --- /dev/null +++ b/tests/ui/concurrent_cached_expires_ttl_exclusive.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_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 new file mode 100644 index 00000000..9aa3f772 --- /dev/null +++ b/tests/ui/concurrent_cached_expires_ty_exclusive.rs @@ -0,0 +1,8 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached(expires = true, ty = "ShardedCache")] +fn my_fn(x: u32) -> Result { + Ok(x) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_expires_ty_exclusive.stderr b/tests/ui/concurrent_cached_expires_ty_exclusive.stderr new file mode 100644 index 00000000..ceeba633 --- /dev/null +++ b/tests/ui/concurrent_cached_expires_ty_exclusive.stderr @@ -0,0 +1,5 @@ +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_map_error_on_infallible.rs b/tests/ui/concurrent_cached_map_error_on_infallible.rs new file mode 100644 index 00000000..11c112c1 --- /dev/null +++ b/tests/ui/concurrent_cached_map_error_on_infallible.rs @@ -0,0 +1,9 @@ +use cached::macros::concurrent_cached; + +// `map_error` must not be accepted when the store is infallible (default sharded in-memory). +#[concurrent_cached(map_error = "|e| e")] +fn simple(n: u64) -> u64 { + n +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_map_error_on_infallible.stderr b/tests/ui/concurrent_cached_map_error_on_infallible.stderr new file mode 100644 index 00000000..b349515f --- /dev/null +++ b/tests/ui/concurrent_cached_map_error_on_infallible.stderr @@ -0,0 +1,5 @@ +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_create_conflict.rs b/tests/ui/concurrent_cached_max_size_create_conflict.rs new file mode 100644 index 00000000..01f74729 --- /dev/null +++ b/tests/ui/concurrent_cached_max_size_create_conflict.rs @@ -0,0 +1,10 @@ +use cached::macros::concurrent_cached; + +// `max_size` is an alias for `size`; the create-conflict diagnostic must name the +// attribute the user actually wrote (`max_size`), not the reconciled `size`. +#[concurrent_cached(map_error = "|e| e", disk = true, create = "{ }", max_size = 8)] +fn my_fn(k: i32) -> Result { + Ok(k) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_max_size_create_conflict.stderr b/tests/ui/concurrent_cached_max_size_create_conflict.stderr new file mode 100644 index 00000000..91294e67 --- /dev/null +++ b/tests/ui/concurrent_cached_max_size_create_conflict.stderr @@ -0,0 +1,5 @@ +error: `max_size` only applies to the default in-memory store, not `disk = true` + --> tests/ui/concurrent_cached_max_size_create_conflict.rs:6:4 + | +6 | fn my_fn(k: i32) -> Result { + | ^^^^^ diff --git a/tests/ui/concurrent_cached_no_return.stderr b/tests/ui/concurrent_cached_no_return.stderr index 2454c108..0cdbc665 100644 --- a/tests/ui/concurrent_cached_no_return.stderr +++ b/tests/ui/concurrent_cached_no_return.stderr @@ -1,4 +1,4 @@ -error: #[concurrent_cached] functions must return `Result`s, found "()" +error: #[concurrent_cached] plain return types are only supported for the default in-memory sharded stores. Use `Result` when specifying `redis`, `disk`, or a custom `ty`/`create`. --> tests/ui/concurrent_cached_no_return.rs:3:1 | 3 | #[concurrent_cached(map_error = "|e| e", disk = true)] diff --git a/tests/ui/concurrent_cached_non_result_return.stderr b/tests/ui/concurrent_cached_non_result_return.stderr index df847372..5b7fbd71 100644 --- a/tests/ui/concurrent_cached_non_result_return.stderr +++ b/tests/ui/concurrent_cached_non_result_return.stderr @@ -1,4 +1,4 @@ -error: #[concurrent_cached] functions must return `Result`s, found "i32" +error: #[concurrent_cached] plain return types are only supported for the default in-memory sharded stores. Use `Result` when specifying `redis`, `disk`, or a custom `ty`/`create`. --> tests/ui/concurrent_cached_non_result_return.rs:4:21 | 4 | fn my_fn(k: i32) -> i32 { diff --git a/tests/ui/concurrent_cached_option_attr_removed.rs b/tests/ui/concurrent_cached_option_attr_removed.rs new file mode 100644 index 00000000..f2d806a4 --- /dev/null +++ b/tests/ui/concurrent_cached_option_attr_removed.rs @@ -0,0 +1,9 @@ +use cached::macros::concurrent_cached; + +// `option = true` was never a valid attribute for `#[concurrent_cached]`. +#[concurrent_cached(option = true)] +fn find(id: u64) -> Option { + Some(id) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_option_attr_removed.stderr b/tests/ui/concurrent_cached_option_attr_removed.stderr new file mode 100644 index 00000000..0364f80f --- /dev/null +++ b/tests/ui/concurrent_cached_option_attr_removed.stderr @@ -0,0 +1,5 @@ +error: `option = true` is not a valid attribute for `#[concurrent_cached]`; `Option` returns skip `None` by default. Use `cache_none = true` to force caching `None` values. + --> tests/ui/concurrent_cached_option_attr_removed.rs:4:21 + | +4 | #[concurrent_cached(option = true)] + | ^^^^^^ diff --git a/tests/ui/concurrent_cached_option_attr_unsupported.rs b/tests/ui/concurrent_cached_option_attr_unsupported.rs new file mode 100644 index 00000000..608e7de4 --- /dev/null +++ b/tests/ui/concurrent_cached_option_attr_unsupported.rs @@ -0,0 +1,9 @@ +use cached::macros::concurrent_cached; + +// `cache_none = true` requires the function to return `Option`. +#[concurrent_cached(cache_none = true)] +fn load(id: u64) -> u64 { + id +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_option_attr_unsupported.stderr b/tests/ui/concurrent_cached_option_attr_unsupported.stderr new file mode 100644 index 00000000..2705772b --- /dev/null +++ b/tests/ui/concurrent_cached_option_attr_unsupported.stderr @@ -0,0 +1,5 @@ +error: `cache_none = true` requires the function to return `Option` + --> tests/ui/concurrent_cached_option_attr_unsupported.rs:5:4 + | +5 | fn load(id: u64) -> u64 { + | ^^^^ diff --git a/tests/ui/concurrent_cached_option_return.rs b/tests/ui/concurrent_cached_option_return.rs index 6e781f58..1728a84b 100644 --- a/tests/ui/concurrent_cached_option_return.rs +++ b/tests/ui/concurrent_cached_option_return.rs @@ -1,8 +1,7 @@ use cached::macros::concurrent_cached; -// `#[concurrent_cached]` requires a `Result` return. `Option` (and other -// single-generic path types) must fail here with a clear message, not deeper -// inside the generated body. +// `Option` returns are supported on the default in-memory path, but not +// with `disk`/`redis`/custom stores. This must fail with a clear message. #[concurrent_cached(map_error = "|e| e", disk = true)] fn my_fn(k: i32) -> Option { Some(k) diff --git a/tests/ui/concurrent_cached_option_return.stderr b/tests/ui/concurrent_cached_option_return.stderr index afb2cb9b..2fa7aaaf 100644 --- a/tests/ui/concurrent_cached_option_return.stderr +++ b/tests/ui/concurrent_cached_option_return.stderr @@ -1,5 +1,5 @@ -error: #[concurrent_cached] functions must return `Result`s, found "Option" - --> tests/ui/concurrent_cached_option_return.rs:7:21 +error: `Option` return types that skip `None` are only supported for the default in-memory sharded stores, not `disk = true`. Use `Result` as the return type, or remove `disk = true` to use the default in-memory sharded path. + --> tests/ui/concurrent_cached_option_return.rs:6:4 | -7 | fn my_fn(k: i32) -> Option { - | ^^^^^^ +6 | fn my_fn(k: i32) -> Option { + | ^^^^^ diff --git a/tests/ui/concurrent_cached_option_with_redis.rs b/tests/ui/concurrent_cached_option_with_redis.rs new file mode 100644 index 00000000..fe456808 --- /dev/null +++ b/tests/ui/concurrent_cached_option_with_redis.rs @@ -0,0 +1,9 @@ +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)] +fn my_fn(k: i32) -> Option { + Some(k) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_option_with_redis.stderr b/tests/ui/concurrent_cached_option_with_redis.stderr new file mode 100644 index 00000000..8a72801d --- /dev/null +++ b/tests/ui/concurrent_cached_option_with_redis.stderr @@ -0,0 +1,5 @@ +error: `Option` return types that skip `None` are only supported for the default in-memory sharded stores, not `redis = true`. Use `Result` as the return type, or remove `redis = true` to use the default in-memory sharded path. + --> tests/ui/concurrent_cached_option_with_redis.rs:5:4 + | +5 | fn my_fn(k: i32) -> Option { + | ^^^^^ diff --git a/tests/ui/concurrent_cached_redis_disk_exclusive.rs b/tests/ui/concurrent_cached_redis_disk_exclusive.rs new file mode 100644 index 00000000..b74f069a --- /dev/null +++ b/tests/ui/concurrent_cached_redis_disk_exclusive.rs @@ -0,0 +1,8 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached(redis = true, disk = true, ttl = 60, map_error = r#"|e| format!("{:?}", e)"#)] +fn my_fn(k: i32) -> Result { + Ok(k) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_redis_disk_exclusive.stderr b/tests/ui/concurrent_cached_redis_disk_exclusive.stderr new file mode 100644 index 00000000..a3c0e5ba --- /dev/null +++ b/tests/ui/concurrent_cached_redis_disk_exclusive.stderr @@ -0,0 +1,5 @@ +error: `redis = true` and `disk = true` are mutually exclusive + --> tests/ui/concurrent_cached_redis_disk_exclusive.rs:4:4 + | +4 | fn my_fn(k: i32) -> Result { + | ^^^^^ diff --git a/tests/ui/concurrent_cached_refresh_without_ttl.rs b/tests/ui/concurrent_cached_refresh_without_ttl.rs new file mode 100644 index 00000000..62319552 --- /dev/null +++ b/tests/ui/concurrent_cached_refresh_without_ttl.rs @@ -0,0 +1,8 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached(refresh = true)] +fn my_fn(k: i32) -> Result { + Ok(k) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_refresh_without_ttl.stderr b/tests/ui/concurrent_cached_refresh_without_ttl.stderr new file mode 100644 index 00000000..ab88d31c --- /dev/null +++ b/tests/ui/concurrent_cached_refresh_without_ttl.stderr @@ -0,0 +1,5 @@ +error: `refresh` requires `ttl` 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_attr_unsupported.rs b/tests/ui/concurrent_cached_result_attr_unsupported.rs new file mode 100644 index 00000000..98d5e899 --- /dev/null +++ b/tests/ui/concurrent_cached_result_attr_unsupported.rs @@ -0,0 +1,8 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached(result = true)] +fn load(id: u64) -> Result { + Ok(id) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_result_attr_unsupported.stderr b/tests/ui/concurrent_cached_result_attr_unsupported.stderr new file mode 100644 index 00000000..3f39c8fb --- /dev/null +++ b/tests/ui/concurrent_cached_result_attr_unsupported.stderr @@ -0,0 +1,5 @@ +error: `result` is not a valid attribute for `#[concurrent_cached]`; return `Result` and only `Ok` values are cached by default. Use `cache_err = true` to also cache `Err` values. + --> tests/ui/concurrent_cached_result_attr_unsupported.rs:3:21 + | +3 | #[concurrent_cached(result = true)] + | ^^^^^^ diff --git a/tests/ui/concurrent_cached_result_fallback_expires_exclusive.rs b/tests/ui/concurrent_cached_result_fallback_expires_exclusive.rs new file mode 100644 index 00000000..036120b4 --- /dev/null +++ b/tests/ui/concurrent_cached_result_fallback_expires_exclusive.rs @@ -0,0 +1,14 @@ +use cached::macros::concurrent_cached; + +#[derive(Clone)] +struct Val; +impl cached::Expires for Val { + fn is_expired(&self) -> bool { false } +} + +#[concurrent_cached(expires = true, result_fallback = true)] +fn my_fn(x: u32) -> Result { + Ok(Val) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_result_fallback_expires_exclusive.stderr b/tests/ui/concurrent_cached_result_fallback_expires_exclusive.stderr new file mode 100644 index 00000000..442b313f --- /dev/null +++ b/tests/ui/concurrent_cached_result_fallback_expires_exclusive.stderr @@ -0,0 +1,5 @@ +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_requires_ttl.rs b/tests/ui/concurrent_cached_result_fallback_requires_ttl.rs new file mode 100644 index 00000000..998a3027 --- /dev/null +++ b/tests/ui/concurrent_cached_result_fallback_requires_ttl.rs @@ -0,0 +1,11 @@ +use cached::macros::concurrent_cached; + +// `result_fallback = true` without `ttl` must be a compile error — the +// underlying `ConcurrentCloneCached` trait is only implemented on the +// expiry-capable sharded stores, which require `ttl`. +#[concurrent_cached(result_fallback = true)] +fn my_fn(x: u32) -> Result { + Ok(x * 2) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_result_fallback_requires_ttl.stderr b/tests/ui/concurrent_cached_result_fallback_requires_ttl.stderr new file mode 100644 index 00000000..d51d14b8 --- /dev/null +++ b/tests/ui/concurrent_cached_result_fallback_requires_ttl.stderr @@ -0,0 +1,5 @@ +error: `result_fallback` requires `ttl` to be set. Set a TTL to use a time-bounded sharded store (e.g. `ttl = 60`). Without a TTL, the primary cache never expires and `result_fallback` would only activate on cache misses that return `Err`, providing no meaningful fallback behavior. + --> 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 new file mode 100644 index 00000000..185e05ad --- /dev/null +++ b/tests/ui/concurrent_cached_result_fallback_with_cached_flag_exclusive.rs @@ -0,0 +1,8 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached(ttl = 1, result_fallback = true, with_cached_flag = true)] +fn my_fn(k: i32) -> Result, ()> { + Ok(cached::Return::new(k)) +} + +fn main() {} 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 new file mode 100644 index 00000000..2930ca86 --- /dev/null +++ b/tests/ui/concurrent_cached_result_fallback_with_cached_flag_exclusive.stderr @@ -0,0 +1,5 @@ +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_shards_with_disk.rs b/tests/ui/concurrent_cached_shards_with_disk.rs new file mode 100644 index 00000000..9f2cfe55 --- /dev/null +++ b/tests/ui/concurrent_cached_shards_with_disk.rs @@ -0,0 +1,14 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached( + disk = true, + disk_dir = "/tmp/cached-trybuild", + ttl = 60, + shards = 16, + map_error = r#"|e| format!("{:?}", e)"# +)] +fn my_fn(k: i32) -> Result { + Ok(k) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_shards_with_disk.stderr b/tests/ui/concurrent_cached_shards_with_disk.stderr new file mode 100644 index 00000000..e38b1645 --- /dev/null +++ b/tests/ui/concurrent_cached_shards_with_disk.stderr @@ -0,0 +1,5 @@ +error: `shards` only applies to the default in-memory store, not `disk = true` + --> tests/ui/concurrent_cached_shards_with_disk.rs:10:4 + | +10 | fn my_fn(k: i32) -> Result { + | ^^^^^ diff --git a/tests/ui/concurrent_cached_shards_with_redis.rs b/tests/ui/concurrent_cached_shards_with_redis.rs new file mode 100644 index 00000000..9ec55f45 --- /dev/null +++ b/tests/ui/concurrent_cached_shards_with_redis.rs @@ -0,0 +1,13 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached( + redis = true, + ttl = 60, + shards = 16, + map_error = r#"|e| format!("{:?}", e)"# +)] +fn my_fn(k: i32) -> Result { + Ok(k) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_shards_with_redis.stderr b/tests/ui/concurrent_cached_shards_with_redis.stderr new file mode 100644 index 00000000..e0536d4f --- /dev/null +++ b/tests/ui/concurrent_cached_shards_with_redis.stderr @@ -0,0 +1,5 @@ +error: `shards` only applies to the default in-memory store, not `redis = true` + --> tests/ui/concurrent_cached_shards_with_redis.rs:9:4 + | +9 | fn my_fn(k: i32) -> Result { + | ^^^^^ diff --git a/tests/ui/concurrent_cached_shards_zero.rs b/tests/ui/concurrent_cached_shards_zero.rs new file mode 100644 index 00000000..703eefa4 --- /dev/null +++ b/tests/ui/concurrent_cached_shards_zero.rs @@ -0,0 +1,8 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached(shards = 0)] +fn my_fn(k: i32) -> Result { + Ok(k) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_shards_zero.stderr b/tests/ui/concurrent_cached_shards_zero.stderr new file mode 100644 index 00000000..3253ba55 --- /dev/null +++ b/tests/ui/concurrent_cached_shards_zero.stderr @@ -0,0 +1,5 @@ +error: `shards` must be >= 1 + --> tests/ui/concurrent_cached_shards_zero.rs:4:4 + | +4 | fn my_fn(k: i32) -> Result { + | ^^^^^ diff --git a/tests/ui/concurrent_cached_size_attr_deprecated.rs b/tests/ui/concurrent_cached_size_attr_deprecated.rs new file mode 100644 index 00000000..3ac0e133 --- /dev/null +++ b/tests/ui/concurrent_cached_size_attr_deprecated.rs @@ -0,0 +1,12 @@ +// The `size` attribute is a deprecated alias for `max_size`. Using it must emit a +// deprecation warning, which `#![deny(deprecated)]` promotes to a hard error here. +#![deny(deprecated)] + +use cached::macros::concurrent_cached; + +#[concurrent_cached(size = 2)] +fn my_fn(k: i32) -> i32 { + k +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_size_attr_deprecated.stderr b/tests/ui/concurrent_cached_size_attr_deprecated.stderr new file mode 100644 index 00000000..c66c6189 --- /dev/null +++ b/tests/ui/concurrent_cached_size_attr_deprecated.stderr @@ -0,0 +1,11 @@ +error: use of deprecated constant `cached::__DEPRECATED_SIZE_ATTR`: the `size` macro attribute is deprecated; use `max_size` instead (same meaning) + --> tests/ui/concurrent_cached_size_attr_deprecated.rs:7:21 + | +7 | #[concurrent_cached(size = 2)] + | ^^^^ + | +note: the lint level is defined here + --> tests/ui/concurrent_cached_size_attr_deprecated.rs:3:9 + | +3 | #![deny(deprecated)] + | ^^^^^^^^^^ diff --git a/tests/ui/concurrent_cached_size_max_size_exclusive.rs b/tests/ui/concurrent_cached_size_max_size_exclusive.rs new file mode 100644 index 00000000..4bab2d76 --- /dev/null +++ b/tests/ui/concurrent_cached_size_max_size_exclusive.rs @@ -0,0 +1,8 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached(size = 2, max_size = 2)] +fn my_fn(k: i32) -> Result { + Ok(k) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_size_max_size_exclusive.stderr b/tests/ui/concurrent_cached_size_max_size_exclusive.stderr new file mode 100644 index 00000000..c4f92fba --- /dev/null +++ b/tests/ui/concurrent_cached_size_max_size_exclusive.stderr @@ -0,0 +1,7 @@ +error: cannot specify both `size` and `max_size` — they are aliases; use one + --> tests/ui/concurrent_cached_size_max_size_exclusive.rs:3:1 + | +3 | #[concurrent_cached(size = 2, max_size = 2)] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = note: this error originates in the attribute macro `concurrent_cached` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/tests/ui/concurrent_cached_size_with_disk.rs b/tests/ui/concurrent_cached_size_with_disk.rs new file mode 100644 index 00000000..d2b2a05c --- /dev/null +++ b/tests/ui/concurrent_cached_size_with_disk.rs @@ -0,0 +1,13 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached( + disk = true, + disk_dir = "/tmp/cached-trybuild", + size = 100, + map_error = r#"|e| format!("{:?}", e)"# +)] +fn my_fn(k: i32) -> Result { + Ok(k) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_size_with_disk.stderr b/tests/ui/concurrent_cached_size_with_disk.stderr new file mode 100644 index 00000000..e43fa63e --- /dev/null +++ b/tests/ui/concurrent_cached_size_with_disk.stderr @@ -0,0 +1,5 @@ +error: `size` only applies to the default in-memory store, not `disk = true` + --> tests/ui/concurrent_cached_size_with_disk.rs:9:4 + | +9 | fn my_fn(k: i32) -> Result { + | ^^^^^ diff --git a/tests/ui/concurrent_cached_size_with_disk_ty.rs b/tests/ui/concurrent_cached_size_with_disk_ty.rs new file mode 100644 index 00000000..1291ac73 --- /dev/null +++ b/tests/ui/concurrent_cached_size_with_disk_ty.rs @@ -0,0 +1,14 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached( + disk = true, + disk_dir = "/tmp/cached-trybuild", + size = 100, + ty = "cached::UnboundCache", + map_error = r#"|e| format!("{:?}", e)"# +)] +fn my_fn(k: i32) -> Result { + Ok(k) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_size_with_disk_ty.stderr b/tests/ui/concurrent_cached_size_with_disk_ty.stderr new file mode 100644 index 00000000..863b0fe6 --- /dev/null +++ b/tests/ui/concurrent_cached_size_with_disk_ty.stderr @@ -0,0 +1,5 @@ +error: `size` only applies to the default in-memory store, not `disk = true` + --> tests/ui/concurrent_cached_size_with_disk_ty.rs:10:4 + | +10 | fn my_fn(k: i32) -> Result { + | ^^^^^ diff --git a/tests/ui/concurrent_cached_size_with_redis.rs b/tests/ui/concurrent_cached_size_with_redis.rs new file mode 100644 index 00000000..eec0186c --- /dev/null +++ b/tests/ui/concurrent_cached_size_with_redis.rs @@ -0,0 +1,13 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached( + redis = true, + ttl = 60, + size = 100, + map_error = r#"|e| format!("{:?}", e)"# +)] +fn my_fn(k: i32) -> Result { + Ok(k) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_size_with_redis.stderr b/tests/ui/concurrent_cached_size_with_redis.stderr new file mode 100644 index 00000000..cf6dc367 --- /dev/null +++ b/tests/ui/concurrent_cached_size_with_redis.stderr @@ -0,0 +1,5 @@ +error: `size` only applies to the default in-memory store, not `redis = true` + --> tests/ui/concurrent_cached_size_with_redis.rs:9:4 + | +9 | fn my_fn(k: i32) -> Result { + | ^^^^^ diff --git a/tests/ui/concurrent_cached_size_with_redis_ty.rs b/tests/ui/concurrent_cached_size_with_redis_ty.rs new file mode 100644 index 00000000..e2f3749c --- /dev/null +++ b/tests/ui/concurrent_cached_size_with_redis_ty.rs @@ -0,0 +1,14 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached( + redis = true, + ttl = 60, + size = 100, + ty = "cached::UnboundCache", + map_error = r#"|e| format!("{:?}", e)"# +)] +fn my_fn(k: i32) -> Result { + Ok(k) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_size_with_redis_ty.stderr b/tests/ui/concurrent_cached_size_with_redis_ty.stderr new file mode 100644 index 00000000..6913c42e --- /dev/null +++ b/tests/ui/concurrent_cached_size_with_redis_ty.stderr @@ -0,0 +1,5 @@ +error: `size` only applies to the default in-memory store, not `redis = true` + --> tests/ui/concurrent_cached_size_with_redis_ty.rs:10:4 + | +10 | fn my_fn(k: i32) -> Result { + | ^^^^^ diff --git a/tests/ui/concurrent_cached_size_zero.rs b/tests/ui/concurrent_cached_size_zero.rs new file mode 100644 index 00000000..b0295eea --- /dev/null +++ b/tests/ui/concurrent_cached_size_zero.rs @@ -0,0 +1,8 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached(size = 0)] +fn my_fn(k: i32) -> Result { + Ok(k) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_size_zero.stderr b/tests/ui/concurrent_cached_size_zero.stderr new file mode 100644 index 00000000..22082537 --- /dev/null +++ b/tests/ui/concurrent_cached_size_zero.stderr @@ -0,0 +1,5 @@ +error: `size` must be >= 1 + --> tests/ui/concurrent_cached_size_zero.rs:4:4 + | +4 | fn my_fn(k: i32) -> Result { + | ^^^^^ diff --git a/tests/ui/concurrent_cached_sync_writes_attr_unsupported.rs b/tests/ui/concurrent_cached_sync_writes_attr_unsupported.rs new file mode 100644 index 00000000..c5cda8fe --- /dev/null +++ b/tests/ui/concurrent_cached_sync_writes_attr_unsupported.rs @@ -0,0 +1,8 @@ +use cached::macros::concurrent_cached; + +#[concurrent_cached(sync_writes = true)] +fn load(id: u64) -> u64 { + id +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_sync_writes_attr_unsupported.stderr b/tests/ui/concurrent_cached_sync_writes_attr_unsupported.stderr new file mode 100644 index 00000000..d8c198f4 --- /dev/null +++ b/tests/ui/concurrent_cached_sync_writes_attr_unsupported.stderr @@ -0,0 +1,5 @@ +error: `sync_writes` is not supported by #[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_custom_ty_required.rs b/tests/ui/concurrent_cached_ttl_zero.rs similarity index 72% rename from tests/ui/concurrent_cached_custom_ty_required.rs rename to tests/ui/concurrent_cached_ttl_zero.rs index 103046ab..eabf2ceb 100644 --- a/tests/ui/concurrent_cached_custom_ty_required.rs +++ b/tests/ui/concurrent_cached_ttl_zero.rs @@ -1,6 +1,6 @@ use cached::macros::concurrent_cached; -#[concurrent_cached(map_error = "|e| e")] +#[concurrent_cached(ttl = 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 new file mode 100644 index 00000000..a3af76d9 --- /dev/null +++ b/tests/ui/concurrent_cached_ttl_zero.stderr @@ -0,0 +1,5 @@ +error: `ttl` 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_with_cached_flag_foreign.stderr b/tests/ui/concurrent_cached_with_cached_flag_foreign.stderr index c0a9d4fc..50c93bb9 100644 --- a/tests/ui/concurrent_cached_with_cached_flag_foreign.stderr +++ b/tests/ui/concurrent_cached_with_cached_flag_foreign.stderr @@ -1,7 +1,9 @@ error: When specifying `with_cached_flag = true`, the return type must be wrapped in `cached::Return`. The following return types are supported: + | `cached::Return` | `Result, E>` + | `Option>` Found type: Result,String>. --> tests/ui/concurrent_cached_with_cached_flag_foreign.rs:10:21 | diff --git a/tests/ui/concurrent_cached_with_cached_flag_option.rs b/tests/ui/concurrent_cached_with_cached_flag_option.rs new file mode 100644 index 00000000..bfac5cbb --- /dev/null +++ b/tests/ui/concurrent_cached_with_cached_flag_option.rs @@ -0,0 +1,9 @@ +use cached::macros::concurrent_cached; + +// `Option>` with `cache_none = true` is not supported alongside `with_cached_flag`. +#[concurrent_cached(with_cached_flag = true, cache_none = true)] +fn my_fn(k: i32) -> Option> { + Some(cached::Return::new(k * 2)) +} + +fn main() {} diff --git a/tests/ui/concurrent_cached_with_cached_flag_option.stderr b/tests/ui/concurrent_cached_with_cached_flag_option.stderr new file mode 100644 index 00000000..fde2e9a9 --- /dev/null +++ b/tests/ui/concurrent_cached_with_cached_flag_option.stderr @@ -0,0 +1,5 @@ +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_err_requires_result_return.rs b/tests/ui/once_cache_err_requires_result_return.rs new file mode 100644 index 00000000..7dd725dc --- /dev/null +++ b/tests/ui/once_cache_err_requires_result_return.rs @@ -0,0 +1,8 @@ +use cached::macros::once; + +#[once(cache_err = true)] +fn my_fn() -> i32 { + 0 +} + +fn main() {} diff --git a/tests/ui/once_cache_err_requires_result_return.stderr b/tests/ui/once_cache_err_requires_result_return.stderr new file mode 100644 index 00000000..12e04918 --- /dev/null +++ b/tests/ui/once_cache_err_requires_result_return.stderr @@ -0,0 +1,5 @@ +error: `cache_err = true` requires the function to return `Result` + --> tests/ui/once_cache_err_requires_result_return.rs:4:4 + | +4 | fn my_fn() -> i32 { + | ^^^^^ diff --git a/tests/ui/once_cache_none_requires_option_return.rs b/tests/ui/once_cache_none_requires_option_return.rs new file mode 100644 index 00000000..61df9b7e --- /dev/null +++ b/tests/ui/once_cache_none_requires_option_return.rs @@ -0,0 +1,8 @@ +use cached::macros::once; + +#[once(cache_none = true)] +fn my_fn() -> i32 { + 0 +} + +fn main() {} diff --git a/tests/ui/once_cache_none_requires_option_return.stderr b/tests/ui/once_cache_none_requires_option_return.stderr new file mode 100644 index 00000000..9cc5aa5a --- /dev/null +++ b/tests/ui/once_cache_none_requires_option_return.stderr @@ -0,0 +1,5 @@ +error: `cache_none = true` requires the function to return `Option` + --> tests/ui/once_cache_none_requires_option_return.rs:4:4 + | +4 | fn my_fn() -> i32 { + | ^^^^^ diff --git a/tests/ui/once_cache_none_with_cached_flag_exclusive.rs b/tests/ui/once_cache_none_with_cached_flag_exclusive.rs new file mode 100644 index 00000000..d99750ee --- /dev/null +++ b/tests/ui/once_cache_none_with_cached_flag_exclusive.rs @@ -0,0 +1,8 @@ +use cached::macros::once; + +#[once(cache_none = true, with_cached_flag = true)] +fn my_fn() -> Option> { + Some(cached::Return::new(0)) +} + +fn main() {} diff --git a/tests/ui/once_cache_none_with_cached_flag_exclusive.stderr b/tests/ui/once_cache_none_with_cached_flag_exclusive.stderr new file mode 100644 index 00000000..9e0d1e6a --- /dev/null +++ b/tests/ui/once_cache_none_with_cached_flag_exclusive.stderr @@ -0,0 +1,5 @@ +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_option_attr_removed.rs b/tests/ui/once_option_attr_removed.rs new file mode 100644 index 00000000..ed3add11 --- /dev/null +++ b/tests/ui/once_option_attr_removed.rs @@ -0,0 +1,8 @@ +use cached::macros::once; + +#[once(option = true)] +fn find() -> Option { + Some(0) +} + +fn main() {} diff --git a/tests/ui/once_option_attr_removed.stderr b/tests/ui/once_option_attr_removed.stderr new file mode 100644 index 00000000..9b0c13b2 --- /dev/null +++ b/tests/ui/once_option_attr_removed.stderr @@ -0,0 +1,5 @@ +error: the `option` attribute has been removed. `Option` returns now skip caching `None` by default. Remove `option = true` (or `option = false`), or use `cache_none = true` to force-cache `None` values. + --> tests/ui/once_option_attr_removed.rs:4:4 + | +4 | fn find() -> Option { + | ^^^^ diff --git a/tests/ui/once_result_no_return.rs b/tests/ui/once_result_attr_removed.rs similarity index 59% rename from tests/ui/once_result_no_return.rs rename to tests/ui/once_result_attr_removed.rs index 09034fa5..1f76e496 100644 --- a/tests/ui/once_result_no_return.rs +++ b/tests/ui/once_result_attr_removed.rs @@ -1,8 +1,8 @@ use cached::macros::once; #[once(result = true)] -fn my_fn(k: i32) { - let _ = k; +fn load() -> Result { + Ok(0) } fn main() {} diff --git a/tests/ui/once_result_attr_removed.stderr b/tests/ui/once_result_attr_removed.stderr new file mode 100644 index 00000000..fea90a18 --- /dev/null +++ b/tests/ui/once_result_attr_removed.stderr @@ -0,0 +1,5 @@ +error: the `result` attribute has been removed. `Result` returns now skip caching `Err` by default. Remove `result = true` (or `result = false`), or use `cache_err = true` to force-cache `Err` values. + --> tests/ui/once_result_attr_removed.rs:4:4 + | +4 | fn load() -> Result { + | ^^^^ diff --git a/tests/ui/once_result_no_return.stderr b/tests/ui/once_result_no_return.stderr deleted file mode 100644 index f18fab93..00000000 --- a/tests/ui/once_result_no_return.stderr +++ /dev/null @@ -1,7 +0,0 @@ -error: function must return something when `result` or `option` is set - --> tests/ui/once_result_no_return.rs:3:1 - | -3 | #[once(result = true)] - | ^^^^^^^^^^^^^^^^^^^^^^ - | - = note: this error originates in the attribute macro `once` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/tests/ui/once_result_option_exclusive.rs b/tests/ui/once_result_option_exclusive.rs deleted file mode 100644 index 10b2beb0..00000000 --- a/tests/ui/once_result_option_exclusive.rs +++ /dev/null @@ -1,8 +0,0 @@ -use cached::macros::once; - -#[once(result = true, option = true)] -fn my_fn(k: i32) -> Result { - Ok(k) -} - -fn main() {} diff --git a/tests/ui/once_result_option_exclusive.stderr b/tests/ui/once_result_option_exclusive.stderr deleted file mode 100644 index f502596c..00000000 --- a/tests/ui/once_result_option_exclusive.stderr +++ /dev/null @@ -1,5 +0,0 @@ -error: the `result` and `option` attributes are mutually exclusive - --> tests/ui/once_result_option_exclusive.rs:4:21 - | -4 | fn my_fn(k: i32) -> Result { - | ^^^^^^ diff --git a/tests/ui/result_fallback_unbound_cache.rs b/tests/ui/result_fallback_unbound_cache.rs index cc727016..3a7dff97 100644 --- a/tests/ui/result_fallback_unbound_cache.rs +++ b/tests/ui/result_fallback_unbound_cache.rs @@ -1,6 +1,6 @@ use cached::macros::cached; -#[cached(result = true, result_fallback = true)] +#[cached(result_fallback = true)] fn my_fn(k: i32) -> Result { Ok(k) } diff --git a/tests/ui/result_fallback_without_result.stderr b/tests/ui/result_fallback_without_result.stderr index 7776b237..3d096e7e 100644 --- a/tests/ui/result_fallback_without_result.stderr +++ b/tests/ui/result_fallback_without_result.stderr @@ -1,4 +1,4 @@ -error: `result_fallback` requires `result = true` because it falls back from `Err` to a cached `Ok` value +error: `result_fallback` requires a `Result` return type --> tests/ui/result_fallback_without_result.rs:4:4 | 4 | fn my_fn(k: i32) -> i32 {