safe_memoize 0.1.0 → 0.2.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 +38 -0
- data/.github/workflows/release.yml +91 -0
- data/CHANGELOG.md +30 -0
- data/README.md +98 -2
- data/lib/safe_memoize/cache_metrics_methods.rb +30 -0
- data/lib/safe_memoize/cache_record_methods.rb +52 -0
- data/lib/safe_memoize/cache_store_methods.rb +70 -0
- data/lib/safe_memoize/class_methods.rb +60 -0
- data/lib/safe_memoize/custom_key_methods.rb +39 -0
- data/lib/safe_memoize/hooks_methods.rb +33 -0
- data/lib/safe_memoize/inspection_methods.rb +68 -0
- data/lib/safe_memoize/instance_methods.rb +15 -0
- data/lib/safe_memoize/public_custom_key_methods.rb +23 -0
- data/lib/safe_memoize/public_methods.rb +87 -0
- data/lib/safe_memoize/public_metrics_methods.rb +131 -0
- data/lib/safe_memoize/release_tooling.rb +66 -0
- data/lib/safe_memoize/version.rb +1 -1
- data/lib/safe_memoize.rb +13 -59
- data/sig/safe_memoize.rbs +125 -4
- metadata +16 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 945c98062bb51a6f19e3f229da2f8315680f1aea9ac290d1e9d60924ff6a6b6d
|
|
4
|
+
data.tar.gz: aa74ec52245abaf6fb6835446546cb269b919e07d98088b5f15f589543c1c2f7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a2fd80a5146bf93a91a36e4449abc4e83d3687d6dbbf91b6323150847b5909b6f69ff25ef75e1804c2217a2652ca148357eaf19dccd7cdc3c2cf60ef255a5121
|
|
7
|
+
data.tar.gz: d415cc760c97f1e8d3e50f33802279c065c6c56e7f89cc553717538824663b47ff974a9cbe57ccb064638d630892a837eb10cd7602db1a8c50618a52e040fe05
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches:
|
|
6
|
+
- main
|
|
7
|
+
pull_request:
|
|
8
|
+
workflow_dispatch:
|
|
9
|
+
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
test:
|
|
15
|
+
name: Ruby ${{ matrix.ruby }}
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
strategy:
|
|
18
|
+
fail-fast: false
|
|
19
|
+
matrix:
|
|
20
|
+
ruby:
|
|
21
|
+
- "3.2"
|
|
22
|
+
- "3.3"
|
|
23
|
+
- "3.4"
|
|
24
|
+
- "4.0"
|
|
25
|
+
|
|
26
|
+
steps:
|
|
27
|
+
- name: Check out repository
|
|
28
|
+
uses: actions/checkout@v5
|
|
29
|
+
|
|
30
|
+
- name: Set up Ruby
|
|
31
|
+
uses: ruby/setup-ruby@v1
|
|
32
|
+
with:
|
|
33
|
+
ruby-version: ${{ matrix.ruby }}
|
|
34
|
+
bundler-cache: true
|
|
35
|
+
|
|
36
|
+
- name: Run test and lint suite
|
|
37
|
+
run: bundle exec rake
|
|
38
|
+
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
release:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
|
|
15
|
+
steps:
|
|
16
|
+
- name: Check out repository
|
|
17
|
+
uses: actions/checkout@v5
|
|
18
|
+
with:
|
|
19
|
+
fetch-depth: 0
|
|
20
|
+
|
|
21
|
+
- name: Set up Ruby
|
|
22
|
+
uses: ruby/setup-ruby@v1
|
|
23
|
+
with:
|
|
24
|
+
ruby-version: "3.4"
|
|
25
|
+
bundler-cache: true
|
|
26
|
+
|
|
27
|
+
- name: Verify tag matches gem version
|
|
28
|
+
env:
|
|
29
|
+
RELEASE_TAG: ${{ github.ref_name }}
|
|
30
|
+
run: |
|
|
31
|
+
expected_version="${RELEASE_TAG#v}"
|
|
32
|
+
actual_version="$(bundle exec ruby -e 'require_relative "lib/safe_memoize/version"; print SafeMemoize::VERSION')"
|
|
33
|
+
|
|
34
|
+
if [ "$actual_version" != "$expected_version" ]; then
|
|
35
|
+
echo "Tag $expected_version does not match gem version $actual_version"
|
|
36
|
+
exit 1
|
|
37
|
+
fi
|
|
38
|
+
|
|
39
|
+
- name: Run test suite
|
|
40
|
+
run: bundle exec rake
|
|
41
|
+
|
|
42
|
+
- name: Build gem artifact
|
|
43
|
+
env:
|
|
44
|
+
RELEASE_TAG: ${{ github.ref_name }}
|
|
45
|
+
run: |
|
|
46
|
+
version="${RELEASE_TAG#v}"
|
|
47
|
+
mkdir -p pkg
|
|
48
|
+
gem build safe_memoize.gemspec --output "pkg/safe_memoize-${version}.gem"
|
|
49
|
+
|
|
50
|
+
- name: Check whether the gem version is already on RubyGems
|
|
51
|
+
id: rubygems
|
|
52
|
+
env:
|
|
53
|
+
RELEASE_TAG: ${{ github.ref_name }}
|
|
54
|
+
run: |
|
|
55
|
+
version="${RELEASE_TAG#v}"
|
|
56
|
+
|
|
57
|
+
if ruby -rjson -ropen-uri -e 'version = ARGV.fetch(0); versions = JSON.parse(URI.open("https://rubygems.org/api/v1/versions/safe_memoize.json", &:read)); exit(versions.any? { |item| item["number"] == version } ? 0 : 1)' "$version"; then
|
|
58
|
+
echo "already_published=true" >> "$GITHUB_OUTPUT"
|
|
59
|
+
else
|
|
60
|
+
echo "already_published=false" >> "$GITHUB_OUTPUT"
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
- name: Skip RubyGems publish for existing versions
|
|
64
|
+
if: steps.rubygems.outputs.already_published == 'true'
|
|
65
|
+
env:
|
|
66
|
+
RELEASE_TAG: ${{ github.ref_name }}
|
|
67
|
+
run: echo "RubyGems already has version ${RELEASE_TAG#v}; skipping gem push."
|
|
68
|
+
|
|
69
|
+
- name: Publish gem to RubyGems
|
|
70
|
+
if: steps.rubygems.outputs.already_published != 'true'
|
|
71
|
+
env:
|
|
72
|
+
GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
|
|
73
|
+
RELEASE_TAG: ${{ github.ref_name }}
|
|
74
|
+
run: |
|
|
75
|
+
version="${RELEASE_TAG#v}"
|
|
76
|
+
gem push "pkg/safe_memoize-${version}.gem"
|
|
77
|
+
|
|
78
|
+
- name: Generate GitHub release notes from CHANGELOG
|
|
79
|
+
env:
|
|
80
|
+
RELEASE_TAG: ${{ github.ref_name }}
|
|
81
|
+
run: |
|
|
82
|
+
version="${RELEASE_TAG#v}"
|
|
83
|
+
bundle exec ruby bin/release_notes "$version" "${RUNNER_TEMP}/release_notes.md"
|
|
84
|
+
|
|
85
|
+
- name: Create GitHub release
|
|
86
|
+
uses: softprops/action-gh-release@v2
|
|
87
|
+
with:
|
|
88
|
+
body_path: ${{ runner.temp }}/release_notes.md
|
|
89
|
+
files: pkg/*.gem
|
|
90
|
+
tag_name: ${{ github.ref_name }}
|
|
91
|
+
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.2.0] - 2026-05-14
|
|
4
|
+
|
|
5
|
+
- Add optional TTL expiration support for memoized entries
|
|
6
|
+
- Add cache invalidation/expiration hooks for custom handlers
|
|
7
|
+
- `on_memo_expire` hook fires when TTL entries expire
|
|
8
|
+
- `on_memo_evict` hook fires when manually resetting cache entries
|
|
9
|
+
- `clear_memo_hooks` to remove registered hooks
|
|
10
|
+
- Add cache statistics and monitoring capabilities
|
|
11
|
+
- `cache_stats` for comprehensive cache metrics
|
|
12
|
+
- `cache_stats_for(method_name)` for per-method statistics
|
|
13
|
+
- `cache_hit_rate` and `cache_miss_rate` for performance analysis
|
|
14
|
+
- `cache_metrics_reset` to clear collected metrics
|
|
15
|
+
- Add manual cache key generation support
|
|
16
|
+
- `memoize_with_custom_key` to define custom cache key logic
|
|
17
|
+
- `clear_custom_keys` to remove custom key generators
|
|
18
|
+
- Support for complex and computed keys based on arguments
|
|
19
|
+
|
|
20
|
+
## [0.1.2] - 2026-05-13
|
|
21
|
+
|
|
22
|
+
- Preserve public, protected, and private visibility for memoized methods
|
|
23
|
+
- Allow reset_memo to clear one cached argument combination or all entries for a method
|
|
24
|
+
- Add a memoized? helper for checking whether a method call is already cached
|
|
25
|
+
- Add a memo_count helper for inspecting cache size per instance or method
|
|
26
|
+
- Add a memo_keys helper for inspecting cached argument signatures
|
|
27
|
+
- Add a memo_values helper for inspecting cached signatures and their values
|
|
28
|
+
|
|
29
|
+
## [0.1.1] - 2026-05-13
|
|
30
|
+
|
|
31
|
+
- Add automated release tooling plus a GitHub Actions workflow for RubyGems publishing and GitHub releases
|
|
32
|
+
|
|
3
33
|
## [0.1.0] - 2026-02-26
|
|
4
34
|
|
|
5
35
|
- Initial release
|
data/README.md
CHANGED
|
@@ -21,6 +21,13 @@ SafeMemoize uses `Hash#key?` to distinguish "not yet cached" from "cached nil/fa
|
|
|
21
21
|
- Thread-safe via double-check locking
|
|
22
22
|
- Zero runtime dependencies
|
|
23
23
|
- Simple `prepend` + `memoize` API
|
|
24
|
+
- Preserves public, protected, and private method visibility
|
|
25
|
+
- Supports targeted cache invalidation by argument combination
|
|
26
|
+
- Includes a `memoized?` helper for cache inspection
|
|
27
|
+
- Includes a `memo_count` helper for cache size stats
|
|
28
|
+
- Includes a `memo_keys` helper for inspecting cached signatures
|
|
29
|
+
- Includes a `memo_values` helper for inspecting cached signatures and values
|
|
30
|
+
- Optional TTL expiration support for cached entries
|
|
24
31
|
- Block arguments bypass cache (blocks aren't comparable)
|
|
25
32
|
|
|
26
33
|
## Installation
|
|
@@ -94,12 +101,66 @@ class Config
|
|
|
94
101
|
end
|
|
95
102
|
```
|
|
96
103
|
|
|
104
|
+
### Works with private methods
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
class TokenProvider
|
|
108
|
+
prepend SafeMemoize
|
|
109
|
+
|
|
110
|
+
def bearer_token
|
|
111
|
+
token
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def token
|
|
117
|
+
fetch_token_from_service
|
|
118
|
+
end
|
|
119
|
+
memoize :token
|
|
120
|
+
end
|
|
121
|
+
```
|
|
122
|
+
|
|
97
123
|
### Cache reset
|
|
98
124
|
|
|
99
125
|
```ruby
|
|
100
126
|
obj = MyService.new
|
|
101
|
-
obj.reset_memo(:current_user)
|
|
102
|
-
obj.
|
|
127
|
+
obj.reset_memo(:current_user) # Clears all cached entries for one method
|
|
128
|
+
obj.reset_memo(:find_user, 42) # Clears only the cached call for find_user(42)
|
|
129
|
+
obj.reset_memo(:search, "ruby", page: 2) # Clears one positional/keyword combination
|
|
130
|
+
obj.reset_all_memos # Clears all memoized values
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### TTL expiration
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
class QuoteService
|
|
137
|
+
prepend SafeMemoize
|
|
138
|
+
|
|
139
|
+
def current_quote
|
|
140
|
+
fetch_quote_from_api
|
|
141
|
+
end
|
|
142
|
+
memoize :current_quote, ttl: 60
|
|
143
|
+
end
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
With a TTL, cached values expire automatically after the given number of seconds. The next call recomputes and refreshes the cache.
|
|
147
|
+
|
|
148
|
+
### Cache inspection
|
|
149
|
+
|
|
150
|
+
```ruby
|
|
151
|
+
obj = MyService.new
|
|
152
|
+
|
|
153
|
+
obj.memoized?(:current_user) # => false
|
|
154
|
+
obj.current_user
|
|
155
|
+
obj.memoized?(:current_user) # => true
|
|
156
|
+
|
|
157
|
+
obj.memoized?(:search, "ruby", page: 2) # Checks one cached argument combination
|
|
158
|
+
obj.memo_count # Total cached entries for this instance
|
|
159
|
+
obj.memo_count(:search) # Cached entries for one method
|
|
160
|
+
obj.memo_keys # All cached signatures with method, args, kwargs
|
|
161
|
+
obj.memo_keys(:search) # Cached signatures for one method
|
|
162
|
+
obj.memo_values # Cached signatures and values for all methods
|
|
163
|
+
obj.memo_values(:search) # Cached signatures and values for one method
|
|
103
164
|
```
|
|
104
165
|
|
|
105
166
|
## How It Works
|
|
@@ -110,6 +171,41 @@ SafeMemoize uses Ruby's `prepend` mechanism. When you call `memoize :method_name
|
|
|
110
171
|
|
|
111
172
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rspec` to run the tests. You can also run `bin/console` for an interactive prompt.
|
|
112
173
|
|
|
174
|
+
GitHub Actions also runs the full `bundle exec rake` suite automatically for pull requests, manual workflow runs, and pushes to `main` via `.github/workflows/ci.yml`.
|
|
175
|
+
|
|
176
|
+
## Releasing
|
|
177
|
+
|
|
178
|
+
Releases are automated in two parts:
|
|
179
|
+
|
|
180
|
+
1. Run `bin/release VERSION` locally to:
|
|
181
|
+
- update `lib/safe_memoize/version.rb`
|
|
182
|
+
- convert the current `## [Unreleased]` section in `CHANGELOG.md` into a dated release entry
|
|
183
|
+
- create the release commit and annotated tag
|
|
184
|
+
2. Push the branch and tag to GitHub. The workflow in `.github/workflows/release.yml` will:
|
|
185
|
+
- run the test and lint suite
|
|
186
|
+
- build the gem
|
|
187
|
+
- push it to RubyGems when that version is not already published
|
|
188
|
+
- create a GitHub release using the matching section from `CHANGELOG.md`
|
|
189
|
+
|
|
190
|
+
One-time setup:
|
|
191
|
+
|
|
192
|
+
- add a `RUBYGEMS_API_KEY` repository secret in GitHub
|
|
193
|
+
|
|
194
|
+
Typical release flow:
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
bundle exec rake
|
|
198
|
+
bin/release 0.1.1
|
|
199
|
+
git push origin HEAD
|
|
200
|
+
git push origin v0.1.1
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
To preview the changelog/version update without changing anything, use:
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
bin/release 0.1.1 --dry-run
|
|
207
|
+
```
|
|
208
|
+
|
|
113
209
|
## Contributing
|
|
114
210
|
|
|
115
211
|
Bug reports and pull requests are welcome on GitHub at https://github.com/eclectic-coding/safe_memoize.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module CacheMetricsMethods
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def memo_metrics_store
|
|
8
|
+
@__safe_memo_metrics__ ||= {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def record_cache_hit(method_name, args)
|
|
12
|
+
cache_key = safe_memo_cache_key(method_name, args, {})
|
|
13
|
+
metrics = memo_metrics_store
|
|
14
|
+
metrics[cache_key] ||= {hits: 0, misses: 0, total_time: 0.0}
|
|
15
|
+
metrics[cache_key][:hits] += 1
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def record_cache_miss(method_name, args, computation_time)
|
|
19
|
+
cache_key = safe_memo_cache_key(method_name, args, {})
|
|
20
|
+
metrics = memo_metrics_store
|
|
21
|
+
metrics[cache_key] ||= {hits: 0, misses: 0, total_time: 0.0}
|
|
22
|
+
metrics[cache_key][:misses] += 1
|
|
23
|
+
metrics[cache_key][:total_time] += computation_time
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def _reset_cache_metrics
|
|
27
|
+
@__safe_memo_metrics__ = {}
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module CacheRecordMethods
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def memo_ttl(ttl)
|
|
8
|
+
return nil if ttl.nil?
|
|
9
|
+
|
|
10
|
+
ttl = Float(ttl)
|
|
11
|
+
raise ArgumentError, "ttl must be non-negative" if ttl < 0
|
|
12
|
+
|
|
13
|
+
ttl
|
|
14
|
+
rescue ArgumentError, TypeError
|
|
15
|
+
raise ArgumentError, "ttl must be a non-negative number"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def memo_expires_at(ttl)
|
|
19
|
+
return nil unless ttl
|
|
20
|
+
|
|
21
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) + ttl
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def memo_record(value, expires_at:)
|
|
25
|
+
{value: value, expires_at: expires_at}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def memo_record_value(record)
|
|
29
|
+
record[:value]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def memo_record_live?(record)
|
|
33
|
+
return false unless record
|
|
34
|
+
|
|
35
|
+
expires_at = record[:expires_at]
|
|
36
|
+
return true unless expires_at
|
|
37
|
+
|
|
38
|
+
expires_at > Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def memo_prune_expired_entries!(cache)
|
|
42
|
+
cache.delete_if do |cache_key, record|
|
|
43
|
+
if !memo_record_live?(record)
|
|
44
|
+
call_memo_hooks(:on_expire, cache_key, record)
|
|
45
|
+
true
|
|
46
|
+
else
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module CacheStoreMethods
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def with_memo_lock
|
|
8
|
+
if defined?(@__safe_memo_mutex__) && @__safe_memo_mutex__
|
|
9
|
+
@__safe_memo_mutex__.synchronize { yield }
|
|
10
|
+
else
|
|
11
|
+
yield
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def memo_cache_or_nil
|
|
16
|
+
return nil unless defined?(@__safe_memo_cache__)
|
|
17
|
+
|
|
18
|
+
@__safe_memo_cache__
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def memo_cache_hit?(cache_key)
|
|
22
|
+
!!memo_cache_record(cache_key)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def memo_cache_record(cache_key)
|
|
26
|
+
cache = memo_cache_or_nil
|
|
27
|
+
return nil unless cache
|
|
28
|
+
|
|
29
|
+
record = cache[cache_key]
|
|
30
|
+
return nil unless memo_record_live?(record)
|
|
31
|
+
|
|
32
|
+
record
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def memo_cache_read(cache_key)
|
|
36
|
+
record = memo_cache_record(cache_key)
|
|
37
|
+
return nil unless record
|
|
38
|
+
|
|
39
|
+
memo_record_value(record)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def memo_fetch_or_store(cache_key, expires_at: nil)
|
|
43
|
+
memo_mutex!.synchronize do
|
|
44
|
+
@__safe_memo_cache__ ||= {}
|
|
45
|
+
|
|
46
|
+
record = @__safe_memo_cache__[cache_key]
|
|
47
|
+
|
|
48
|
+
if memo_record_live?(record)
|
|
49
|
+
memo_record_value(record)
|
|
50
|
+
else
|
|
51
|
+
value = yield
|
|
52
|
+
@__safe_memo_cache__[cache_key] = memo_record(value, expires_at: expires_at)
|
|
53
|
+
|
|
54
|
+
value
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def memo_mutex!
|
|
60
|
+
@__safe_memo_mutex__ ||= Mutex.new
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def with_memo_cache
|
|
64
|
+
cache = memo_cache_or_nil
|
|
65
|
+
return nil unless cache
|
|
66
|
+
|
|
67
|
+
yield cache
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module ClassMethods
|
|
5
|
+
def memoize(method_name, ttl: nil)
|
|
6
|
+
method_name = method_name.to_sym
|
|
7
|
+
visibility = memoized_method_visibility(method_name)
|
|
8
|
+
|
|
9
|
+
ttl = if ttl.nil?
|
|
10
|
+
nil
|
|
11
|
+
else
|
|
12
|
+
ttl = Float(ttl)
|
|
13
|
+
raise ArgumentError, "ttl must be non-negative" if ttl < 0
|
|
14
|
+
|
|
15
|
+
ttl
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
expires_at = ttl && Process.clock_gettime(Process::CLOCK_MONOTONIC) + ttl
|
|
19
|
+
|
|
20
|
+
mod = Module.new do
|
|
21
|
+
define_method(method_name) do |*args, **kwargs, &block|
|
|
22
|
+
# Blocks bypass cache entirely — they aren't comparable
|
|
23
|
+
return super(*args, **kwargs, &block) if block
|
|
24
|
+
|
|
25
|
+
cache_key = compute_cache_key(method_name, args, kwargs)
|
|
26
|
+
|
|
27
|
+
# Fast path: check without lock
|
|
28
|
+
if (record = memo_cache_record(cache_key))
|
|
29
|
+
record_cache_hit(method_name, args)
|
|
30
|
+
return memo_record_value(record)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Cache miss - compute and store
|
|
34
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
35
|
+
result = memo_fetch_or_store(cache_key, expires_at: expires_at) { super(*args, **kwargs) }
|
|
36
|
+
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
37
|
+
|
|
38
|
+
with_memo_lock do
|
|
39
|
+
record_cache_miss(method_name, args, elapsed_time)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
result
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
send(visibility, method_name)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
prepend mod
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def memoized_method_visibility(method_name)
|
|
54
|
+
return :private if private_method_defined?(method_name)
|
|
55
|
+
return :protected if protected_method_defined?(method_name)
|
|
56
|
+
|
|
57
|
+
:public
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module CustomKeyMethods
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def custom_key_store
|
|
8
|
+
@__safe_memo_custom_keys__ ||= {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def register_custom_key(method_name, &block)
|
|
12
|
+
raise ArgumentError, "block required" unless block
|
|
13
|
+
|
|
14
|
+
method_name = method_name.to_sym
|
|
15
|
+
custom_key_store[method_name] = block
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def compute_cache_key(method_name, args, kwargs)
|
|
19
|
+
method_name = method_name.to_sym
|
|
20
|
+
|
|
21
|
+
# Check if a custom key generator is registered
|
|
22
|
+
custom_key_block = custom_key_store[method_name]
|
|
23
|
+
|
|
24
|
+
if custom_key_block
|
|
25
|
+
# Call the custom key generator with args and kwargs
|
|
26
|
+
custom_key = custom_key_block.call(*args, **kwargs)
|
|
27
|
+
# Wrap in a standard format: [method, custom_key]
|
|
28
|
+
[method_name, custom_key]
|
|
29
|
+
else
|
|
30
|
+
# Use default key generation
|
|
31
|
+
safe_memo_cache_key(method_name, args, kwargs)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def _clear_custom_keys
|
|
36
|
+
@__safe_memo_custom_keys__ = {}
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module HooksMethods
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def memo_hook_store
|
|
8
|
+
@__safe_memo_hooks__ ||= {on_expire: [], on_evict: []}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def register_memo_hook(hook_type, &block)
|
|
12
|
+
raise ArgumentError, "block required" unless block
|
|
13
|
+
|
|
14
|
+
valid_hooks = [:on_expire, :on_evict]
|
|
15
|
+
raise ArgumentError, "invalid hook type: #{hook_type}" unless valid_hooks.include?(hook_type)
|
|
16
|
+
|
|
17
|
+
memo_hook_store[hook_type] << block
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call_memo_hooks(hook_type, cache_key, record)
|
|
21
|
+
hooks = memo_hook_store[hook_type] || []
|
|
22
|
+
hooks.each { |hook| hook.call(cache_key, record) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def _clear_memo_hooks(hook_type = nil)
|
|
26
|
+
if hook_type
|
|
27
|
+
memo_hook_store[hook_type] = []
|
|
28
|
+
else
|
|
29
|
+
@__safe_memo_hooks__ = {on_expire: [], on_evict: []}
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module InspectionMethods
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def safe_memo_scoped_method(method_name)
|
|
8
|
+
raise ArgumentError, "expected 0 or 1 arguments" if method_name.length > 1
|
|
9
|
+
|
|
10
|
+
method_name.first&.to_sym
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def memo_matcher_for(method_name, args, kwargs)
|
|
14
|
+
if args.empty? && kwargs.empty?
|
|
15
|
+
->(key) { key[0] == method_name }
|
|
16
|
+
else
|
|
17
|
+
cache_key = safe_memo_cache_key(method_name, args, kwargs)
|
|
18
|
+
->(key) { key == cache_key }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def memo_entries_for(method_name)
|
|
23
|
+
cache = memo_cache_or_nil
|
|
24
|
+
return [] unless cache
|
|
25
|
+
|
|
26
|
+
memo_prune_expired_entries!(cache)
|
|
27
|
+
entries = cache.to_a
|
|
28
|
+
return entries unless method_name
|
|
29
|
+
|
|
30
|
+
entries.select { |(cache_key, _)| cache_key[0] == method_name }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def safe_memo_count_for(method_name)
|
|
34
|
+
memo_entries_for(method_name).length
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def safe_memo_keys_for(method_name)
|
|
38
|
+
entries = memo_entries_for(method_name)
|
|
39
|
+
include_method = method_name.nil?
|
|
40
|
+
|
|
41
|
+
entries.map do |(cache_key, value)|
|
|
42
|
+
memo_projection(cache_key, value, include_method: include_method, include_value: false)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def safe_memo_values_for(method_name)
|
|
47
|
+
entries = memo_entries_for(method_name)
|
|
48
|
+
include_method = method_name.nil?
|
|
49
|
+
|
|
50
|
+
entries.map do |(cache_key, value)|
|
|
51
|
+
memo_projection(cache_key, value, include_method: include_method, include_value: true)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def memo_projection(cache_key, value, include_method:, include_value:)
|
|
56
|
+
method_name, args, kwargs = cache_key
|
|
57
|
+
|
|
58
|
+
payload = {args: args, kwargs: kwargs}
|
|
59
|
+
payload[:method] = method_name if include_method
|
|
60
|
+
payload[:value] = memo_record_value(value) if include_value
|
|
61
|
+
payload
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def safe_memo_cache_key(method_name, args, kwargs)
|
|
65
|
+
[method_name.to_sym, args, kwargs]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module InstanceMethods
|
|
5
|
+
include PublicMethods
|
|
6
|
+
include CacheStoreMethods
|
|
7
|
+
include CacheRecordMethods
|
|
8
|
+
include InspectionMethods
|
|
9
|
+
include HooksMethods
|
|
10
|
+
include CacheMetricsMethods
|
|
11
|
+
include PublicMetricsMethods
|
|
12
|
+
include CustomKeyMethods
|
|
13
|
+
include PublicCustomKeyMethods
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module PublicCustomKeyMethods
|
|
5
|
+
def memoize_with_custom_key(method_name, &key_generator)
|
|
6
|
+
raise ArgumentError, "block required for key generation" unless key_generator
|
|
7
|
+
|
|
8
|
+
register_custom_key(method_name, &key_generator)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def clear_custom_keys(method_name = nil)
|
|
12
|
+
if method_name
|
|
13
|
+
with_memo_lock do
|
|
14
|
+
custom_key_store.delete(method_name.to_sym)
|
|
15
|
+
end
|
|
16
|
+
else
|
|
17
|
+
with_memo_lock do
|
|
18
|
+
_clear_custom_keys
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module PublicMethods
|
|
5
|
+
def memoized?(method_name, *args, **kwargs, &block)
|
|
6
|
+
return false if block
|
|
7
|
+
|
|
8
|
+
cache_key = safe_memo_cache_key(method_name, args, kwargs)
|
|
9
|
+
|
|
10
|
+
with_memo_lock do
|
|
11
|
+
memo_cache_hit?(cache_key)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def memo_count(*method_name)
|
|
16
|
+
scoped_method = safe_memo_scoped_method(method_name)
|
|
17
|
+
|
|
18
|
+
with_memo_lock do
|
|
19
|
+
safe_memo_count_for(scoped_method)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def memo_keys(*method_name)
|
|
24
|
+
scoped_method = safe_memo_scoped_method(method_name)
|
|
25
|
+
|
|
26
|
+
with_memo_lock do
|
|
27
|
+
safe_memo_keys_for(scoped_method)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def memo_values(*method_name)
|
|
32
|
+
scoped_method = safe_memo_scoped_method(method_name)
|
|
33
|
+
|
|
34
|
+
with_memo_lock do
|
|
35
|
+
safe_memo_values_for(scoped_method)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def on_memo_expire(&block)
|
|
40
|
+
raise ArgumentError, "block required" unless block
|
|
41
|
+
|
|
42
|
+
register_memo_hook(:on_expire, &block)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def on_memo_evict(&block)
|
|
46
|
+
raise ArgumentError, "block required" unless block
|
|
47
|
+
|
|
48
|
+
register_memo_hook(:on_evict, &block)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def clear_memo_hooks(hook_type = nil)
|
|
52
|
+
with_memo_lock do
|
|
53
|
+
_clear_memo_hooks(hook_type)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def reset_memo(method_name, *args, **kwargs)
|
|
58
|
+
method_name = method_name.to_sym
|
|
59
|
+
|
|
60
|
+
matcher = memo_matcher_for(method_name, args, kwargs)
|
|
61
|
+
|
|
62
|
+
with_memo_lock do
|
|
63
|
+
with_memo_cache do |cache|
|
|
64
|
+
cache.delete_if do |key, record|
|
|
65
|
+
if matcher.call(key)
|
|
66
|
+
call_memo_hooks(:on_evict, key, record)
|
|
67
|
+
true
|
|
68
|
+
else
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def reset_all_memos
|
|
77
|
+
with_memo_lock do
|
|
78
|
+
if defined?(@__safe_memo_cache__) && @__safe_memo_cache__
|
|
79
|
+
@__safe_memo_cache__.each do |key, record|
|
|
80
|
+
call_memo_hooks(:on_evict, key, record)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
@__safe_memo_cache__ = {}
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module PublicMetricsMethods
|
|
5
|
+
def cache_stats
|
|
6
|
+
with_memo_lock do
|
|
7
|
+
metrics = memo_metrics_store
|
|
8
|
+
|
|
9
|
+
if metrics.empty?
|
|
10
|
+
return {
|
|
11
|
+
total_hits: 0,
|
|
12
|
+
total_misses: 0,
|
|
13
|
+
hit_rate: 0.0,
|
|
14
|
+
miss_rate: 0.0,
|
|
15
|
+
average_computation_time: 0.0,
|
|
16
|
+
entries: []
|
|
17
|
+
}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
total_hits = metrics.values.sum { |m| m[:hits] }
|
|
21
|
+
total_misses = metrics.values.sum { |m| m[:misses] }
|
|
22
|
+
total_time = metrics.values.sum { |m| m[:total_time] }
|
|
23
|
+
total_calls = total_hits + total_misses
|
|
24
|
+
|
|
25
|
+
hit_rate = total_calls.zero? ? 0.0 : (total_hits.to_f / total_calls * 100).round(2)
|
|
26
|
+
miss_rate = total_calls.zero? ? 0.0 : (total_misses.to_f / total_calls * 100).round(2)
|
|
27
|
+
avg_time = total_misses.zero? ? 0.0 : (total_time / total_misses).round(6)
|
|
28
|
+
|
|
29
|
+
entries = metrics.map do |cache_key, stats|
|
|
30
|
+
method_name, args, _kwargs = cache_key
|
|
31
|
+
entry_hit_rate = if (stats[:hits] + stats[:misses]).zero?
|
|
32
|
+
0.0
|
|
33
|
+
else
|
|
34
|
+
(stats[:hits].to_f / (stats[:hits] + stats[:misses]) * 100).round(2)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
{
|
|
38
|
+
method: method_name,
|
|
39
|
+
args: args,
|
|
40
|
+
hits: stats[:hits],
|
|
41
|
+
misses: stats[:misses],
|
|
42
|
+
hit_rate: entry_hit_rate,
|
|
43
|
+
computation_time: stats[:total_time].round(6)
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
{
|
|
48
|
+
total_hits: total_hits,
|
|
49
|
+
total_misses: total_misses,
|
|
50
|
+
hit_rate: hit_rate,
|
|
51
|
+
miss_rate: miss_rate,
|
|
52
|
+
average_computation_time: avg_time,
|
|
53
|
+
entries: entries
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def cache_stats_for(method_name)
|
|
59
|
+
method_name = method_name.to_sym
|
|
60
|
+
|
|
61
|
+
with_memo_lock do
|
|
62
|
+
metrics = memo_metrics_store
|
|
63
|
+
method_metrics = metrics.select { |key, _| key[0] == method_name }
|
|
64
|
+
|
|
65
|
+
if method_metrics.empty?
|
|
66
|
+
return {
|
|
67
|
+
method: method_name,
|
|
68
|
+
total_hits: 0,
|
|
69
|
+
total_misses: 0,
|
|
70
|
+
hit_rate: 0.0,
|
|
71
|
+
miss_rate: 0.0,
|
|
72
|
+
average_computation_time: 0.0,
|
|
73
|
+
entries: []
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
total_hits = method_metrics.values.sum { |m| m[:hits] }
|
|
78
|
+
total_misses = method_metrics.values.sum { |m| m[:misses] }
|
|
79
|
+
total_time = method_metrics.values.sum { |m| m[:total_time] }
|
|
80
|
+
total_calls = total_hits + total_misses
|
|
81
|
+
|
|
82
|
+
hit_rate = total_calls.zero? ? 0.0 : (total_hits.to_f / total_calls * 100).round(2)
|
|
83
|
+
miss_rate = total_calls.zero? ? 0.0 : (total_misses.to_f / total_calls * 100).round(2)
|
|
84
|
+
avg_time = total_misses.zero? ? 0.0 : (total_time / total_misses).round(6)
|
|
85
|
+
|
|
86
|
+
entries = method_metrics.map do |cache_key, stats|
|
|
87
|
+
_method, args, _kwargs = cache_key
|
|
88
|
+
entry_hit_rate = if (stats[:hits] + stats[:misses]).zero?
|
|
89
|
+
0.0
|
|
90
|
+
else
|
|
91
|
+
(stats[:hits].to_f / (stats[:hits] + stats[:misses]) * 100).round(2)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
args: args,
|
|
96
|
+
hits: stats[:hits],
|
|
97
|
+
misses: stats[:misses],
|
|
98
|
+
hit_rate: entry_hit_rate,
|
|
99
|
+
computation_time: stats[:total_time].round(6)
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
{
|
|
104
|
+
method: method_name,
|
|
105
|
+
total_hits: total_hits,
|
|
106
|
+
total_misses: total_misses,
|
|
107
|
+
hit_rate: hit_rate,
|
|
108
|
+
miss_rate: miss_rate,
|
|
109
|
+
average_computation_time: avg_time,
|
|
110
|
+
entries: entries
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def cache_hit_rate
|
|
116
|
+
stats = cache_stats
|
|
117
|
+
stats[:hit_rate]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def cache_miss_rate
|
|
121
|
+
stats = cache_stats
|
|
122
|
+
stats[:miss_rate]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def cache_metrics_reset
|
|
126
|
+
with_memo_lock do
|
|
127
|
+
_reset_cache_metrics
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
module SafeMemoize
|
|
6
|
+
module ReleaseTooling
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
VERSION_PATTERN = /VERSION = "[^"]+"/
|
|
10
|
+
SEMVER_PATTERN = /\A\d+\.\d+\.\d+(?:[-.][0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?\z/
|
|
11
|
+
UNRELEASED_HEADING = "## [Unreleased]"
|
|
12
|
+
|
|
13
|
+
def normalize_version(version)
|
|
14
|
+
normalized_version = version.to_s.sub(/\Av/, "")
|
|
15
|
+
|
|
16
|
+
unless normalized_version.match?(SEMVER_PATTERN)
|
|
17
|
+
raise ArgumentError, "version must look like x.y.z"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
normalized_version
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def update_version_file(contents, version)
|
|
24
|
+
normalized_version = normalize_version(version)
|
|
25
|
+
|
|
26
|
+
unless contents.match?(VERSION_PATTERN)
|
|
27
|
+
raise ArgumentError, "version file does not define VERSION"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
contents.sub(VERSION_PATTERN, %(VERSION = "#{normalized_version}"))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def finalize_changelog(contents, version, date = Date.today)
|
|
34
|
+
normalized_version = normalize_version(version)
|
|
35
|
+
release_heading = "## [#{normalized_version}] - #{date.iso8601}"
|
|
36
|
+
|
|
37
|
+
unless contents.include?(UNRELEASED_HEADING)
|
|
38
|
+
raise ArgumentError, "CHANGELOG.md must contain an Unreleased heading"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if contents.match?(/^## \[#{Regexp.escape(normalized_version)}\](?: - .+)?$/)
|
|
42
|
+
raise ArgumentError, "CHANGELOG.md already contains #{normalized_version}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
contents.sub(UNRELEASED_HEADING, "#{UNRELEASED_HEADING}\n\n#{release_heading}")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def extract_release_notes(contents, version)
|
|
49
|
+
normalized_version = normalize_version(version)
|
|
50
|
+
lines = contents.lines
|
|
51
|
+
release_heading = /^## \[#{Regexp.escape(normalized_version)}\](?: - .+)?$/
|
|
52
|
+
start_index = lines.index { |line| line.match?(release_heading) }
|
|
53
|
+
|
|
54
|
+
raise ArgumentError, "CHANGELOG.md is missing release notes for #{normalized_version}" unless start_index
|
|
55
|
+
|
|
56
|
+
body = lines[(start_index + 1)..].take_while { |line| !line.start_with?("## [") }.join.strip
|
|
57
|
+
body = "- No changes listed." if body.empty?
|
|
58
|
+
|
|
59
|
+
<<~MARKDOWN
|
|
60
|
+
## SafeMemoize #{normalized_version}
|
|
61
|
+
|
|
62
|
+
#{body}
|
|
63
|
+
MARKDOWN
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
data/lib/safe_memoize/version.rb
CHANGED
data/lib/safe_memoize.rb
CHANGED
|
@@ -1,70 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "safe_memoize/version"
|
|
4
|
+
require_relative "safe_memoize/class_methods"
|
|
5
|
+
require_relative "safe_memoize/public_methods"
|
|
6
|
+
require_relative "safe_memoize/cache_store_methods"
|
|
7
|
+
require_relative "safe_memoize/cache_record_methods"
|
|
8
|
+
require_relative "safe_memoize/inspection_methods"
|
|
9
|
+
require_relative "safe_memoize/hooks_methods"
|
|
10
|
+
require_relative "safe_memoize/cache_metrics_methods"
|
|
11
|
+
require_relative "safe_memoize/public_metrics_methods"
|
|
12
|
+
require_relative "safe_memoize/custom_key_methods"
|
|
13
|
+
require_relative "safe_memoize/public_custom_key_methods"
|
|
14
|
+
require_relative "safe_memoize/instance_methods"
|
|
4
15
|
|
|
5
16
|
module SafeMemoize
|
|
6
17
|
class Error < StandardError; end
|
|
7
18
|
|
|
19
|
+
include InstanceMethods
|
|
20
|
+
|
|
8
21
|
def self.prepended(base)
|
|
9
22
|
base.extend(ClassMethods)
|
|
10
23
|
end
|
|
11
|
-
|
|
12
|
-
module ClassMethods
|
|
13
|
-
def memoize(method_name)
|
|
14
|
-
method_name = method_name.to_sym
|
|
15
|
-
|
|
16
|
-
mod = Module.new do
|
|
17
|
-
define_method(method_name) do |*args, **kwargs, &block|
|
|
18
|
-
# Blocks bypass cache entirely — they aren't comparable
|
|
19
|
-
return super(*args, **kwargs, &block) if block
|
|
20
|
-
|
|
21
|
-
cache_key = [method_name, args, kwargs]
|
|
22
|
-
|
|
23
|
-
@__safe_memo_mutex__ ||= Mutex.new
|
|
24
|
-
|
|
25
|
-
# Fast path: check without lock
|
|
26
|
-
if defined?(@__safe_memo_cache__) && @__safe_memo_cache__.key?(cache_key)
|
|
27
|
-
return @__safe_memo_cache__[cache_key]
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
# Slow path: lock and double-check
|
|
31
|
-
@__safe_memo_mutex__.synchronize do
|
|
32
|
-
@__safe_memo_cache__ ||= {}
|
|
33
|
-
|
|
34
|
-
if @__safe_memo_cache__.key?(cache_key)
|
|
35
|
-
@__safe_memo_cache__[cache_key]
|
|
36
|
-
else
|
|
37
|
-
@__safe_memo_cache__[cache_key] = super(*args, **kwargs)
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
prepend mod
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def reset_memo(method_name)
|
|
48
|
-
method_name = method_name.to_sym
|
|
49
|
-
|
|
50
|
-
return unless defined?(@__safe_memo_cache__)
|
|
51
|
-
|
|
52
|
-
if defined?(@__safe_memo_mutex__) && @__safe_memo_mutex__
|
|
53
|
-
@__safe_memo_mutex__.synchronize do
|
|
54
|
-
@__safe_memo_cache__.delete_if { |key, _| key[0] == method_name }
|
|
55
|
-
end
|
|
56
|
-
else
|
|
57
|
-
@__safe_memo_cache__.delete_if { |key, _| key[0] == method_name }
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def reset_all_memos
|
|
62
|
-
if defined?(@__safe_memo_mutex__) && @__safe_memo_mutex__
|
|
63
|
-
@__safe_memo_mutex__.synchronize do
|
|
64
|
-
@__safe_memo_cache__ = {}
|
|
65
|
-
end
|
|
66
|
-
else
|
|
67
|
-
@__safe_memo_cache__ = {}
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
24
|
end
|
data/sig/safe_memoize.rbs
CHANGED
|
@@ -1,12 +1,133 @@
|
|
|
1
1
|
module SafeMemoize
|
|
2
2
|
VERSION: String
|
|
3
|
+
include InstanceMethods
|
|
3
4
|
|
|
4
|
-
|
|
5
|
+
type default_memo_key = [Symbol, Array[untyped], Hash[Symbol, untyped]]
|
|
6
|
+
type custom_memo_key = [Symbol, untyped]
|
|
7
|
+
type memo_key = default_memo_key | custom_memo_key
|
|
8
|
+
type memo_record = { value: untyped, expires_at: Float? }
|
|
9
|
+
|
|
10
|
+
@__safe_memo_cache__: Hash[memo_key, memo_record]?
|
|
11
|
+
@__safe_memo_mutex__: Mutex?
|
|
5
12
|
|
|
6
|
-
def
|
|
7
|
-
def reset_all_memos: () -> void
|
|
13
|
+
def self.prepended: (Class base) -> void
|
|
8
14
|
|
|
9
15
|
module ClassMethods
|
|
10
|
-
def memoize: (Symbol | String method_name) -> void
|
|
16
|
+
def memoize: (Symbol | String method_name, ?ttl: Numeric?) -> void
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def memoized_method_visibility: (Symbol method_name) -> Symbol
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
module PublicMethods
|
|
24
|
+
def memoized?: (Symbol | String method_name, *untyped args, **untyped kwargs) ?{ () -> untyped } -> bool
|
|
25
|
+
def memo_count: (*untyped method_name) -> Integer
|
|
26
|
+
def memo_keys: (*untyped method_name) -> Array[untyped]
|
|
27
|
+
def memo_values: (*untyped method_name) -> Array[untyped]
|
|
28
|
+
def on_memo_expire: { (memo_key cache_key, memo_record record) -> untyped } -> void
|
|
29
|
+
def on_memo_evict: { (memo_key cache_key, memo_record record) -> untyped } -> void
|
|
30
|
+
def clear_memo_hooks: (Symbol? hook_type) -> void
|
|
31
|
+
def reset_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
|
|
32
|
+
def reset_all_memos: () -> void
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
module CacheStoreMethods
|
|
36
|
+
@__safe_memo_cache__: Hash[memo_key, memo_record]?
|
|
37
|
+
@__safe_memo_mutex__: Mutex?
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def with_memo_lock: { () -> untyped } -> untyped
|
|
42
|
+
def memo_cache_or_nil: () -> Hash[memo_key, memo_record]?
|
|
43
|
+
def memo_cache_hit?: (memo_key cache_key) -> bool
|
|
44
|
+
def memo_cache_record: (memo_key cache_key) -> memo_record?
|
|
45
|
+
def memo_cache_read: (memo_key cache_key) -> untyped?
|
|
46
|
+
def memo_fetch_or_store: (memo_key cache_key) { () -> untyped } -> untyped
|
|
47
|
+
def memo_mutex!: () -> Mutex
|
|
48
|
+
def with_memo_cache: { (Hash[memo_key, memo_record] cache) -> untyped } -> untyped?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
module CacheRecordMethods
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def memo_ttl: (Numeric? ttl) -> Float?
|
|
55
|
+
def memo_expires_at: (Float? ttl) -> Float?
|
|
56
|
+
def memo_record: (untyped value, expires_at: Float?) -> memo_record
|
|
57
|
+
def memo_record_value: (memo_record record) -> untyped
|
|
58
|
+
def memo_record_live?: (memo_record? record) -> bool
|
|
59
|
+
def memo_prune_expired_entries!: (Hash[memo_key, memo_record] cache) -> void
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
module InspectionMethods
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def safe_memo_scoped_method: (Array[untyped] method_name) -> Symbol?
|
|
66
|
+
def memo_matcher_for: (Symbol method_name, Array[untyped] args, Hash[Symbol, untyped] kwargs) -> untyped
|
|
67
|
+
def memo_entries_for: (Symbol? method_name) -> Array[untyped]
|
|
68
|
+
def safe_memo_count_for: (Symbol? method_name) -> Integer
|
|
69
|
+
def safe_memo_keys_for: (Symbol? method_name) -> Array[untyped]
|
|
70
|
+
def safe_memo_values_for: (Symbol? method_name) -> Array[untyped]
|
|
71
|
+
def memo_projection: (memo_key cache_key, memo_record value, include_method: bool, include_value: bool) -> Hash[Symbol, untyped]
|
|
72
|
+
def safe_memo_cache_key: (Symbol | String method_name, Array[untyped] args, Hash[Symbol, untyped] kwargs) -> default_memo_key
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
module HooksMethods
|
|
76
|
+
@__safe_memo_hooks__: { on_expire: Array[Proc], on_evict: Array[Proc] }?
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def memo_hook_store: () -> { on_expire: Array[Proc], on_evict: Array[Proc] }
|
|
81
|
+
def register_memo_hook: (Symbol hook_type) { (memo_key cache_key, memo_record record) -> untyped } -> void
|
|
82
|
+
def call_memo_hooks: (Symbol hook_type, memo_key cache_key, memo_record record) -> void
|
|
83
|
+
def _clear_memo_hooks: (Symbol? hook_type) -> void
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
module CacheMetricsMethods
|
|
87
|
+
@__safe_memo_metrics__: Hash[memo_key, { hits: Integer, misses: Integer, total_time: Float }]?
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def memo_metrics_store: () -> Hash[memo_key, { hits: Integer, misses: Integer, total_time: Float }]
|
|
92
|
+
def record_cache_hit: (Symbol method_name, Array[untyped] args) -> void
|
|
93
|
+
def record_cache_miss: (Symbol method_name, Array[untyped] args, Float computation_time) -> void
|
|
94
|
+
def _reset_cache_metrics: () -> void
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
module PublicMetricsMethods
|
|
98
|
+
def cache_stats: () -> Hash[Symbol, untyped]
|
|
99
|
+
def cache_stats_for: (Symbol | String method_name) -> Hash[Symbol, untyped]
|
|
100
|
+
def cache_hit_rate: () -> Float
|
|
101
|
+
def cache_miss_rate: () -> Float
|
|
102
|
+
def cache_metrics_reset: () -> void
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
module CustomKeyMethods
|
|
106
|
+
@__safe_memo_custom_keys__: Hash[Symbol, Proc]?
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def custom_key_store: () -> Hash[Symbol, Proc]
|
|
111
|
+
def register_custom_key: (Symbol | String method_name) { (*untyped args, **untyped kwargs) -> untyped } -> void
|
|
112
|
+
def compute_cache_key: (Symbol | String method_name, Array[untyped] args, Hash[Symbol, untyped] kwargs) -> memo_key
|
|
113
|
+
def _clear_custom_keys: () -> void
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
module PublicCustomKeyMethods
|
|
117
|
+
def memoize_with_custom_key: (Symbol | String method_name) { (*untyped args, **untyped kwargs) -> untyped } -> void
|
|
118
|
+
def clear_custom_keys: (Symbol | String? method_name) -> void
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
module InstanceMethods
|
|
122
|
+
include PublicMethods
|
|
123
|
+
include CacheStoreMethods
|
|
124
|
+
include CacheRecordMethods
|
|
125
|
+
include InspectionMethods
|
|
126
|
+
include HooksMethods
|
|
127
|
+
include CacheMetricsMethods
|
|
128
|
+
include PublicMetricsMethods
|
|
129
|
+
include CustomKeyMethods
|
|
130
|
+
include PublicCustomKeyMethods
|
|
11
131
|
end
|
|
12
132
|
end
|
|
133
|
+
|
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.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chuck Smith
|
|
@@ -18,11 +18,25 @@ executables: []
|
|
|
18
18
|
extensions: []
|
|
19
19
|
extra_rdoc_files: []
|
|
20
20
|
files:
|
|
21
|
+
- ".github/workflows/ci.yml"
|
|
22
|
+
- ".github/workflows/release.yml"
|
|
21
23
|
- CHANGELOG.md
|
|
22
24
|
- LICENSE.txt
|
|
23
25
|
- README.md
|
|
24
26
|
- Rakefile
|
|
25
27
|
- lib/safe_memoize.rb
|
|
28
|
+
- lib/safe_memoize/cache_metrics_methods.rb
|
|
29
|
+
- lib/safe_memoize/cache_record_methods.rb
|
|
30
|
+
- lib/safe_memoize/cache_store_methods.rb
|
|
31
|
+
- lib/safe_memoize/class_methods.rb
|
|
32
|
+
- lib/safe_memoize/custom_key_methods.rb
|
|
33
|
+
- lib/safe_memoize/hooks_methods.rb
|
|
34
|
+
- lib/safe_memoize/inspection_methods.rb
|
|
35
|
+
- lib/safe_memoize/instance_methods.rb
|
|
36
|
+
- lib/safe_memoize/public_custom_key_methods.rb
|
|
37
|
+
- lib/safe_memoize/public_methods.rb
|
|
38
|
+
- lib/safe_memoize/public_metrics_methods.rb
|
|
39
|
+
- lib/safe_memoize/release_tooling.rb
|
|
26
40
|
- lib/safe_memoize/version.rb
|
|
27
41
|
- sig/safe_memoize.rbs
|
|
28
42
|
homepage: https://github.com/eclectic-coding/safe_memoize
|
|
@@ -47,7 +61,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
47
61
|
- !ruby/object:Gem::Version
|
|
48
62
|
version: '0'
|
|
49
63
|
requirements: []
|
|
50
|
-
rubygems_version:
|
|
64
|
+
rubygems_version: 3.6.9
|
|
51
65
|
specification_version: 4
|
|
52
66
|
summary: Thread-safe memoization that correctly handles nil and false values
|
|
53
67
|
test_files: []
|