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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4806562a18a6f5143379d638d270ea3552280637dd33ed322c0f667f1257d836
4
- data.tar.gz: bdb12e7ae3a41d230c85fd24750edcc5eeba4d90cf2ac93b59cd0bccc8ba0325
3
+ metadata.gz: 945c98062bb51a6f19e3f229da2f8315680f1aea9ac290d1e9d60924ff6a6b6d
4
+ data.tar.gz: aa74ec52245abaf6fb6835446546cb269b919e07d98088b5f15f589543c1c2f7
5
5
  SHA512:
6
- metadata.gz: 4799eb0209980d5fee072ae05ff1661d70b8db5869e8701cac3fcd9241c1106f17fa84a798f85bcb084c197c24c646e2996fe60fa7375d4db8d780ad3bc4b71a
7
- data.tar.gz: 6d64a7e4bef4d5b3825d903294bc955c1ec483157aee479496e001cb500e6128804e148791793d862938543bf5e9e7ee05cca128adb4bb37e68a9d4858bcb517
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) # Clears cache for one method
102
- obj.reset_all_memos # Clears all memoized values
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeMemoize
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
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
- def self.prepended: (Class base) -> void
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 reset_memo: (Symbol method_name) -> void
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.1.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: 4.0.2
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: []