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 +4 -4
- data/.github/workflows/ci.yml +29 -2
- data/.github/workflows/release.yml +5 -1
- data/CHANGELOG.md +108 -100
- data/README.md +10 -0
- data/ROADMAP.md +92 -0
- data/lib/safe_memoize/class_methods.rb +10 -1
- data/lib/safe_memoize/configuration.rb +3 -1
- data/lib/safe_memoize/hooks_methods.rb +10 -1
- data/lib/safe_memoize/inspection_methods.rb +14 -1
- data/lib/safe_memoize/public_methods.rb +43 -0
- data/lib/safe_memoize/version.rb +1 -1
- data/lib/safe_memoize.rb +6 -0
- data/sig/safe_memoize.rbs +6 -1
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 62f1dae5d57d99d59fe20d981bca17d04bd1ff5b9dd09175770a51bf3d49dd03
|
|
4
|
+
data.tar.gz: fade31de5124ed4b4f5d88f8bd62aaf750ec03cb22bdf97889fed2283ad3a58d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e742795adaef41bc07e32d1ffb0a1f78335eae46daca57dca3b6b27cf4fc1eb6a58e1636e05d21814336ab3e54468b6ffbe0a4ea52f017c00b0df1a70b98eaeb
|
|
7
|
+
data.tar.gz: 92b1cb813ed3c54e18408fed0f79f2e9f4983672660f6ee1cf6c0ec9628577fbebf1055797e9d9a4c53ca5b293427c66d9c946a770a443e77b98c77c0937d244
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -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
|
|
36
|
-
run: bundle exec
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
-
|
|
16
|
-
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
-
|
|
23
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
-
|
|
62
|
-
-
|
|
63
|
-
-
|
|
64
|
-
-
|
|
65
|
-
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
-
|
|
129
|
-
-
|
|
130
|
-
-
|
|
131
|
-
-
|
|
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
|
-
|
|
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
|
+
[](https://github.com/eclectic-coding/safe_memoize/actions/workflows/ci.yml)
|
|
4
|
+
[](https://rubygems.org/gems/safe_memoize)
|
|
5
|
+
[](https://rubygems.org/gems/safe_memoize)
|
|
6
|
+
[](https://www.ruby-lang.org)
|
|
7
|
+
[](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
|
|
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
|
data/lib/safe_memoize/version.rb
CHANGED
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.
|
|
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
|
-
-
|
|
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
|