safe_memoize 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 41e3b8e3232ef52d536166c4e701a99a8d7a1e86e2c6d07493d1838d904dbef6
4
- data.tar.gz: dac922ad348d3ccf14d8946035b5a0e7709cacc899dcc6e0713da77c980ac6ca
3
+ metadata.gz: 62f1dae5d57d99d59fe20d981bca17d04bd1ff5b9dd09175770a51bf3d49dd03
4
+ data.tar.gz: fade31de5124ed4b4f5d88f8bd62aaf750ec03cb22bdf97889fed2283ad3a58d
5
5
  SHA512:
6
- metadata.gz: a7f263ac2b16ff248ccf6afa158aaaae5a7fd69cadef68ff6926c3d96257378df8d4295f2eac8278a68de12492128282c1ffea42da95c178de7e5619981c8634
7
- data.tar.gz: b50610d8ac36d0c7ff4fe9ed174a7f7864654f4178bc5778a9555eda1d934320b7ee0bcf4de53919e9dfacc35d06d8320f0b9dc8e8a25d6fab6808dfb0c0aab4
6
+ metadata.gz: e742795adaef41bc07e32d1ffb0a1f78335eae46daca57dca3b6b27cf4fc1eb6a58e1636e05d21814336ab3e54468b6ffbe0a4ea52f017c00b0df1a70b98eaeb
7
+ data.tar.gz: 92b1cb813ed3c54e18408fed0f79f2e9f4983672660f6ee1cf6c0ec9628577fbebf1055797e9d9a4c53ca5b293427c66d9c946a770a443e77b98c77c0937d244
@@ -10,7 +10,27 @@ on:
10
10
  permissions:
11
11
  contents: read
12
12
 
13
+ env:
14
+ FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
15
+
13
16
  jobs:
17
+ lint:
18
+ name: Lint
19
+ runs-on: ubuntu-latest
20
+
21
+ steps:
22
+ - name: Check out repository
23
+ uses: actions/checkout@v5
24
+
25
+ - name: Set up Ruby
26
+ uses: ruby/setup-ruby@v1
27
+ with:
28
+ ruby-version: "3.4"
29
+ bundler-cache: true
30
+
31
+ - name: Run StandardRB
32
+ run: bundle exec standardrb
33
+
14
34
  test:
15
35
  name: Ruby ${{ matrix.ruby }}
16
36
  runs-on: ubuntu-latest
@@ -32,6 +52,13 @@ jobs:
32
52
  ruby-version: ${{ matrix.ruby }}
33
53
  bundler-cache: true
34
54
 
35
- - name: Run test and lint suite
36
- run: bundle exec rake
55
+ - name: Run test suite
56
+ run: bundle exec rspec
57
+
58
+ - name: Upload coverage to Codecov
59
+ uses: codecov/codecov-action@v5
60
+ if: matrix.ruby == '3.4'
61
+ with:
62
+ token: ${{ secrets.CODECOV_TOKEN }}
63
+ files: coverage/.resultset.json
37
64
 
@@ -7,6 +7,7 @@ on:
7
7
 
8
8
  permissions:
9
9
  contents: write
10
+ id-token: write
10
11
 
11
12
  jobs:
12
13
  release:
@@ -66,10 +67,13 @@ jobs:
66
67
  RELEASE_TAG: ${{ github.ref_name }}
67
68
  run: echo "RubyGems already has version ${RELEASE_TAG#v}; skipping gem push."
68
69
 
70
+ - name: Configure RubyGems credentials (trusted publishing)
71
+ if: steps.rubygems.outputs.already_published != 'true'
72
+ uses: rubygems/configure-rubygems-credentials@v1.0.0
73
+
69
74
  - name: Publish gem to RubyGems
70
75
  if: steps.rubygems.outputs.already_published != 'true'
71
76
  env:
72
- GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
73
77
  RELEASE_TAG: ${{ github.ref_name }}
74
78
  run: |
75
79
  version="${RELEASE_TAG#v}"
data/CHANGELOG.md CHANGED
@@ -1,139 +1,147 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
7
+ from v1.0.0 onwards. Prior 0.x releases may include breaking changes between minor versions.
8
+
1
9
  ## [Unreleased]
2
10
 
11
+ ## [0.8.0] - 2026-05-21
12
+
13
+ ### Added
14
+
15
+ - Raise `ArgumentError` at definition time when `memoize` is called on a method that does not exist on the class — previously the error only surfaced at runtime when `super` had nothing to call
16
+ - Key serialization safety: argument arrays, hashes, and strings are deep-frozen into an independent copy when the cache key is built, so callers that mutate their arguments after a call can no longer corrupt or miss the cached entry
17
+ - `memo_inspect` — single-entry deep-inspection helper returning all metadata for one cached call in one mutex-held read: `cached`, `value`, `hits`, `misses`, `ttl_remaining`, `age`, `custom_key`, and `lru_position`; returns `nil` when the entry is not cached
18
+ - Deprecation infrastructure: `SafeMemoize.deprecate(subject, message:, horizon:)` emits a structured `[SafeMemoize]` warning to stderr by default; configurable via `SafeMemoize.configure { |c| c.on_deprecation = ->(msg) { ... } }` to raise, log, or collect warnings
19
+ - `memoize_all only:` — symmetric counterpart to `except:`; explicitly lists the methods to memoize and skips all others; raises `ArgumentError` when both `only:` and `except:` are given
20
+ - Hook error isolation: exceptions raised inside lifecycle hooks no longer propagate to the caller; by default a `[SafeMemoize] Hook error in <type>: <message>` warning is emitted to stderr; configurable via `SafeMemoize.configure { |c| c.on_hook_error = ->(error, hook_type, cache_key) { ... } }` to raise, log, or silence
21
+
3
22
  ## [0.7.0] - 2026-05-18
4
23
 
5
- - Add `memo_preload` to batch-warm multiple cache entries in one call
6
- - `obj.memo_preload(:find, [1], [2], [3])` calls the memoized method for each arg set and caches all results
7
- - Returns an array of results in the same order as the input arg sets
8
- - Computes each entry only once subsequent calls return from cache
9
- - Add `on_memo_store` hook that fires whenever a value is written to the cache
10
- - Fires on every cache miss (fast path and LRU path)
11
- - Also fires when entries are written via `warm_memo` or `load_memo`
12
- - Does not fire on cache hits or when a conditional `:if`/`:unless` prevents storing
13
- - Fires on the calling instance for `shared: true` misses
14
- - Completes the full lifecycle hook set: `on_store`, `on_hit`, `on_miss`, `on_expire`, `on_evict`
15
- - Add per-method `cache_metrics_reset(:method)` to clear stats for a single method without wiping the rest
16
- - `cache_metrics_reset` (no args) still clears all metrics as before
17
- - Add `SafeMemoize.configure` for global default options
18
- - `SafeMemoize.configure { |c| c.default_ttl = 60 }` applies a TTL to all subsequently memoized methods
19
- - `SafeMemoize.configure { |c| c.default_max_size = 100 }` sets a global LRU size limit
20
- - Per-call options (`ttl:`, `max_size:`) override the global defaults
21
- - `SafeMemoize.reset_configuration!` restores defaults to `nil`
22
- - Add `memo_touch` to reset the expiry clock on a cached entry without recomputing
23
- - `memo_touch(:method, *args)` extends the entry's TTL from now using the original TTL window
24
- - `memo_touch(:method, *args, ttl: 30)` sets a new TTL explicitly
25
- - Returns `true` on success, `false` if the entry is not cached or already expired
26
- - Add `shared_memo_age` class method to inspect how long ago a shared entry was cached
27
- - Add `shared_memo_stale?` class method to check whether a shared entry's TTL has elapsed
28
- - Update RBS type signatures for all new methods and the `Configuration` class
29
- - Add `key:` option to `memoize` for class-level cache key generation
30
- - `memoize :method, key: ->(a, b) { a }` defines a key generator at the class level — calls whose key block returns the same value share one cache entry
31
- - Instance-level `memoize_with_custom_key` still takes priority over `key:`
32
- - Composes with all existing options (`ttl:`, `max_size:`, `shared:`, `if:`, etc.)
33
- - Raises `ArgumentError` if `key:` is not callable
34
- - Add `shared:` support to `memoize_all` (was already functional via `**options` passthrough; now tested and documented)
35
- - Add `memo_refresh` to force-recompute a cached entry and store the new value in one call
36
- - Add `memo_age` to return how many seconds ago an entry was cached (`nil` if not cached or expired)
37
- - Add `memo_stale?` to check whether a cached entry exists but its TTL has elapsed
24
+ ### Added
25
+
26
+ - `memo_preload` to batch-warm multiple cache entries in one call `obj.memo_preload(:find, [1], [2], [3])` calls the memoized method for each arg set, caches all results, and returns them in input order
27
+ - `on_memo_store` hook that fires whenever a value is written to the cache (miss, `warm_memo`, or `load_memo`); completes the full lifecycle hook set alongside `on_hit`, `on_miss`, `on_expire`, and `on_evict`
28
+ - `SafeMemoize.configure` for global default options `default_ttl` and `default_max_size` apply to all subsequently memoized methods; per-call options override the global defaults
29
+ - `SafeMemoize.reset_configuration!` to restore all global defaults to `nil`
30
+ - `memo_touch` to reset the expiry clock on a cached entry without recomputing — accepts an optional `ttl:` override; returns `true` on success, `false` if the entry is not cached or already expired
31
+ - `shared_memo_age` class method to inspect how long ago a shared entry was cached
32
+ - `shared_memo_stale?` class method to check whether a shared entry's TTL has elapsed
33
+ - `key:` option on `memoize` for class-level cache key generation — calls whose key block returns the same value share one cache entry; instance-level `memoize_with_custom_key` still takes priority
34
+ - `memo_refresh` to force-recompute a cached entry and store the new value in one call
35
+ - `memo_age` to return how many seconds ago an entry was cached (`nil` if not cached or expired)
36
+ - `memo_stale?` to check whether a cached entry exists but its TTL has elapsed
37
+
38
+ ### Changed
39
+
40
+ - `cache_metrics_reset` now accepts an optional method name to clear stats for a single method only; calling without arguments still clears all metrics
41
+ - `shared:` support in `memoize_all` is now tested and documented (was already functional via `**options` passthrough)
42
+ - RBS type signatures updated for all new methods and the `Configuration` class
38
43
 
39
44
  ## [0.6.3] - 2026-05-18
40
45
 
46
+ ### Changed
47
+
41
48
  - Upgrade `softprops/action-gh-release` from v2 to v3 to resolve Node.js 20 deprecation warning in release workflow
42
49
 
43
50
  ## [0.6.2] - 2026-05-18
44
51
 
45
- - Achieve 100% line coverage across all lib files
46
- - Add SimpleCov filter to exclude `/spec` from coverage reporting
47
- - Add tests for `memo_ttl` in `CacheRecordMethods` covering nil, valid numeric, negative, and non-numeric inputs
48
- - Add tests for private `memo_cache_read` in `CacheStoreMethods` covering nil cache, live hit, and expired entry
49
- - Add tests for `memo_keys` / `memo_values` with custom-key entries, covering the `custom_key:` projection branch in `InspectionMethods`
50
- - Add missing error-case tests for `ReleaseTooling.update_version_file` (no VERSION constant) and `finalize_changelog` (no Unreleased heading)
52
+ ### Added
53
+
54
+ - 100% line coverage across all lib files — added tests for edge cases in `CacheRecordMethods`, `CacheStoreMethods`, `InspectionMethods`, and `ReleaseTooling`; added SimpleCov filter to exclude `/spec` from coverage reporting
51
55
 
52
56
  ## [0.6.1] - 2026-05-17
53
57
 
54
- - Fix `memo_keys` and `memo_values` showing `args: custom_key, kwargs: nil` for methods using `memoize_with_custom_key` — now surfaces as `custom_key:`
55
- - Refactor `cache_stats` / `cache_stats_for` to share aggregation logic via private helpers
58
+ ### Changed
59
+
60
+ - Refactored `cache_stats` / `cache_stats_for` to share aggregation logic via private helpers
61
+
62
+ ### Fixed
63
+
64
+ - `memo_keys` and `memo_values` showed `args: custom_key, kwargs: nil` for methods using `memoize_with_custom_key` — now correctly surfaces as `custom_key:`
56
65
 
57
66
  ## [0.6.0] - 2026-05-17
58
67
 
59
- - Fix TTL clock starting at `memoize` definition time instead of first method call
60
- - Fix metrics key silently dropping kwargs, causing methods that differ only in kwargs to share a metrics bucket
61
- - Fix stale LRU references remaining after expired entries are pruned
62
- - Add `ttl:` option to `warm_memo` so warmed entries can be given an expiry
63
- - Add `max_size:` support for `shared: true` memoization (class-level LRU eviction)
64
- - Add `ttl_refresh: true` option on `memoize` for sliding window TTL — resets expiry on every cache hit
65
- - Add `include_protected:` and `include_private:` options to `memoize_all`
66
- - Add `memo_ttl_remaining` for TTL introspection — returns seconds until expiry, `nil` for no TTL, `0` for uncached/expired
68
+ ### Added
69
+
70
+ - `ttl:` option on `warm_memo` so warmed entries can be given an expiry
71
+ - `max_size:` support for `shared: true` memoization (class-level LRU eviction)
72
+ - `ttl_refresh: true` option on `memoize` for sliding window TTL — resets the expiry clock on every cache hit so the entry only expires after a full TTL of inactivity
73
+ - `include_protected:` and `include_private:` options on `memoize_all`
74
+ - `memo_ttl_remaining` for TTL introspection — returns seconds until expiry, `nil` for no TTL, `0` for uncached or expired
75
+
76
+ ### Fixed
77
+
78
+ - TTL clock started at `memoize` definition time instead of at first method call
79
+ - Metrics key silently dropped kwargs, causing methods that differ only in kwargs to share a metrics bucket
80
+ - Stale LRU references remained in the order list after expired entries were pruned
67
81
 
68
82
  ## [0.5.0] - 2026-05-17
69
83
 
70
- - Drop support for Ruby 3.2 (EOL); minimum required version is now Ruby 3.3
84
+ ### Removed
85
+
86
+ - Support for Ruby 3.2 (EOL); minimum required version is now Ruby 3.3
71
87
 
72
88
  ## [0.4.0] - 2026-05-17
73
89
 
74
- - Add `warm_memo`, `dump_memo`, and `load_memo` for cache warm-up and persistence
75
- - `warm_memo(:method, *args, **kwargs) { value }` — pre-populates a cache entry via block without calling the method
76
- - `dump_memo` / `dump_memo(:method)` — exports live cached entries as a plain `{[method, args, kwargs] => value}` hash
77
- - `load_memo(snapshot)` merges a snapshot into the cache; loaded entries have no TTL
78
- - Expired entries are excluded from `dump_memo` output
79
- - Add `shared: true` option on `memoize` to store results on the class instead of per-instance
80
- - All instances share one cache; the method is computed only once regardless of how many objects exist
81
- - Class-level invalidation: `reset_shared_memo`, `reset_all_shared_memos`
82
- - Class-level inspection: `shared_memoized?`, `shared_memo_count`
83
- - Supports `ttl:`, `if:`, and `unless:` options
84
- - Instance hooks (`on_memo_hit`, `on_memo_miss`, `on_memo_expire`) fire on the calling instance
85
- - Add `memoize_all` to memoize every public method defined on the class in one call
86
- - Accepts all options supported by `memoize` (`ttl:`, `max_size:`, `if:`, `unless:`)
87
- - `except:` option to skip specific methods by name
88
- - Only affects public methods defined directly on the class
89
- - Add `on_memo_miss` hook that fires on every cache miss, completing the full lifecycle hook set alongside `on_memo_hit`, `on_memo_evict`, and `on_memo_expire`
90
+ ### Added
91
+
92
+ - `warm_memo`, `dump_memo`, and `load_memo` for cache warm-up and persistence pre-populate entries without calling the method, export live entries as a plain hash, and restore from a snapshot
93
+ - `shared: true` option on `memoize` to store results on the class instead of per-instance includes `reset_shared_memo`, `reset_all_shared_memos`, `shared_memoized?`, and `shared_memo_count`; supports `ttl:`, `if:`, and `unless:`
94
+ - `memoize_all` to memoize every public method defined on the class in one call — accepts all `memoize` options plus `except:` to skip specific methods
95
+ - `on_memo_miss` hook that fires on every cache miss, completing the full lifecycle hook set
90
96
 
91
97
  ## [0.3.0] - 2026-05-15
92
98
 
93
- - Add `on_memo_hit` hook that fires on every cache hit, completing the lifecycle API alongside `on_memo_expire` and `on_memo_evict`
94
- - Add conditional memoization via `if:` and `unless:` options on `memoize`
95
- - `if: ->(result) { ... }` only caches when the lambda returns truthy
96
- - `unless: ->(result) { ... }` — skips caching when the lambda returns truthy
97
- - Uncached calls recompute on every invocation until the condition is met
98
- - Compatible with `ttl:`, `max_size:`, hooks, and all inspection APIs
99
- - Add LRU cache size limit via `max_size:` option on `memoize`
100
- - Evicts the least-recently-used entry per method when the limit is reached
101
- - Cache hits promote entries to most-recently-used, preventing premature eviction
102
- - Fires the existing `on_evict` hook for LRU-evicted entries
103
- - Self-healing: stale LRU references left by `reset_memo` are pruned automatically
104
- - Compatible with `ttl:` option and all existing inspection/reset APIs
105
- - Thread-safe under concurrent access
99
+ ### Added
100
+
101
+ - `on_memo_hit` hook that fires on every cache hit
102
+ - Conditional memoization via `if:` and `unless:` predicates on `memoize`uncached calls recompute on every invocation until the condition is satisfied; composes with `ttl:`, `max_size:`, and hooks
103
+ - LRU cache size limit via `max_size:` on `memoize` evicts the least-recently-used entry when the limit is reached; cache hits promote entries; fires `on_evict`; thread-safe
106
104
 
107
105
  ## [0.2.0] - 2026-05-14
108
106
 
109
- - Add optional TTL expiration support for memoized entries
110
- - Add cache invalidation/expiration hooks for custom handlers
111
- - `on_memo_expire` hook fires when TTL entries expire
112
- - `on_memo_evict` hook fires when manually resetting cache entries
113
- - `clear_memo_hooks` to remove registered hooks
114
- - Add cache statistics and monitoring capabilities
115
- - `cache_stats` for comprehensive cache metrics
116
- - `cache_stats_for(method_name)` for per-method statistics
117
- - `cache_hit_rate` and `cache_miss_rate` for performance analysis
118
- - `cache_metrics_reset` to clear collected metrics
119
- - Add manual cache key generation support
120
- - `memoize_with_custom_key` to define custom cache key logic
121
- - `clear_custom_keys` to remove custom key generators
122
- - Support for complex and computed keys based on arguments
107
+ ### Added
108
+
109
+ - Optional TTL expiration for memoized entries
110
+ - `on_memo_expire` and `on_memo_evict` lifecycle hooks; `clear_memo_hooks` to remove registered hooks
111
+ - Cache metrics: `cache_stats`, `cache_stats_for`, `cache_hit_rate`, `cache_miss_rate`, and `cache_metrics_reset`
112
+ - Custom cache key generation via `memoize_with_custom_key` and `clear_custom_keys`
123
113
 
124
114
  ## [0.1.2] - 2026-05-13
125
115
 
126
- - Preserve public, protected, and private visibility for memoized methods
127
- - Allow reset_memo to clear one cached argument combination or all entries for a method
128
- - Add a memoized? helper for checking whether a method call is already cached
129
- - Add a memo_count helper for inspecting cache size per instance or method
130
- - Add a memo_keys helper for inspecting cached argument signatures
131
- - Add a memo_values helper for inspecting cached signatures and their values
116
+ ### Added
117
+
118
+ - Method visibility preservation (public, protected, private) for memoized methods
119
+ - Targeted `reset_memo` clear one cached argument combination or all entries for a method
120
+ - `memoized?` helper to check whether a specific call is cached
121
+ - `memo_count`, `memo_keys`, and `memo_values` helpers for cache introspection
132
122
 
133
123
  ## [0.1.1] - 2026-05-13
134
124
 
135
- - Add automated release tooling plus a GitHub Actions workflow for RubyGems publishing and GitHub releases
125
+ ### Added
126
+
127
+ - Automated release tooling (`bin/release`) and GitHub Actions workflow for RubyGems publishing and GitHub releases
136
128
 
137
129
  ## [0.1.0] - 2026-02-26
138
130
 
131
+ ### Added
132
+
139
133
  - Initial release
134
+
135
+ [Unreleased]: https://github.com/eclectic-coding/safe_memoize/compare/v0.7.0...HEAD
136
+ [0.7.0]: https://github.com/eclectic-coding/safe_memoize/compare/v0.6.3...v0.7.0
137
+ [0.6.3]: https://github.com/eclectic-coding/safe_memoize/compare/v0.6.2...v0.6.3
138
+ [0.6.2]: https://github.com/eclectic-coding/safe_memoize/compare/v0.6.1...v0.6.2
139
+ [0.6.1]: https://github.com/eclectic-coding/safe_memoize/compare/v0.6.0...v0.6.1
140
+ [0.6.0]: https://github.com/eclectic-coding/safe_memoize/compare/v0.5.0...v0.6.0
141
+ [0.5.0]: https://github.com/eclectic-coding/safe_memoize/compare/v0.4.0...v0.5.0
142
+ [0.4.0]: https://github.com/eclectic-coding/safe_memoize/compare/v0.3.0...v0.4.0
143
+ [0.3.0]: https://github.com/eclectic-coding/safe_memoize/compare/v0.2.0...v0.3.0
144
+ [0.2.0]: https://github.com/eclectic-coding/safe_memoize/compare/v0.1.2...v0.2.0
145
+ [0.1.2]: https://github.com/eclectic-coding/safe_memoize/compare/v0.1.1...v0.1.2
146
+ [0.1.1]: https://github.com/eclectic-coding/safe_memoize/compare/v0.1.0...v0.1.1
147
+ [0.1.0]: https://github.com/eclectic-coding/safe_memoize/releases/tag/v0.1.0
data/README.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # SafeMemoize
2
2
 
3
+ [![CI](https://github.com/eclectic-coding/safe_memoize/actions/workflows/ci.yml/badge.svg)](https://github.com/eclectic-coding/safe_memoize/actions/workflows/ci.yml)
4
+ [![Gem Version](https://img.shields.io/gem/v/safe_memoize)](https://rubygems.org/gems/safe_memoize)
5
+ [![Total Downloads](https://img.shields.io/gem/dt/safe_memoize)](https://rubygems.org/gems/safe_memoize)
6
+ [![Ruby](https://img.shields.io/badge/ruby-%3E%3D%203.3-CC342D)](https://www.ruby-lang.org)
7
+ [![codecov](https://codecov.io/gh/eclectic-coding/safe_memoize/graph/badge.svg)](https://codecov.io/gh/eclectic-coding/safe_memoize)
8
+
3
9
  Thread-safe memoization for Ruby that correctly handles `nil` and `false` values.
4
10
 
5
11
  SafeMemoize is a production-ready, zero-dependency memoization library for Ruby. It wraps methods with a `prepend`-based cache that handles everything the standard `||=` idiom gets wrong: `nil` and `false` return values are cached correctly, per-argument result maps eliminate redundant computation for parameterized methods, and a per-instance `Mutex` with double-check locking makes the whole thing safe under concurrent load.
@@ -562,6 +568,10 @@ To preview the changelog/version update without changing anything, use:
562
568
  bin/release 0.1.1 --dry-run
563
569
  ```
564
570
 
571
+ ## Roadmap
572
+
573
+ See [ROADMAP.md](ROADMAP.md) for the planned path from v0.7.0 to v1.0.0 and beyond, including upcoming features, API stability goals, and the versioning policy.
574
+
565
575
  ## Contributing
566
576
 
567
577
  Bug reports and pull requests are welcome on GitHub at https://github.com/eclectic-coding/safe_memoize.
data/ROADMAP.md ADDED
@@ -0,0 +1,92 @@
1
+ # SafeMemoize Roadmap
2
+
3
+ This document tracks the planned evolution of SafeMemoize through v1.0.0 and beyond. Items are grouped by release milestone; ordering within a milestone reflects priority, not a strict implementation sequence.
4
+
5
+ ---
6
+
7
+ ## v0.9.0 — Observability & Ecosystem Integration
8
+
9
+ *Goal: make SafeMemoize a first-class citizen in Rails/ActiveSupport stacks and in observability pipelines.*
10
+
11
+ | Feature | Description | Status |
12
+ |---|---|---|
13
+ | ActiveSupport::Notifications integration | Emit `cache.hit`, `cache.miss`, `cache.evict`, and `cache.expire` events when ActiveSupport is available (opt-in via configuration) | Planned |
14
+ | StatsD adapter | Thin optional module (`SafeMemoize::Adapters::StatsD`) that routes lifecycle hooks to a StatsD client with sensible metric names and tags | Planned |
15
+ | OpenTelemetry spans | Optional adapter (`SafeMemoize::Adapters::OpenTelemetry`) wrapping computation time in a trace span for distributed tracing pipelines | Planned |
16
+ | Rails request-scope helper | Guide + optional mixin for resetting instance memos at the end of each request (controller concern, middleware, or Active Model pattern) | Planned |
17
+ | Formal benchmark suite | `benchmarks/` directory with comparisons against `memery`, `memo_wise`, and raw `||=`, covering single-threaded throughput and contention under concurrent load | Planned |
18
+ | Concurrency stress tests | Dedicated spec suite hammering shared-cache paths and LRU eviction under high thread counts to surface race conditions | Planned |
19
+
20
+ ---
21
+
22
+ ## v1.0.0 — Stable API
23
+
24
+ *Goal: declare a stable, semver-governed public API that downstream code can depend on with confidence.*
25
+
26
+ | Feature | Description | Status |
27
+ |---|---|---|
28
+ | Semantic versioning guarantee | Document which constants, methods, and option keys are public API; breaking changes require a major bump henceforth | Planned |
29
+ | Complete RBS + Sorbet signatures | Cover all public methods including overloads for optional keyword arguments; publish `.rbi` stubs as a companion package if demand warrants | Planned |
30
+ | Full API reference | Generated documentation hosted on RubyDoc or a dedicated docs site; all public methods documented with parameter types, return values, and usage examples | Planned |
31
+ | Ractor compatibility audit | Investigate and either support Ractor-compatible operation (Mutex replacement, shared-cache storage) or document the limitation clearly | Planned |
32
+ | Ruby version policy | Formalise the supported Ruby version window and cadence for dropping EOL versions | Planned |
33
+ | Deprecation sweep | Resolve or formally deprecate any unstable internal APIs before the stable release | Planned |
34
+ | Upgrade guide | Document all breaking changes from 0.x and provide a migration path for users of deprecated behaviour | Planned |
35
+
36
+ ---
37
+
38
+ ## v1.1.0 — Pluggable Cache Stores
39
+
40
+ *Goal: allow the in-process hash cache to be swapped for an external store, enabling cross-process and distributed memoization.*
41
+
42
+ | Feature | Description | Status |
43
+ |---|---|---|
44
+ | Cache store adapter interface | Define a minimal read/write/delete/clear/keys contract that external backends must implement | Planned |
45
+ | `store:` option on `memoize` | Accept any store adapter object; defaults to the existing in-process hash store | Planned |
46
+ | Redis adapter | Reference implementation (`SafeMemoize::Stores::Redis`) with TTL, LRU-like expiry, and serialization handled transparently | Planned |
47
+ | Rails.cache adapter | Thin wrapper around `ActiveSupport::Cache::Store` for projects already using a configured Rails cache | Planned |
48
+ | Global default store | Set via `SafeMemoize.configure` — applies a default store to every memoized method without per-call configuration | Planned |
49
+
50
+ ---
51
+
52
+ ## v1.2.0 — Async & Fiber-Safe Memoization
53
+
54
+ *Goal: first-class support for Fiber-based concurrency frameworks (Async, Falcon, Rails async controllers).*
55
+
56
+ | Feature | Description | Status |
57
+ |---|---|---|
58
+ | Fiber-local memoization mode | `memoize :method, fiber_local: true` stores results in `Fiber[:safe_memoize_cache]` rather than instance variables, giving each fiber its own isolated cache automatically reset when the fiber terminates | Planned |
59
+ | Ractor-compatible shared cache | Revisit `shared: true` using `Ractor::TVar` or shareable frozen objects so class-level caches work across Ractors | Planned |
60
+ | concurrent-ruby integration | Optional adapter using `Concurrent::Map` and `Concurrent::ReentrantReadWriteLock` as a drop-in replacement for `Mutex` where higher read-concurrency is desirable | Planned |
61
+
62
+ ---
63
+
64
+ ## v2.0.0 — Next Generation (Long Horizon)
65
+
66
+ *Goal: incorporate real-world usage feedback, clean up accumulated API surface, and open a path for advanced extension.*
67
+
68
+ | Feature | Description | Status |
69
+ |---|---|---|
70
+ | Plugin / extension architecture | A formal `SafeMemoize::Extension` API so third-party gems can add new options, hooks, or store adapters without monkey-patching | Planned |
71
+ | DSL refinements | Evaluate alternative syntax proposals (`memoize_method`, block form, annotation approach) based on community feedback; introduce the preferred form with a migration path from the current API | Planned |
72
+ | Cross-instance cache sharing | Beyond the class-level `shared: true`, support explicitly named shared caches that span unrelated classes | Planned |
73
+ | Cache namespacing | Allow a namespace prefix on all keys for multi-tenant or versioned deployments (especially useful with external stores) | Planned |
74
+ | Automatic cache busting | Optional integration with ActiveRecord's `updated_at` timestamp so object mutations automatically invalidate their own cached entries | Planned |
75
+
76
+ ---
77
+
78
+ ## Versioning policy
79
+
80
+ SafeMemoize follows [Semantic Versioning](https://semver.org/) from v1.0.0 onwards:
81
+
82
+ - **Patch** (1.x.**y**) — bug fixes; no API changes
83
+ - **Minor** (1.**x**.0) — additive features; backward-compatible
84
+ - **Major** (**x**.0.0) — breaking changes; migration guide published
85
+
86
+ 0.x releases may include breaking changes between minor versions.
87
+
88
+ ---
89
+
90
+ ## Contributing
91
+
92
+ Ideas, bug reports, and pull requests are welcome. Open an issue at <https://github.com/eclectic-coding/safe_memoize/issues> to discuss a feature before building it. If you are picking up a roadmap item, mention the milestone in your PR so it can be tracked against this document.
@@ -4,6 +4,11 @@ module SafeMemoize
4
4
  module ClassMethods
5
5
  def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, unless: nil, shared: false, key: nil)
6
6
  method_name = method_name.to_sym
7
+
8
+ unless method_defined?(method_name) || private_method_defined?(method_name) || protected_method_defined?(method_name)
9
+ raise ArgumentError, "cannot memoize :#{method_name} — no instance method with that name is defined on #{self}"
10
+ end
11
+
7
12
  visibility = memoized_method_visibility(method_name)
8
13
 
9
14
  config = SafeMemoize.configuration
@@ -274,8 +279,11 @@ module SafeMemoize
274
279
  end
275
280
  end
276
281
 
277
- def memoize_all(except: [], include_protected: false, include_private: false, **options)
282
+ def memoize_all(except: [], only: [], include_protected: false, include_private: false, **options)
283
+ raise ArgumentError, "cannot specify both :only and :except" if only.any? && except.any?
284
+
278
285
  excluded = Array(except).map(&:to_sym)
286
+ included = Array(only).map(&:to_sym)
279
287
 
280
288
  methods = public_instance_methods(false)
281
289
  methods |= protected_instance_methods(false) if include_protected
@@ -283,6 +291,7 @@ module SafeMemoize
283
291
 
284
292
  methods.each do |method_name|
285
293
  next if excluded.include?(method_name)
294
+ next if included.any? && !included.include?(method_name)
286
295
 
287
296
  memoize(method_name, **options)
288
297
  end
@@ -2,11 +2,13 @@
2
2
 
3
3
  module SafeMemoize
4
4
  class Configuration
5
- attr_accessor :default_ttl, :default_max_size
5
+ attr_accessor :default_ttl, :default_max_size, :on_deprecation, :on_hook_error
6
6
 
7
7
  def initialize
8
8
  @default_ttl = nil
9
9
  @default_max_size = nil
10
+ @on_deprecation = nil
11
+ @on_hook_error = nil
10
12
  end
11
13
  end
12
14
  end
@@ -19,7 +19,16 @@ module SafeMemoize
19
19
 
20
20
  def call_memo_hooks(hook_type, cache_key, record)
21
21
  hooks = memo_hook_store[hook_type] || []
22
- hooks.each { |hook| hook.call(cache_key, record) }
22
+ hooks.each do |hook|
23
+ hook.call(cache_key, record)
24
+ rescue => error
25
+ handler = SafeMemoize.configuration.on_hook_error
26
+ if handler
27
+ handler.call(error, hook_type, cache_key)
28
+ else
29
+ warn "[SafeMemoize] Hook error in #{hook_type}: #{error.message}"
30
+ end
31
+ end
23
32
  end
24
33
 
25
34
  def _clear_memo_hooks(hook_type = nil)
@@ -69,7 +69,20 @@ module SafeMemoize
69
69
  end
70
70
 
71
71
  def safe_memo_cache_key(method_name, args, kwargs)
72
- [method_name.to_sym, args, kwargs]
72
+ [method_name.to_sym, deep_freeze_copy(args), deep_freeze_copy(kwargs)]
73
+ end
74
+
75
+ def deep_freeze_copy(obj)
76
+ case obj
77
+ when Array
78
+ obj.map { |e| deep_freeze_copy(e) }.freeze
79
+ when Hash
80
+ obj.each_with_object({}) { |(k, v), h| h[deep_freeze_copy(k)] = deep_freeze_copy(v) }.freeze
81
+ when String
82
+ -obj
83
+ else
84
+ obj
85
+ end
73
86
  end
74
87
  end
75
88
  end
@@ -226,5 +226,48 @@ module SafeMemoize
226
226
  lru_clear_all
227
227
  end
228
228
  end
229
+
230
+ def memo_inspect(method_name, *args, **kwargs)
231
+ method_name = method_name.to_sym
232
+ cache_key = compute_cache_key(method_name, args, kwargs)
233
+
234
+ with_memo_lock do
235
+ record = memo_cache_record(cache_key)
236
+ return nil unless record
237
+
238
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
239
+
240
+ ttl_remaining = if record[:expires_at]
241
+ remaining = record[:expires_at] - now
242
+ (remaining > 0) ? remaining.round(6) : 0
243
+ end
244
+
245
+ age = (now - record[:cached_at]).round(6) if record[:cached_at]
246
+
247
+ metrics_key = safe_memo_cache_key(method_name, args, kwargs)
248
+ entry_metrics = memo_metrics_store[metrics_key] || {hits: 0, misses: 0}
249
+
250
+ custom_key = (cache_key.length == 2) ? cache_key[1] : nil
251
+
252
+ lru_position = begin
253
+ method_lru = lru_order_store[method_name]
254
+ if method_lru&.key?(cache_key)
255
+ keys = method_lru.keys
256
+ keys.length - keys.index(cache_key)
257
+ end
258
+ end
259
+
260
+ {
261
+ cached: true,
262
+ value: memo_record_value(record),
263
+ hits: entry_metrics[:hits],
264
+ misses: entry_metrics[:misses],
265
+ ttl_remaining: ttl_remaining,
266
+ age: age,
267
+ custom_key: custom_key,
268
+ lru_position: lru_position
269
+ }
270
+ end
271
+ end
229
272
  end
230
273
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeMemoize
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
data/lib/safe_memoize.rb CHANGED
@@ -35,4 +35,10 @@ module SafeMemoize
35
35
  def self.reset_configuration!
36
36
  @configuration = Configuration.new
37
37
  end
38
+
39
+ def self.deprecate(subject, message:, horizon:)
40
+ text = "[SafeMemoize] #{subject} is deprecated and will be removed in #{horizon}. #{message}"
41
+ handler = configuration.on_deprecation
42
+ handler ? handler.call(text) : warn(text)
43
+ end
38
44
  end
data/sig/safe_memoize.rbs CHANGED
@@ -17,17 +17,20 @@ module SafeMemoize
17
17
  def self.configure: () { (Configuration) -> void } -> void
18
18
  def self.configuration: () -> Configuration
19
19
  def self.reset_configuration!: () -> Configuration
20
+ def self.deprecate: (String subject, message: String, horizon: String) -> void
20
21
 
21
22
  class Configuration
22
23
  attr_accessor default_ttl: Numeric?
23
24
  attr_accessor default_max_size: Integer?
25
+ attr_accessor on_deprecation: (^(String message) -> void)?
26
+ attr_accessor on_hook_error: (^(Exception error, Symbol hook_type, untyped cache_key) -> void)?
24
27
 
25
28
  def initialize: () -> void
26
29
  end
27
30
 
28
31
  module ClassMethods
29
32
  def memoize: (Symbol | String method_name, ?ttl: Numeric?, ?max_size: Integer?, ?ttl_refresh: bool, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool, ?key: (^(*untyped args, **untyped kwargs) -> untyped)?) -> void
30
- def memoize_all: (?except: Array[Symbol | String], ?include_protected: bool, ?include_private: bool, ?ttl: Numeric?, ?max_size: Integer?, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool, ?key: (^(*untyped args, **untyped kwargs) -> untyped)?) -> void
33
+ def memoize_all: (?except: Array[Symbol | String], ?only: Array[Symbol | String], ?include_protected: bool, ?include_private: bool, ?ttl: Numeric?, ?max_size: Integer?, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool, ?key: (^(*untyped args, **untyped kwargs) -> untyped)?) -> void
31
34
  def reset_shared_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
32
35
  def reset_all_shared_memos: () -> void
33
36
  def shared_memoized?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool
@@ -68,6 +71,7 @@ module SafeMemoize
68
71
  def memo_stale?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool
69
72
  def reset_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
70
73
  def reset_all_memos: () -> void
74
+ def memo_inspect: (Symbol | String method_name, *untyped args, **untyped kwargs) -> { cached: bool, value: untyped, hits: Integer, misses: Integer, ttl_remaining: Float?, age: Float?, custom_key: untyped, lru_position: Integer? }?
71
75
  end
72
76
 
73
77
  module CacheStoreMethods
@@ -108,6 +112,7 @@ module SafeMemoize
108
112
  def safe_memo_values_for: (Symbol? method_name) -> Array[untyped]
109
113
  def memo_projection: (memo_key cache_key, memo_record value, include_method: bool, include_value: bool) -> Hash[Symbol, untyped]
110
114
  def safe_memo_cache_key: (Symbol | String method_name, Array[untyped] args, Hash[Symbol, untyped] kwargs) -> default_memo_key
115
+ def deep_freeze_copy: (untyped obj) -> untyped
111
116
  end
112
117
 
113
118
  module HooksMethods
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: safe_memoize
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -34,7 +34,7 @@ description: 'SafeMemoize is a production-ready, zero-dependency memoization lib
34
34
  cache key generators, and introspection helpers. Method visibility (public, protected,
35
35
  private) is fully preserved.'
36
36
  email:
37
- - eclectic-coding@users.noreply.github.com
37
+ - chuck@eclecticcoding.com
38
38
  executables: []
39
39
  extensions: []
40
40
  extra_rdoc_files: []
@@ -44,6 +44,7 @@ files:
44
44
  - CHANGELOG.md
45
45
  - LICENSE.txt
46
46
  - README.md
47
+ - ROADMAP.md
47
48
  - Rakefile
48
49
  - lib/safe_memoize.rb
49
50
  - lib/safe_memoize/cache_metrics_methods.rb