safe_memoize 0.6.3 → 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 +111 -67
- data/README.md +10 -0
- data/ROADMAP.md +92 -0
- data/lib/safe_memoize/cache_metrics_methods.rb +6 -0
- data/lib/safe_memoize/cache_record_methods.rb +1 -1
- data/lib/safe_memoize/class_methods.rb +65 -3
- data/lib/safe_memoize/configuration.rb +14 -0
- data/lib/safe_memoize/custom_key_methods.rb +6 -9
- data/lib/safe_memoize/hooks_methods.rb +13 -4
- data/lib/safe_memoize/inspection_methods.rb +14 -1
- data/lib/safe_memoize/public_methods.rb +121 -2
- data/lib/safe_memoize/public_metrics_methods.rb +6 -2
- data/lib/safe_memoize/version.rb +1 -1
- data/lib/safe_memoize.rb +19 -0
- data/sig/safe_memoize.rbs +30 -6
- metadata +4 -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,103 +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
|
+
|
|
22
|
+
## [0.7.0] - 2026-05-18
|
|
23
|
+
|
|
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
|
|
43
|
+
|
|
3
44
|
## [0.6.3] - 2026-05-18
|
|
4
45
|
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
5
48
|
- Upgrade `softprops/action-gh-release` from v2 to v3 to resolve Node.js 20 deprecation warning in release workflow
|
|
6
49
|
|
|
7
50
|
## [0.6.2] - 2026-05-18
|
|
8
51
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
- Add tests for private `memo_cache_read` in `CacheStoreMethods` covering nil cache, live hit, and expired entry
|
|
13
|
-
- Add tests for `memo_keys` / `memo_values` with custom-key entries, covering the `custom_key:` projection branch in `InspectionMethods`
|
|
14
|
-
- 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
|
|
15
55
|
|
|
16
56
|
## [0.6.1] - 2026-05-17
|
|
17
57
|
|
|
18
|
-
|
|
19
|
-
|
|
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:`
|
|
20
65
|
|
|
21
66
|
## [0.6.0] - 2026-05-17
|
|
22
67
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
|
|
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
|
|
31
81
|
|
|
32
82
|
## [0.5.0] - 2026-05-17
|
|
33
83
|
|
|
34
|
-
|
|
84
|
+
### Removed
|
|
85
|
+
|
|
86
|
+
- Support for Ruby 3.2 (EOL); minimum required version is now Ruby 3.3
|
|
35
87
|
|
|
36
88
|
## [0.4.0] - 2026-05-17
|
|
37
89
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
-
|
|
44
|
-
- All instances share one cache; the method is computed only once regardless of how many objects exist
|
|
45
|
-
- Class-level invalidation: `reset_shared_memo`, `reset_all_shared_memos`
|
|
46
|
-
- Class-level inspection: `shared_memoized?`, `shared_memo_count`
|
|
47
|
-
- Supports `ttl:`, `if:`, and `unless:` options
|
|
48
|
-
- Instance hooks (`on_memo_hit`, `on_memo_miss`, `on_memo_expire`) fire on the calling instance
|
|
49
|
-
- Add `memoize_all` to memoize every public method defined on the class in one call
|
|
50
|
-
- Accepts all options supported by `memoize` (`ttl:`, `max_size:`, `if:`, `unless:`)
|
|
51
|
-
- `except:` option to skip specific methods by name
|
|
52
|
-
- Only affects public methods defined directly on the class
|
|
53
|
-
- 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
|
|
54
96
|
|
|
55
97
|
## [0.3.0] - 2026-05-15
|
|
56
98
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
- Compatible with `ttl:`, `max_size:`, hooks, and all inspection APIs
|
|
63
|
-
- Add LRU cache size limit via `max_size:` option on `memoize`
|
|
64
|
-
- Evicts the least-recently-used entry per method when the limit is reached
|
|
65
|
-
- Cache hits promote entries to most-recently-used, preventing premature eviction
|
|
66
|
-
- Fires the existing `on_evict` hook for LRU-evicted entries
|
|
67
|
-
- Self-healing: stale LRU references left by `reset_memo` are pruned automatically
|
|
68
|
-
- Compatible with `ttl:` option and all existing inspection/reset APIs
|
|
69
|
-
- 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
|
|
70
104
|
|
|
71
105
|
## [0.2.0] - 2026-05-14
|
|
72
106
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
-
|
|
79
|
-
- `cache_stats` for comprehensive cache metrics
|
|
80
|
-
- `cache_stats_for(method_name)` for per-method statistics
|
|
81
|
-
- `cache_hit_rate` and `cache_miss_rate` for performance analysis
|
|
82
|
-
- `cache_metrics_reset` to clear collected metrics
|
|
83
|
-
- Add manual cache key generation support
|
|
84
|
-
- `memoize_with_custom_key` to define custom cache key logic
|
|
85
|
-
- `clear_custom_keys` to remove custom key generators
|
|
86
|
-
- 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`
|
|
87
113
|
|
|
88
114
|
## [0.1.2] - 2026-05-13
|
|
89
115
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
-
|
|
93
|
-
-
|
|
94
|
-
-
|
|
95
|
-
-
|
|
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
|
|
96
122
|
|
|
97
123
|
## [0.1.1] - 2026-05-13
|
|
98
124
|
|
|
99
|
-
|
|
125
|
+
### Added
|
|
126
|
+
|
|
127
|
+
- Automated release tooling (`bin/release`) and GitHub Actions workflow for RubyGems publishing and GitHub releases
|
|
100
128
|
|
|
101
129
|
## [0.1.0] - 2026-02-26
|
|
102
130
|
|
|
131
|
+
### Added
|
|
132
|
+
|
|
103
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.
|
|
@@ -26,5 +26,11 @@ module SafeMemoize
|
|
|
26
26
|
def _reset_cache_metrics
|
|
27
27
|
@__safe_memo_metrics__ = {}
|
|
28
28
|
end
|
|
29
|
+
|
|
30
|
+
def _reset_cache_metrics_for(method_name)
|
|
31
|
+
return unless defined?(@__safe_memo_metrics__) && @__safe_memo_metrics__
|
|
32
|
+
|
|
33
|
+
@__safe_memo_metrics__.delete_if { |key, _| key[0] == method_name }
|
|
34
|
+
end
|
|
29
35
|
end
|
|
30
36
|
end
|
|
@@ -2,10 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
module SafeMemoize
|
|
4
4
|
module ClassMethods
|
|
5
|
-
def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, unless: nil, shared: false)
|
|
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
|
|
|
14
|
+
config = SafeMemoize.configuration
|
|
15
|
+
ttl = config.default_ttl if ttl.nil?
|
|
16
|
+
max_size = config.default_max_size if max_size.nil?
|
|
17
|
+
|
|
9
18
|
# :if and :unless are reserved Ruby keywords, so they can't be referenced
|
|
10
19
|
# as local variables directly. binding.local_variable_get is the only way
|
|
11
20
|
# to read keyword arguments with those names inside the method body.
|
|
@@ -38,6 +47,9 @@ module SafeMemoize
|
|
|
38
47
|
end
|
|
39
48
|
raise ArgumentError, ":if must be callable" if cond_if && !cond_if.respond_to?(:call)
|
|
40
49
|
raise ArgumentError, ":unless must be callable" if cond_unless && !cond_unless.respond_to?(:call)
|
|
50
|
+
raise ArgumentError, ":key must be callable" if key && !key.respond_to?(:call)
|
|
51
|
+
|
|
52
|
+
__safe_memo_class_key_generators__[method_name] = key if key
|
|
41
53
|
|
|
42
54
|
# Normalize to a single "should cache?" predicate
|
|
43
55
|
condition = if cond_if
|
|
@@ -79,7 +91,7 @@ module SafeMemoize
|
|
|
79
91
|
value = super(*args, **kwargs)
|
|
80
92
|
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
81
93
|
|
|
82
|
-
new_record =
|
|
94
|
+
new_record = memo_record(value, expires_at: memo_expires_at(ttl))
|
|
83
95
|
|
|
84
96
|
if !condition || condition.call(value)
|
|
85
97
|
if max_size
|
|
@@ -97,6 +109,7 @@ module SafeMemoize
|
|
|
97
109
|
lru = klass.send(:__safe_memo_shared_lru_order__)[method_name] ||= {}
|
|
98
110
|
lru[cache_key] = true
|
|
99
111
|
end
|
|
112
|
+
call_memo_hooks(:on_store, cache_key, new_record)
|
|
100
113
|
end
|
|
101
114
|
|
|
102
115
|
record_cache_miss(method_name, args, kwargs, elapsed_time)
|
|
@@ -143,6 +156,7 @@ module SafeMemoize
|
|
|
143
156
|
@__safe_memo_cache__ ||= {}
|
|
144
157
|
@__safe_memo_cache__[cache_key] = new_record
|
|
145
158
|
lru_touch(method_name, cache_key) if max_size
|
|
159
|
+
call_memo_hooks(:on_store, cache_key, new_record)
|
|
146
160
|
end
|
|
147
161
|
record_cache_miss(method_name, args, kwargs, elapsed_time)
|
|
148
162
|
call_memo_hooks(:on_miss, cache_key, new_record)
|
|
@@ -166,6 +180,7 @@ module SafeMemoize
|
|
|
166
180
|
with_memo_lock do
|
|
167
181
|
record_cache_miss(method_name, args, kwargs, elapsed_time)
|
|
168
182
|
new_record = memo_cache_record(cache_key)
|
|
183
|
+
call_memo_hooks(:on_store, cache_key, new_record)
|
|
169
184
|
call_memo_hooks(:on_miss, cache_key, new_record)
|
|
170
185
|
end
|
|
171
186
|
|
|
@@ -225,8 +240,50 @@ module SafeMemoize
|
|
|
225
240
|
end
|
|
226
241
|
end
|
|
227
242
|
|
|
228
|
-
def
|
|
243
|
+
def shared_memo_age(method_name, *args, **kwargs)
|
|
244
|
+
method_name = method_name.to_sym
|
|
245
|
+
cache_key = [method_name, args, kwargs]
|
|
246
|
+
|
|
247
|
+
__safe_memo_shared_mutex__.synchronize do
|
|
248
|
+
cache = @__safe_memo_shared_cache__
|
|
249
|
+
return nil unless cache
|
|
250
|
+
|
|
251
|
+
record = cache[cache_key]
|
|
252
|
+
return nil unless record
|
|
253
|
+
|
|
254
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
255
|
+
return nil if record[:expires_at] && record[:expires_at] <= now
|
|
256
|
+
|
|
257
|
+
cached_at = record[:cached_at]
|
|
258
|
+
return nil unless cached_at
|
|
259
|
+
|
|
260
|
+
(now - cached_at).round(6)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def shared_memo_stale?(method_name, *args, **kwargs)
|
|
265
|
+
method_name = method_name.to_sym
|
|
266
|
+
cache_key = [method_name, args, kwargs]
|
|
267
|
+
|
|
268
|
+
__safe_memo_shared_mutex__.synchronize do
|
|
269
|
+
cache = @__safe_memo_shared_cache__
|
|
270
|
+
return false unless cache
|
|
271
|
+
|
|
272
|
+
record = cache[cache_key]
|
|
273
|
+
return false unless record
|
|
274
|
+
|
|
275
|
+
expires_at = record[:expires_at]
|
|
276
|
+
return false unless expires_at
|
|
277
|
+
|
|
278
|
+
expires_at <= Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
|
|
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
|
+
|
|
229
285
|
excluded = Array(except).map(&:to_sym)
|
|
286
|
+
included = Array(only).map(&:to_sym)
|
|
230
287
|
|
|
231
288
|
methods = public_instance_methods(false)
|
|
232
289
|
methods |= protected_instance_methods(false) if include_protected
|
|
@@ -234,6 +291,7 @@ module SafeMemoize
|
|
|
234
291
|
|
|
235
292
|
methods.each do |method_name|
|
|
236
293
|
next if excluded.include?(method_name)
|
|
294
|
+
next if included.any? && !included.include?(method_name)
|
|
237
295
|
|
|
238
296
|
memoize(method_name, **options)
|
|
239
297
|
end
|
|
@@ -253,6 +311,10 @@ module SafeMemoize
|
|
|
253
311
|
@__safe_memo_shared_lru_order__ ||= {}
|
|
254
312
|
end
|
|
255
313
|
|
|
314
|
+
def __safe_memo_class_key_generators__
|
|
315
|
+
@__safe_memo_class_key_generators__ ||= {}
|
|
316
|
+
end
|
|
317
|
+
|
|
256
318
|
def memoized_method_visibility(method_name)
|
|
257
319
|
return :private if private_method_defined?(method_name)
|
|
258
320
|
return :protected if protected_method_defined?(method_name)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :default_ttl, :default_max_size, :on_deprecation, :on_hook_error
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@default_ttl = nil
|
|
9
|
+
@default_max_size = nil
|
|
10
|
+
@on_deprecation = nil
|
|
11
|
+
@on_hook_error = nil
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -18,16 +18,13 @@ module SafeMemoize
|
|
|
18
18
|
def compute_cache_key(method_name, args, kwargs)
|
|
19
19
|
method_name = method_name.to_sym
|
|
20
20
|
|
|
21
|
-
#
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
# Wrap in a standard format: [method, custom_key]
|
|
28
|
-
[method_name, custom_key]
|
|
21
|
+
# Instance-level key generator takes priority over class-level
|
|
22
|
+
key_block = custom_key_store[method_name] ||
|
|
23
|
+
self.class.send(:__safe_memo_class_key_generators__)[method_name]
|
|
24
|
+
|
|
25
|
+
if key_block
|
|
26
|
+
[method_name, key_block.call(*args, **kwargs)]
|
|
29
27
|
else
|
|
30
|
-
# Use default key generation
|
|
31
28
|
safe_memo_cache_key(method_name, args, kwargs)
|
|
32
29
|
end
|
|
33
30
|
end
|
|
@@ -5,13 +5,13 @@ module SafeMemoize
|
|
|
5
5
|
private
|
|
6
6
|
|
|
7
7
|
def memo_hook_store
|
|
8
|
-
@__safe_memo_hooks__ ||= {on_expire: [], on_evict: [], on_hit: [], on_miss: []}
|
|
8
|
+
@__safe_memo_hooks__ ||= {on_expire: [], on_evict: [], on_hit: [], on_miss: [], on_store: []}
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def register_memo_hook(hook_type, &block)
|
|
12
12
|
raise ArgumentError, "block required" unless block
|
|
13
13
|
|
|
14
|
-
valid_hooks = [:on_expire, :on_evict, :on_hit, :on_miss]
|
|
14
|
+
valid_hooks = [:on_expire, :on_evict, :on_hit, :on_miss, :on_store]
|
|
15
15
|
raise ArgumentError, "invalid hook type: #{hook_type}" unless valid_hooks.include?(hook_type)
|
|
16
16
|
|
|
17
17
|
memo_hook_store[hook_type] << block
|
|
@@ -19,14 +19,23 @@ 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)
|
|
26
35
|
if hook_type
|
|
27
36
|
memo_hook_store[hook_type] = []
|
|
28
37
|
else
|
|
29
|
-
@__safe_memo_hooks__ = {on_expire: [], on_evict: [], on_hit: [], on_miss: []}
|
|
38
|
+
@__safe_memo_hooks__ = {on_expire: [], on_evict: [], on_hit: [], on_miss: [], on_store: []}
|
|
30
39
|
end
|
|
31
40
|
end
|
|
32
41
|
end
|
|
@@ -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
|
|
@@ -75,6 +75,12 @@ module SafeMemoize
|
|
|
75
75
|
register_memo_hook(:on_miss, &block)
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
+
def on_memo_store(&block)
|
|
79
|
+
raise ArgumentError, "block required" unless block
|
|
80
|
+
|
|
81
|
+
register_memo_hook(:on_store, &block)
|
|
82
|
+
end
|
|
83
|
+
|
|
78
84
|
def clear_memo_hooks(hook_type = nil)
|
|
79
85
|
with_memo_lock do
|
|
80
86
|
_clear_memo_hooks(hook_type)
|
|
@@ -90,12 +96,21 @@ module SafeMemoize
|
|
|
90
96
|
|
|
91
97
|
with_memo_lock do
|
|
92
98
|
@__safe_memo_cache__ ||= {}
|
|
93
|
-
|
|
99
|
+
record = memo_record(value, expires_at: memo_expires_at(ttl))
|
|
100
|
+
@__safe_memo_cache__[cache_key] = record
|
|
101
|
+
call_memo_hooks(:on_store, cache_key, record)
|
|
94
102
|
end
|
|
95
103
|
|
|
96
104
|
value
|
|
97
105
|
end
|
|
98
106
|
|
|
107
|
+
def memo_preload(method_name, *arg_sets)
|
|
108
|
+
method_name = method_name.to_sym
|
|
109
|
+
arg_sets.map do |args|
|
|
110
|
+
send(method_name, *Array(args))
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
99
114
|
def dump_memo(method_name = nil)
|
|
100
115
|
method_name = method_name&.to_sym
|
|
101
116
|
|
|
@@ -113,13 +128,74 @@ module SafeMemoize
|
|
|
113
128
|
with_memo_lock do
|
|
114
129
|
@__safe_memo_cache__ ||= {}
|
|
115
130
|
snapshot.each do |cache_key, value|
|
|
116
|
-
|
|
131
|
+
record = memo_record(value, expires_at: nil)
|
|
132
|
+
@__safe_memo_cache__[cache_key] = record
|
|
133
|
+
call_memo_hooks(:on_store, cache_key, record)
|
|
117
134
|
end
|
|
118
135
|
end
|
|
119
136
|
|
|
120
137
|
nil
|
|
121
138
|
end
|
|
122
139
|
|
|
140
|
+
def memo_touch(method_name, *args, ttl: nil, **kwargs)
|
|
141
|
+
method_name = method_name.to_sym
|
|
142
|
+
cache_key = safe_memo_cache_key(method_name, args, kwargs)
|
|
143
|
+
|
|
144
|
+
with_memo_lock do
|
|
145
|
+
cache = memo_cache_or_nil
|
|
146
|
+
return false unless cache
|
|
147
|
+
|
|
148
|
+
record = cache[cache_key]
|
|
149
|
+
return false unless record && memo_record_live?(record)
|
|
150
|
+
|
|
151
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
152
|
+
|
|
153
|
+
effective_ttl = if ttl
|
|
154
|
+
ttl
|
|
155
|
+
elsif record[:expires_at] && record[:cached_at]
|
|
156
|
+
record[:expires_at] - record[:cached_at]
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
record[:expires_at] = effective_ttl ? now + effective_ttl : nil
|
|
160
|
+
record[:cached_at] = now
|
|
161
|
+
true
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def memo_refresh(method_name, *args, **kwargs)
|
|
166
|
+
method_name = method_name.to_sym
|
|
167
|
+
reset_memo(method_name, *args, **kwargs)
|
|
168
|
+
send(method_name, *args, **kwargs)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def memo_age(method_name, *args, **kwargs)
|
|
172
|
+
cache_key = safe_memo_cache_key(method_name, args, kwargs)
|
|
173
|
+
|
|
174
|
+
with_memo_lock do
|
|
175
|
+
record = memo_cache_record(cache_key)
|
|
176
|
+
return nil unless record
|
|
177
|
+
|
|
178
|
+
cached_at = record[:cached_at]
|
|
179
|
+
return nil unless cached_at
|
|
180
|
+
|
|
181
|
+
(Process.clock_gettime(Process::CLOCK_MONOTONIC) - cached_at).round(6)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def memo_stale?(method_name, *args, **kwargs)
|
|
186
|
+
cache_key = safe_memo_cache_key(method_name, args, kwargs)
|
|
187
|
+
|
|
188
|
+
with_memo_lock do
|
|
189
|
+
cache = memo_cache_or_nil
|
|
190
|
+
return false unless cache
|
|
191
|
+
|
|
192
|
+
record = cache[cache_key]
|
|
193
|
+
return false unless record
|
|
194
|
+
|
|
195
|
+
!memo_record_live?(record)
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
123
199
|
def reset_memo(method_name, *args, **kwargs)
|
|
124
200
|
method_name = method_name.to_sym
|
|
125
201
|
|
|
@@ -150,5 +226,48 @@ module SafeMemoize
|
|
|
150
226
|
lru_clear_all
|
|
151
227
|
end
|
|
152
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
|
|
153
272
|
end
|
|
154
273
|
end
|
|
@@ -30,9 +30,13 @@ module SafeMemoize
|
|
|
30
30
|
cache_stats[:miss_rate]
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
def cache_metrics_reset
|
|
33
|
+
def cache_metrics_reset(method_name = nil)
|
|
34
34
|
with_memo_lock do
|
|
35
|
-
|
|
35
|
+
if method_name
|
|
36
|
+
_reset_cache_metrics_for(method_name.to_sym)
|
|
37
|
+
else
|
|
38
|
+
_reset_cache_metrics
|
|
39
|
+
end
|
|
36
40
|
end
|
|
37
41
|
end
|
|
38
42
|
|
data/lib/safe_memoize/version.rb
CHANGED
data/lib/safe_memoize.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "safe_memoize/version"
|
|
4
|
+
require_relative "safe_memoize/configuration"
|
|
4
5
|
require_relative "safe_memoize/class_methods"
|
|
5
6
|
require_relative "safe_memoize/public_methods"
|
|
6
7
|
require_relative "safe_memoize/cache_store_methods"
|
|
@@ -22,4 +23,22 @@ module SafeMemoize
|
|
|
22
23
|
def self.prepended(base)
|
|
23
24
|
base.extend(ClassMethods)
|
|
24
25
|
end
|
|
26
|
+
|
|
27
|
+
def self.configure
|
|
28
|
+
yield configuration
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.configuration
|
|
32
|
+
@configuration ||= Configuration.new
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.reset_configuration!
|
|
36
|
+
@configuration = Configuration.new
|
|
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
|
|
25
44
|
end
|
data/sig/safe_memoize.rbs
CHANGED
|
@@ -5,7 +5,7 @@ module SafeMemoize
|
|
|
5
5
|
type default_memo_key = [Symbol, Array[untyped], Hash[Symbol, untyped]]
|
|
6
6
|
type custom_memo_key = [Symbol, untyped]
|
|
7
7
|
type memo_key = default_memo_key | custom_memo_key
|
|
8
|
-
type memo_record = { value: untyped, expires_at: Float
|
|
8
|
+
type memo_record = { value: untyped, expires_at: Float?, cached_at: Float }
|
|
9
9
|
|
|
10
10
|
@__safe_memo_cache__: Hash[memo_key, memo_record]?
|
|
11
11
|
@__safe_memo_mutex__: Mutex?
|
|
@@ -14,20 +14,36 @@ module SafeMemoize
|
|
|
14
14
|
@__safe_memo_shared_lru_order__: Hash[Symbol, Hash[memo_key, true]]?
|
|
15
15
|
|
|
16
16
|
def self.prepended: (Class base) -> void
|
|
17
|
+
def self.configure: () { (Configuration) -> void } -> void
|
|
18
|
+
def self.configuration: () -> Configuration
|
|
19
|
+
def self.reset_configuration!: () -> Configuration
|
|
20
|
+
def self.deprecate: (String subject, message: String, horizon: String) -> void
|
|
21
|
+
|
|
22
|
+
class Configuration
|
|
23
|
+
attr_accessor default_ttl: Numeric?
|
|
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)?
|
|
27
|
+
|
|
28
|
+
def initialize: () -> void
|
|
29
|
+
end
|
|
17
30
|
|
|
18
31
|
module ClassMethods
|
|
19
|
-
def memoize: (Symbol | String method_name, ?ttl: Numeric?, ?max_size: Integer?, ?ttl_refresh: bool, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool) -> void
|
|
20
|
-
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)?) -> void
|
|
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
|
|
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
|
|
21
34
|
def reset_shared_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
|
|
22
35
|
def reset_all_shared_memos: () -> void
|
|
23
36
|
def shared_memoized?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool
|
|
24
37
|
def shared_memo_count: (?Symbol | String method_name) -> Integer
|
|
38
|
+
def shared_memo_age: (Symbol | String method_name, *untyped args, **untyped kwargs) -> Float?
|
|
39
|
+
def shared_memo_stale?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool
|
|
25
40
|
|
|
26
41
|
private
|
|
27
42
|
|
|
28
43
|
def __safe_memo_shared_cache__: () -> Hash[memo_key, memo_record]
|
|
29
44
|
def __safe_memo_shared_mutex__: () -> Mutex
|
|
30
45
|
def __safe_memo_shared_lru_order__: () -> Hash[Symbol, Hash[memo_key, true]]
|
|
46
|
+
def __safe_memo_class_key_generators__: () -> Hash[Symbol, Proc]
|
|
31
47
|
def memoized_method_visibility: (Symbol method_name) -> Symbol
|
|
32
48
|
end
|
|
33
49
|
|
|
@@ -43,12 +59,19 @@ module SafeMemoize
|
|
|
43
59
|
def on_memo_evict: { (memo_key cache_key, memo_record record) -> untyped } -> void
|
|
44
60
|
def on_memo_hit: { (memo_key cache_key, memo_record record) -> untyped } -> void
|
|
45
61
|
def on_memo_miss: { (memo_key cache_key, memo_record record) -> untyped } -> void
|
|
62
|
+
def on_memo_store: { (memo_key cache_key, memo_record record) -> untyped } -> void
|
|
46
63
|
def clear_memo_hooks: (Symbol? hook_type) -> void
|
|
47
64
|
def warm_memo: (Symbol | String method_name, *untyped args, ?ttl: Numeric?, **untyped kwargs) { () -> untyped } -> untyped
|
|
65
|
+
def memo_preload: (Symbol | String method_name, *Array[untyped] arg_sets) -> Array[untyped]
|
|
48
66
|
def dump_memo: (?Symbol | String method_name) -> Hash[memo_key, untyped]
|
|
49
67
|
def load_memo: (Hash[memo_key, untyped] snapshot) -> nil
|
|
68
|
+
def memo_touch: (Symbol | String method_name, *untyped args, ?ttl: Numeric?, **untyped kwargs) -> bool
|
|
69
|
+
def memo_refresh: (Symbol | String method_name, *untyped args, **untyped kwargs) -> untyped
|
|
70
|
+
def memo_age: (Symbol | String method_name, *untyped args, **untyped kwargs) -> Float?
|
|
71
|
+
def memo_stale?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool
|
|
50
72
|
def reset_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
|
|
51
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? }?
|
|
52
75
|
end
|
|
53
76
|
|
|
54
77
|
module CacheStoreMethods
|
|
@@ -89,14 +112,15 @@ module SafeMemoize
|
|
|
89
112
|
def safe_memo_values_for: (Symbol? method_name) -> Array[untyped]
|
|
90
113
|
def memo_projection: (memo_key cache_key, memo_record value, include_method: bool, include_value: bool) -> Hash[Symbol, untyped]
|
|
91
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
|
|
92
116
|
end
|
|
93
117
|
|
|
94
118
|
module HooksMethods
|
|
95
|
-
@__safe_memo_hooks__: { on_expire: Array[Proc], on_evict: Array[Proc], on_hit: Array[Proc], on_miss: Array[Proc] }?
|
|
119
|
+
@__safe_memo_hooks__: { on_expire: Array[Proc], on_evict: Array[Proc], on_hit: Array[Proc], on_miss: Array[Proc], on_store: Array[Proc] }?
|
|
96
120
|
|
|
97
121
|
private
|
|
98
122
|
|
|
99
|
-
def memo_hook_store: () -> { on_expire: Array[Proc], on_evict: Array[Proc], on_hit: Array[Proc], on_miss: Array[Proc] }
|
|
123
|
+
def memo_hook_store: () -> { on_expire: Array[Proc], on_evict: Array[Proc], on_hit: Array[Proc], on_miss: Array[Proc], on_store: Array[Proc] }
|
|
100
124
|
def register_memo_hook: (Symbol hook_type) { (memo_key cache_key, memo_record record) -> untyped } -> void
|
|
101
125
|
def call_memo_hooks: (Symbol hook_type, memo_key cache_key, memo_record record) -> void
|
|
102
126
|
def _clear_memo_hooks: (Symbol? hook_type) -> void
|
|
@@ -118,7 +142,7 @@ module SafeMemoize
|
|
|
118
142
|
def cache_stats_for: (Symbol | String method_name) -> Hash[Symbol, untyped]
|
|
119
143
|
def cache_hit_rate: () -> Float
|
|
120
144
|
def cache_miss_rate: () -> Float
|
|
121
|
-
def cache_metrics_reset: () -> void
|
|
145
|
+
def cache_metrics_reset: (?Symbol | String method_name) -> void
|
|
122
146
|
|
|
123
147
|
private
|
|
124
148
|
|
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,12 +44,14 @@ 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
|
|
50
51
|
- lib/safe_memoize/cache_record_methods.rb
|
|
51
52
|
- lib/safe_memoize/cache_store_methods.rb
|
|
52
53
|
- lib/safe_memoize/class_methods.rb
|
|
54
|
+
- lib/safe_memoize/configuration.rb
|
|
53
55
|
- lib/safe_memoize/custom_key_methods.rb
|
|
54
56
|
- lib/safe_memoize/hooks_methods.rb
|
|
55
57
|
- lib/safe_memoize/inspection_methods.rb
|