safe_memoize 1.3.0 → 1.5.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: 3e47bd422b1516a4f775e49ad3f58bf916df60c094aae7dfddf652fa78e44d73
4
- data.tar.gz: 2adc318d5cda8858d1ee49a2dd32080acea1e8163807e424366d9e8e42a30943
3
+ metadata.gz: 8ce254347308d6a616bc17df194ff5e91f9c151f6606d3b77963a963c8a3186c
4
+ data.tar.gz: 7399a03d13d297b798cfc60716376129c0162697ae845e5420409c0da33ecf68
5
5
  SHA512:
6
- metadata.gz: de347fc9b0e6f9ad1385f0f5f3a1cc03595f9cf8b969f354cd87bd5173e318ec9af98cf31d2253617c369a8cd7ea9d6c08fbc489d22d30775b912f6867bab70f
7
- data.tar.gz: '08153a2284f53e6110d4f3fae0a84ae72d2739bc0146e70cbabd5a6cce30b544122204623185ad96c1706c1de518feb583a227fd8b74018b896775f884ac7053'
6
+ metadata.gz: 8a034c60f1f200e3971be9828563d43b406e1fc1feef4d2e044f880456ac2d1e0b7cf98b3d1e3292fb2641bb8c5e9d3e21c387f1a0041ae8996fa004db863d92
7
+ data.tar.gz: 1f72d605f086951abc9bff9a2dc452ecece2694cd7ad27a118589ba8c48b4e02fb6a35b97271e0007e1dde55ff1af798bf278fabedc07b2c0b74d955af270a56
@@ -0,0 +1,15 @@
1
+ # These are supported funding model platforms
2
+
3
+ github: [eclectic-coding] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4
+ patreon: # Replace with a single Patreon username
5
+ open_collective: # Replace with a single Open Collective username
6
+ ko_fi: # Replace with a single Ko-fi username
7
+ tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8
+ community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9
+ liberapay: # Replace with a single Liberapay username
10
+ issuehunt: # Replace with a single IssueHunt username
11
+ lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
12
+ polar: # Replace with a single Polar username
13
+ buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
14
+ thanks_dev: # Replace with a single thanks.dev username
15
+ custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
data/CHANGELOG.md CHANGED
@@ -8,6 +8,25 @@ from v1.0.0 onwards. Prior 0.x releases may include breaking changes between min
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.5.0] - 2026-06-02
12
+
13
+ ### Added
14
+
15
+ - `group:` option on `memoize` — assigns a method to a named invalidation group (`memoize :find, group: :database`). Groups are stored on the class and survive re-memoization; a method can belong to at most one group at a time (re-memoizing with a different group moves it). Accepts any non-empty Symbol or String. Can be set as a class default via `safe_memoize_options group: :my_group`.
16
+ - `reset_memo_group(group_name)` instance method — clears all per-instance cached entries for every method in the named group in a single call; each evicted entry fires the `:on_evict` hook. A no-op for unknown groups.
17
+ - `reset_shared_memo_group(group_name)` class method — the shared-cache equivalent of `reset_memo_group`; clears all shared-cache entries for every method in the group that was memoized with `shared: true`.
18
+ - `memo_group_methods(group_name)` instance method — returns the array of method names belonging to the given group on the instance's class (empty array for unknown groups).
19
+ - `memo_groups` instance method — returns all group names registered on the instance's class.
20
+ - `safe_memo_group_methods(group_name)` class method — class-level equivalent of `memo_group_methods`.
21
+ - `safe_memo_groups` class method — class-level equivalent of `memo_groups`.
22
+
23
+ ## [1.4.0] - 2026-06-02
24
+
25
+ ### Added
26
+
27
+ - `safe_memoize_options(**opts)` class-level macro — sets default options for every subsequent `memoize` call on the class. Per-call options take precedence; class defaults take precedence over global `SafeMemoize.configure` defaults. Accepts all `memoize` options except mode-switch options (`shared:`, `fiber_local:`, `ractor_safe:`, `shared_cache:`), which must be specified per call. Call with no arguments to clear class-level defaults.
28
+ - `copy_on_read: true` option on `memoize` — returns a `dup` (or `deep_dup` when available, e.g. ActiveRecord objects) of the cached value on every read, preventing callers from mutating shared cached state. `nil` and frozen values are returned as-is. Works across all cache paths (per-instance, LRU, shared, fiber-local, and external store). Incompatible with `ractor_safe:` (ractor-safe values are always frozen; use that guarantee instead). Can be set as a class default via `safe_memoize_options copy_on_read: true`.
29
+
11
30
  ## [1.3.0] - 2026-05-28
12
31
 
13
32
  ### Added
data/README.md CHANGED
@@ -77,6 +77,9 @@ SafeMemoize uses Ruby's `prepend` mechanism. When you call `memoize :method_name
77
77
  - [Named shared caches via `shared_cache: "name"` — cross-class cache sharing backed by a globally-registered store](#named-shared-caches)
78
78
  - [Automatic cache busting via `cache_bust:` — version-token-based invalidation; works with ActiveRecord `updated_at` and any comparable value](#automatic-cache-busting)
79
79
  - [Plugin / extension architecture — `SafeMemoize::Extension` DSL for adding custom `memoize` options and global lifecycle handlers without monkey-patching](#plugin--extension-architecture)
80
+ - [Per-class default options via `safe_memoize_options` — set TTL, max size, copy-on-read, and other defaults for every `memoize` call on the class without repeating them](#per-class-default-options-safe_memoize_options)
81
+ - [Copy-on-read via `copy_on_read: true` — returns a `dup`/`deep_dup` on every cache read to protect shared cached state from caller mutation](#copy-on-read)
82
+ - [Cache invalidation groups via `group:` — tag related methods with a group name and bust them all with a single `reset_memo_group` call](#cache-invalidation-groups)
80
83
 
81
84
  ## Installation
82
85
 
@@ -192,6 +195,79 @@ obj.reset_all_memos # Clears all memoized values
192
195
 
193
196
  [↑ Back to features](#features)
194
197
 
198
+ ### Cache invalidation groups
199
+
200
+ Tag related methods with `group:` and bust them all at once with a single `reset_memo_group` call:
201
+
202
+ ```ruby
203
+ class RepoService
204
+ prepend SafeMemoize
205
+
206
+ def find_user(id) = db.query("SELECT * FROM users WHERE id=?", id)
207
+ def find_post(id) = db.query("SELECT * FROM posts WHERE id=?", id)
208
+ def site_config = db.query("SELECT * FROM config LIMIT 1")
209
+
210
+ memoize :find_user, group: :database
211
+ memoize :find_post, group: :database
212
+ memoize :site_config # no group — unaffected by group reset
213
+ end
214
+
215
+ svc = RepoService.new
216
+ svc.find_user(1)
217
+ svc.find_post(42)
218
+ svc.site_config
219
+
220
+ svc.reset_memo_group(:database) # invalidates find_user and find_post only
221
+ svc.memoized?(:site_config) # => true — unaffected
222
+ ```
223
+
224
+ For `shared: true` methods, use the class method:
225
+
226
+ ```ruby
227
+ class CatalogService
228
+ prepend SafeMemoize
229
+
230
+ def products = fetch_all_products
231
+ def categories = fetch_all_categories
232
+
233
+ memoize :products, shared: true, group: :catalog
234
+ memoize :categories, shared: true, group: :catalog
235
+ end
236
+
237
+ CatalogService.reset_shared_memo_group(:catalog) # clears shared cache for both methods
238
+ ```
239
+
240
+ #### Introspection
241
+
242
+ ```ruby
243
+ svc.memo_groups # => [:database] — all groups on the class
244
+ svc.memo_group_methods(:database) # => [:find_user, :find_post]
245
+ CatalogService.safe_memo_groups # => [:catalog]
246
+ CatalogService.safe_memo_group_methods(:catalog) # => [:products, :categories]
247
+ ```
248
+
249
+ #### Class-wide group default
250
+
251
+ Use `safe_memoize_options` to assign all subsequently memoized methods to the same group:
252
+
253
+ ```ruby
254
+ class ApiClient
255
+ prepend SafeMemoize
256
+ safe_memoize_options group: :api
257
+
258
+ def users = http.get("/users")
259
+ def orders = http.get("/orders")
260
+
261
+ memoize :users # group: :api
262
+ memoize :orders # group: :api
263
+ memoize :health, group: nil # override — no group
264
+ end
265
+ ```
266
+
267
+ A method belongs to at most one group at a time; re-memoizing with a different `group:` moves it.
268
+
269
+ [↑ Back to features](#features)
270
+
195
271
  ### Lifecycle hooks
196
272
 
197
273
  Register callbacks that fire when cached entries are evicted or expire.
@@ -1372,6 +1448,83 @@ end
1372
1448
 
1373
1449
  [↑ Back to features](#features)
1374
1450
 
1451
+ ## Per-class default options (`safe_memoize_options`)
1452
+
1453
+ `safe_memoize_options` sets option defaults for every `memoize` call on the class, eliminating repetition when many methods share the same TTL, LRU cap, or other option. Per-call options still take precedence; class defaults take precedence over global `SafeMemoize.configure` defaults.
1454
+
1455
+ ```ruby
1456
+ class ApiClient
1457
+ prepend SafeMemoize
1458
+ safe_memoize_options ttl: 60, max_size: 200, copy_on_read: true
1459
+
1460
+ def fetch(id) = http.get(id)
1461
+ memoize :fetch # uses ttl: 60, max_size: 200, copy_on_read: true
1462
+
1463
+ def list = http.get("/all")
1464
+ memoize :list, ttl: 300 # uses max_size: 200, copy_on_read: true; ttl: 300 overrides
1465
+ end
1466
+ ```
1467
+
1468
+ Accepted options are the same as `memoize` minus the mode-switch options (`shared:`, `fiber_local:`, `ractor_safe:`, `shared_cache:`), which must be specified per call because they change the entire execution path:
1469
+
1470
+ ```ruby
1471
+ safe_memoize_options(
1472
+ ttl: 60,
1473
+ max_size: 100,
1474
+ ttl_refresh: true,
1475
+ copy_on_read: true,
1476
+ namespace: "v2",
1477
+ if: ->(v) { v.present? },
1478
+ cache_bust: :updated_at
1479
+ )
1480
+ ```
1481
+
1482
+ Call with no arguments to clear all class-level defaults:
1483
+
1484
+ ```ruby
1485
+ MyClass.safe_memoize_options # clears — subsequent memoize calls use global config or per-call options only
1486
+ ```
1487
+
1488
+ [↑ Back to features](#features)
1489
+
1490
+ ## Copy-on-read
1491
+
1492
+ Pass `copy_on_read: true` to `memoize` to return a `dup` (or `deep_dup` when available, e.g. ActiveRecord objects) of the stored value on every cache read. This prevents callers from mutating the shared cached object:
1493
+
1494
+ ```ruby
1495
+ class ConfigService
1496
+ prepend SafeMemoize
1497
+
1498
+ def settings = {host: "localhost", port: 8080}
1499
+ memoize :settings, copy_on_read: true
1500
+ end
1501
+
1502
+ svc = ConfigService.new
1503
+ result = svc.settings
1504
+ result[:host] = "mutated" # only affects the caller's copy
1505
+
1506
+ svc.settings[:host] # => "localhost" — cache is unaffected
1507
+ ```
1508
+
1509
+ `nil` and frozen values are returned as-is (no dup attempted). `copy_on_read:` works across all cache paths: per-instance hash, LRU (`max_size:`), class-level shared (`shared: true`), fiber-local (`fiber_local: true`), and external stores. It is incompatible with `ractor_safe: true` (ractor-safe values are always frozen; rely on that guarantee instead).
1510
+
1511
+ Set it as a class-wide default with `safe_memoize_options`:
1512
+
1513
+ ```ruby
1514
+ class ReportService
1515
+ prepend SafeMemoize
1516
+ safe_memoize_options copy_on_read: true
1517
+
1518
+ def summary = build_summary
1519
+ memoize :summary
1520
+
1521
+ def details = build_details
1522
+ memoize :details
1523
+ end
1524
+ ```
1525
+
1526
+ [↑ Back to features](#features)
1527
+
1375
1528
  ## Ractor-safe shared cache
1376
1529
 
1377
1530
  Pass `ractor_safe: true` (together with `shared: true`) to replace the `Mutex`-backed class-level shared cache with a supervisor `Ractor` that owns the mutable cache hash. All reads and writes are serialised through message passing, so the cache is safe to use from multiple Ractors.
@@ -1531,6 +1684,8 @@ Anything **not** listed here — internal modules, private methods, `@__safe_mem
1531
1684
  | `namespace:` | `String \| nil` | `nil` | Namespace prefix prepended to the cache key's first element; must not contain `:`; takes precedence over the class-level and global namespace |
1532
1685
  | `shared_cache:` | `String \| nil` | `nil` | Name of a globally-registered shared store; incompatible with `shared:`, `store:`, `fiber_local:`, `ractor_safe:`, and `max_size:` |
1533
1686
  | `cache_bust:` | `Proc \| Symbol \| nil` | `nil` | Version-token callable; invoked on the instance at each lookup; token is folded into the key; incompatible with `key:` |
1687
+ | `copy_on_read:` | `Boolean` | `false` | Return a `dup`/`deep_dup` of the cached value on every read; protects shared state from caller mutation; nil and frozen values pass through; incompatible with `ractor_safe:` |
1688
+ | `group:` | `Symbol \| String \| nil` | `nil` | Assigns the method to a named invalidation group; call `reset_memo_group` / `reset_shared_memo_group` to bust all methods in the group at once; a method belongs to at most one group |
1534
1689
  | *(extension options)* | any | — | Unknown kwargs are validated against registered extensions; raise `ArgumentError` if unclaimed |
1535
1690
 
1536
1691
  ### `memoize_all` options (class method)
@@ -1544,6 +1699,12 @@ All `memoize` option keys above, plus:
1544
1699
  | `include_protected:` | `Boolean` | `false` |
1545
1700
  | `include_private:` | `Boolean` | `false` |
1546
1701
 
1702
+ ### `safe_memoize_options` (class method)
1703
+
1704
+ | Option key | Type | Default | Notes |
1705
+ |---|---|---|---|
1706
+ | any `memoize` key except mode-switches | — | — | Accepts `ttl:`, `max_size:`, `ttl_refresh:`, `if:`, `unless:`, `key:`, `cache_bust:`, `copy_on_read:`, `namespace:`, `store:`, `group:`; raises `ArgumentError` for `shared:`, `fiber_local:`, `ractor_safe:`, `shared_cache:` |
1707
+
1547
1708
  ### Instance methods (public)
1548
1709
 
1549
1710
  **Inspection**
@@ -1564,10 +1725,18 @@ All `memoize` option keys above, plus:
1564
1725
  | Method | Returns |
1565
1726
  |---|---|
1566
1727
  | `reset_memo(method_name, *args, **kwargs)` | `nil` |
1728
+ | `reset_memo_group(group_name)` | `nil` |
1567
1729
  | `reset_all_memos` | `nil` |
1568
1730
  | `memo_touch(method_name, *args, ttl: nil, **kwargs)` | `Boolean` |
1569
1731
  | `memo_refresh(method_name, *args, **kwargs)` | cached value |
1570
1732
 
1733
+ **Group introspection**
1734
+
1735
+ | Method | Returns |
1736
+ |---|---|
1737
+ | `memo_groups` | `Array<Symbol>` — all group names on the class |
1738
+ | `memo_group_methods(group_name)` | `Array<Symbol>` — methods in the group |
1739
+
1571
1740
  **Warm-up and persistence**
1572
1741
 
1573
1742
  | Method | Returns |
@@ -1619,11 +1788,19 @@ All `memoize` option keys above, plus:
1619
1788
  |---|---|
1620
1789
  | `reset_shared_memo(method_name, *args, **kwargs)` | `nil` |
1621
1790
  | `reset_all_shared_memos` | `nil` |
1791
+ | `reset_shared_memo_group(group_name)` | `nil` |
1622
1792
  | `shared_memoized?(method_name, *args, **kwargs)` | `Boolean` |
1623
1793
  | `shared_memo_count(method_name = nil)` | `Integer` |
1624
1794
  | `shared_memo_age(method_name, *args, **kwargs)` | `Numeric \| nil` |
1625
1795
  | `shared_memo_stale?(method_name, *args, **kwargs)` | `Boolean` |
1626
1796
 
1797
+ ### Group class methods (available on any class that uses `group:`)
1798
+
1799
+ | Method | Returns |
1800
+ |---|---|
1801
+ | `safe_memo_groups` | `Array<Symbol>` — all group names on the class |
1802
+ | `safe_memo_group_methods(group_name)` | `Array<Symbol>` — methods belonging to the group |
1803
+
1627
1804
  **Ractor-safe shared cache (added when any method uses `ractor_safe: true`)**
1628
1805
 
1629
1806
  | Method | Returns |
data/ROADMAP.md CHANGED
@@ -4,6 +4,27 @@ This document tracks the planned evolution of SafeMemoize through v1.0.0 and bey
4
4
 
5
5
  ---
6
6
 
7
+ ## v1.6.0 — Resilience
8
+
9
+ *Goal: make external-store memoization resilient to infrastructure failures.*
10
+
11
+ | Feature | Description | Status |
12
+ |---|---|---|
13
+ | Circuit breaker for external stores | When a `store:` adapter raises on `read` or `write`, automatically fall back to the per-instance in-process hash rather than propagating the exception; configurable error threshold and recovery probe interval | Planned |
14
+
15
+ ---
16
+
17
+ ## v1.7.0 — Advanced Store Features
18
+
19
+ *Goal: multi-process performance patterns for high-traffic deployments.*
20
+
21
+ | Feature | Description | Status |
22
+ |---|---|---|
23
+ | Multi-level (L1/L2) caching | `store: [memory_store, redis_store]` — check in-process first, fall back to the remote store on miss, and promote to L1 on read; each level can have independent TTL and eviction settings | Planned |
24
+ | Stampede protection | Probabilistic early expiry (XFetch algorithm) for external stores; recomputes slightly before a TTL expires to prevent multiple processes hitting a cold miss simultaneously | Planned |
25
+
26
+ ---
27
+
7
28
  ## v2.0.0 — Next Generation (Long Horizon)
8
29
 
9
30
  *Goal: incorporate real-world usage feedback, clean up accumulated API surface, and open a path for advanced extension.*
@@ -37,7 +37,7 @@ module SafeMemoize
37
37
  # a supervisor +Ractor+ rather than a +Mutex+-protected ivar, making it accessible
38
38
  # from worker Ractors. Requires +shared: true+. Cached values are deep-frozen via
39
39
  # +Ractor.make_shareable+. Incompatible with +if:+, +unless:+, +max_size:+,
40
- # +ttl_refresh:+, +key:+, and +store:+.
40
+ # +ttl_refresh:+, +key:+, +store:+, and +copy_on_read:+.
41
41
  # @param namespace [String, nil] prefix prepended to every cache key for this method,
42
42
  # scoping it to a logical partition. Takes precedence over both the class-level
43
43
  # {#safe_memoize_namespace} and the global {SafeMemoize::Configuration#namespace}.
@@ -59,6 +59,16 @@ module SafeMemoize
59
59
  # {SafeMemoize.register_shared_cache} before the class is loaded to supply a custom
60
60
  # adapter. Incompatible with +shared:+, +store:+, +fiber_local:+, +ractor_safe:+,
61
61
  # and +max_size:+. Composes naturally with +namespace:+, +ttl:+, +if:+, and +key:+.
62
+ # @param copy_on_read [Boolean] when +true+, every cache read returns a +dup+ (or
63
+ # +deep_dup+ when available) of the stored value rather than the cached object
64
+ # itself. Prevents callers from mutating shared cached state. Frozen and +nil+
65
+ # values are returned as-is. Incompatible with +ractor_safe:+ (ractor values are
66
+ # always frozen; use that guarantee instead).
67
+ # @param group [Symbol, String, nil] assigns the method to a named invalidation
68
+ # group. Call {PublicMethods#reset_memo_group} on an instance (or
69
+ # {.reset_shared_memo_group} for shared-mode methods) to bust every method in the
70
+ # group at once. A method may belong to at most one group; re-memoizing with a
71
+ # different group moves it. Must be a non-empty Symbol or String.
62
72
  # @return [void]
63
73
  # @raise [ArgumentError] if the method does not exist, or option values are invalid
64
74
  #
@@ -73,10 +83,13 @@ module SafeMemoize
73
83
  # @example Conditional — only cache successful responses
74
84
  # memoize :fetch, if: ->(v) { v[:status] == 200 }
75
85
  #
86
+ # @example Copy-on-read — protect mutable cached config
87
+ # memoize :config, copy_on_read: true
88
+ #
76
89
  # @example With a custom store
77
90
  # STORE = SafeMemoize::Stores::Memory.new
78
91
  # memoize :fetch, store: STORE, ttl: 300
79
- def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, unless: nil, shared: false, key: nil, store: nil, fiber_local: false, ractor_safe: false, namespace: nil, shared_cache: nil, cache_bust: nil, **extension_options)
92
+ def memoize(method_name, ttl: UNSET, max_size: UNSET, ttl_refresh: UNSET, if: UNSET, unless: UNSET, shared: UNSET, key: UNSET, store: UNSET, fiber_local: UNSET, ractor_safe: UNSET, namespace: UNSET, shared_cache: UNSET, cache_bust: UNSET, copy_on_read: UNSET, group: UNSET, **extension_options)
80
93
  method_name = method_name.to_sym
81
94
 
82
95
  unless method_defined?(method_name) || private_method_defined?(method_name) || protected_method_defined?(method_name)
@@ -102,18 +115,48 @@ module SafeMemoize
102
115
  cache_bust = injected[:cache_bust] if injected.key?(:cache_bust)
103
116
  end
104
117
 
118
+ # :if and :unless are reserved Ruby keywords; use binding to extract them
119
+ cond_if = binding.local_variable_get(:if)
120
+ cond_unless = binding.local_variable_get(:unless)
121
+
122
+ # Apply class-level defaults (safe_memoize_options) for any still-unset options
123
+ if (cls_defaults = __safe_memoize_defaults__)
124
+ ttl = cls_defaults[:ttl] if ttl.equal?(UNSET) && cls_defaults.key?(:ttl)
125
+ max_size = cls_defaults[:max_size] if max_size.equal?(UNSET) && cls_defaults.key?(:max_size)
126
+ ttl_refresh = cls_defaults[:ttl_refresh] if ttl_refresh.equal?(UNSET) && cls_defaults.key?(:ttl_refresh)
127
+ cond_if = cls_defaults[:if] if cond_if.equal?(UNSET) && cls_defaults.key?(:if)
128
+ cond_unless = cls_defaults[:unless] if cond_unless.equal?(UNSET) && cls_defaults.key?(:unless)
129
+ key = cls_defaults[:key] if key.equal?(UNSET) && cls_defaults.key?(:key)
130
+ cache_bust = cls_defaults[:cache_bust] if cache_bust.equal?(UNSET) && cls_defaults.key?(:cache_bust)
131
+ copy_on_read = cls_defaults[:copy_on_read] if copy_on_read.equal?(UNSET) && cls_defaults.key?(:copy_on_read)
132
+ namespace = cls_defaults[:namespace] if namespace.equal?(UNSET) && cls_defaults.key?(:namespace)
133
+ store = cls_defaults[:store] if store.equal?(UNSET) && cls_defaults.key?(:store)
134
+ group = cls_defaults[:group] if group.equal?(UNSET) && cls_defaults.key?(:group)
135
+ end
136
+
137
+ # Normalize remaining UNSET to original per-call defaults
138
+ ttl = nil if ttl.equal?(UNSET)
139
+ max_size = nil if max_size.equal?(UNSET)
140
+ ttl_refresh = false if ttl_refresh.equal?(UNSET)
141
+ shared = false if shared.equal?(UNSET)
142
+ key = nil if key.equal?(UNSET)
143
+ store = nil if store.equal?(UNSET)
144
+ fiber_local = false if fiber_local.equal?(UNSET)
145
+ ractor_safe = false if ractor_safe.equal?(UNSET)
146
+ namespace = nil if namespace.equal?(UNSET)
147
+ shared_cache = nil if shared_cache.equal?(UNSET)
148
+ cache_bust = nil if cache_bust.equal?(UNSET)
149
+ copy_on_read = false if copy_on_read.equal?(UNSET)
150
+ group = nil if group.equal?(UNSET)
151
+ cond_if = nil if cond_if.equal?(UNSET)
152
+ cond_unless = nil if cond_unless.equal?(UNSET)
153
+
105
154
  visibility = memoized_method_visibility(method_name)
106
155
 
107
156
  config = SafeMemoize.configuration
108
157
  ttl = config.default_ttl if ttl.nil?
109
158
  max_size = config.default_max_size if max_size.nil?
110
159
 
111
- # :if and :unless are reserved Ruby keywords, so they can't be referenced
112
- # as local variables directly. binding.local_variable_get is the only way
113
- # to read keyword arguments with those names inside the method body.
114
- cond_if = binding.local_variable_get(:if)
115
- cond_unless = binding.local_variable_get(:unless)
116
-
117
160
  ttl = if ttl.nil?
118
161
  nil
119
162
  else
@@ -167,6 +210,7 @@ module SafeMemoize
167
210
  raise ArgumentError, "ractor_safe: is incompatible with ttl_refresh:" if ttl_refresh
168
211
  raise ArgumentError, "ractor_safe: is incompatible with key:" if key
169
212
  raise ArgumentError, "ractor_safe: is incompatible with store:" if store
213
+ raise ArgumentError, "ractor_safe: is incompatible with copy_on_read:" if copy_on_read
170
214
  end
171
215
 
172
216
  if namespace
@@ -176,6 +220,15 @@ module SafeMemoize
176
220
  __safe_memo_method_namespaces__[method_name] = namespace
177
221
  end
178
222
 
223
+ if group
224
+ unless group.is_a?(Symbol) || group.is_a?(String)
225
+ raise ArgumentError, "group: must be a Symbol or String (got #{group.class})"
226
+ end
227
+ group = group.to_sym
228
+ raise ArgumentError, "group: must not be empty" if group.empty?
229
+ __safe_memo_register_group__(method_name, group)
230
+ end
231
+
179
232
  if shared_cache
180
233
  raise ArgumentError, "shared_cache: must be a String (got #{shared_cache.class})" unless shared_cache.is_a?(String)
181
234
  raise ArgumentError, "shared_cache: must not be empty" if shared_cache.empty?
@@ -217,6 +270,18 @@ module SafeMemoize
217
270
  ->(result) { !cond_unless.call(result) }
218
271
  end
219
272
 
273
+ # Build a value-duplication function for copy_on_read: true.
274
+ # Frozen and nil values are returned as-is; deep_dup is preferred when available
275
+ # (e.g. ActiveRecord objects) so nested mutable structures are also protected.
276
+ dup_fn = if copy_on_read
277
+ lambda do |v|
278
+ return v if v.nil? || v.frozen?
279
+ v.respond_to?(:deep_dup) ? v.deep_dup : v.dup
280
+ end
281
+ else
282
+ ->(v) { v }
283
+ end
284
+
220
285
  if effective_store
221
286
  miss = SafeMemoize::Stores::Base::MISS
222
287
 
@@ -231,7 +296,7 @@ module SafeMemoize
231
296
  effective_store.write(cache_key, cached, expires_in: ttl) if ttl_refresh
232
297
  record_cache_hit(cache_key)
233
298
  call_memo_hooks(:on_hit, cache_key, {value: cached, expires_at: nil, cached_at: nil})
234
- return cached
299
+ return dup_fn.call(cached)
235
300
  end
236
301
 
237
302
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -249,7 +314,7 @@ module SafeMemoize
249
314
  record_cache_miss(cache_key, elapsed_time)
250
315
  call_memo_hooks(:on_miss, cache_key, {value: value, expires_at: nil, cached_at: now})
251
316
 
252
- value
317
+ dup_fn.call(value)
253
318
  end
254
319
 
255
320
  send(visibility, method_name)
@@ -278,7 +343,7 @@ module SafeMemoize
278
343
  record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
279
344
  record_cache_hit(cache_key)
280
345
  call_memo_hooks(:on_hit, cache_key, record)
281
- memo_record_value(record)
346
+ dup_fn.call(memo_record_value(record))
282
347
  else
283
348
  call_memo_hooks(:on_expire, cache_key, record) if record
284
349
 
@@ -312,7 +377,7 @@ module SafeMemoize
312
377
  record_cache_miss(cache_key, elapsed_time)
313
378
  call_memo_hooks(:on_miss, cache_key, new_record)
314
379
 
315
- value
380
+ dup_fn.call(value)
316
381
  end
317
382
  end
318
383
 
@@ -356,7 +421,7 @@ module SafeMemoize
356
421
  record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
357
422
  record_cache_hit(cache_key)
358
423
  call_memo_hooks(:on_hit, cache_key, record)
359
- record[:value]
424
+ dup_fn.call(record[:value])
360
425
  else
361
426
  call_memo_hooks(:on_expire, cache_key, record) if record && !record_live
362
427
 
@@ -388,7 +453,7 @@ module SafeMemoize
388
453
  record_cache_miss(cache_key, elapsed_time)
389
454
  call_memo_hooks(:on_miss, cache_key, new_record)
390
455
 
391
- value
456
+ dup_fn.call(value)
392
457
  end
393
458
  end
394
459
  end
@@ -417,7 +482,7 @@ module SafeMemoize
417
482
  record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
418
483
  record_cache_hit(cache_key)
419
484
  call_memo_hooks(:on_hit, cache_key, record)
420
- memo_record_value(record)
485
+ dup_fn.call(memo_record_value(record))
421
486
  else
422
487
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
423
488
  value = Adapters::OpenTelemetry.trace(SafeMemoize.configuration.opentelemetry_tracer, method_name, self.class.name) { super(*args, **kwargs) }
@@ -434,7 +499,7 @@ module SafeMemoize
434
499
  record_cache_miss(cache_key, elapsed_time)
435
500
  call_memo_hooks(:on_miss, cache_key, new_record)
436
501
 
437
- value
502
+ dup_fn.call(value)
438
503
  end
439
504
  end
440
505
  else
@@ -442,7 +507,7 @@ module SafeMemoize
442
507
  if (record = memo_cache_record(cache_key))
443
508
  record_cache_hit(cache_key)
444
509
  call_memo_hooks(:on_hit, cache_key, record)
445
- return memo_record_value(record)
510
+ return dup_fn.call(memo_record_value(record))
446
511
  end
447
512
 
448
513
  # Cache miss - compute and store
@@ -459,7 +524,7 @@ module SafeMemoize
459
524
  call_memo_hooks(:on_miss, cache_key, new_record)
460
525
  end
461
526
 
462
- result
527
+ dup_fn.call(result)
463
528
  end
464
529
  end
465
530
 
@@ -520,6 +585,41 @@ module SafeMemoize
520
585
  @__safe_memoize_namespace__ = ns
521
586
  end
522
587
 
588
+ # Sets class-wide default options applied to every subsequent {#memoize} call
589
+ # on this class. Per-call options take precedence; class defaults take
590
+ # precedence over global {SafeMemoize::Configuration} defaults.
591
+ #
592
+ # Call with no arguments (or an empty hash) to clear all class-level defaults.
593
+ #
594
+ # @example Apply a TTL and LRU cap to every memoized method on the class
595
+ # class ApiClient
596
+ # prepend SafeMemoize
597
+ # safe_memoize_options ttl: 60, max_size: 200
598
+ #
599
+ # def fetch(id) = http.get(id)
600
+ # memoize :fetch # uses ttl: 60, max_size: 200
601
+ #
602
+ # def list = http.get("/all")
603
+ # memoize :list, ttl: 300 # uses max_size: 200, ttl: 300
604
+ # end
605
+ #
606
+ # @example Protect all cached values from mutation
607
+ # safe_memoize_options copy_on_read: true
608
+ #
609
+ # @param opts [Hash] any subset of {#memoize} options except mode-switch options
610
+ # (+shared:+, +fiber_local:+, +ractor_safe:+, +shared_cache:+)
611
+ # @return [Hash] the stored defaults
612
+ # @raise [ArgumentError] for disallowed options
613
+ def safe_memoize_options(**opts)
614
+ disallowed = %i[shared fiber_local ractor_safe shared_cache]
615
+ bad = opts.keys & disallowed
616
+ unless bad.empty?
617
+ raise ArgumentError,
618
+ "safe_memoize_options does not accept #{bad.map { |k| ":#{k}" }.join(", ")} — pass mode-switch options per memoize call"
619
+ end
620
+ @__safe_memoize_defaults__ = opts.empty? ? nil : opts
621
+ end
622
+
523
623
  # Memoizes every eligible public instance method defined directly on the class.
524
624
  #
525
625
  # Accepts all options that {#memoize} accepts, plus +:except:+ and +:only:+.
@@ -679,6 +779,35 @@ module SafeMemoize
679
779
  end
680
780
  end
681
781
 
782
+ # Clears all shared-cache entries for every method in the given group.
783
+ #
784
+ # Only affects methods memoized with +shared: true+. For per-instance cache
785
+ # invalidation use {PublicMethods#reset_memo_group} on the instance.
786
+ #
787
+ # @param group_name [Symbol, String]
788
+ # @return [void]
789
+ def reset_shared_memo_group(group_name)
790
+ group_name = group_name.to_sym
791
+ (__safe_memo_groups__[group_name] || []).each { |m| reset_shared_memo(m) }
792
+ end
793
+
794
+ # Returns the method names belonging to the given invalidation group, or an
795
+ # empty array when the group is unknown.
796
+ #
797
+ # @param group_name [Symbol, String]
798
+ # @return [Array<Symbol>]
799
+ def safe_memo_group_methods(group_name)
800
+ group_name = group_name.to_sym
801
+ (__safe_memo_groups__[group_name] || []).dup
802
+ end
803
+
804
+ # Returns all group names registered on this class.
805
+ #
806
+ # @return [Array<Symbol>]
807
+ def safe_memo_groups
808
+ __safe_memo_groups__.keys
809
+ end
810
+
682
811
  private
683
812
 
684
813
  def __safe_memo_shared_cache__
@@ -705,6 +834,21 @@ module SafeMemoize
705
834
  @__safe_memo_class_cache_bust_generators__ ||= {}
706
835
  end
707
836
 
837
+ def __safe_memoize_defaults__
838
+ @__safe_memoize_defaults__
839
+ end
840
+
841
+ def __safe_memo_groups__
842
+ @__safe_memo_groups__ ||= {}
843
+ end
844
+
845
+ def __safe_memo_register_group__(method_name, group)
846
+ groups = __safe_memo_groups__
847
+ # Remove method from any prior group it belonged to
848
+ groups.each_value { |methods| methods.delete(method_name) }
849
+ (groups[group] ||= []) << method_name
850
+ end
851
+
708
852
  # Resolves the effective first-element key sym for a given bare method name,
709
853
  # applying the active namespace. Used by class-level cache operations where
710
854
  # instance methods (compute_cache_key) are unavailable.
@@ -359,6 +359,37 @@ module SafeMemoize
359
359
  end
360
360
  end
361
361
 
362
+ # Clears all per-instance cached entries for every method belonging to the
363
+ # given invalidation group (declared via +memoize :method, group: :name+).
364
+ #
365
+ # A no-op when the group is unknown or has no members. Each evicted entry
366
+ # fires the +:on_evict+ hook. For shared-mode methods use the class-level
367
+ # {ClassMethods.reset_shared_memo_group} instead.
368
+ #
369
+ # @param group_name [Symbol, String]
370
+ # @return [void]
371
+ def reset_memo_group(group_name)
372
+ group_name = group_name.to_sym
373
+ (self.class.send(:__safe_memo_groups__)[group_name] || []).each { |m| reset_memo(m) }
374
+ end
375
+
376
+ # Returns the method names belonging to the given invalidation group on
377
+ # this instance's class, or an empty array when the group is unknown.
378
+ #
379
+ # @param group_name [Symbol, String]
380
+ # @return [Array<Symbol>]
381
+ def memo_group_methods(group_name)
382
+ group_name = group_name.to_sym
383
+ (self.class.send(:__safe_memo_groups__)[group_name] || []).dup
384
+ end
385
+
386
+ # Returns all invalidation group names registered on this instance's class.
387
+ #
388
+ # @return [Array<Symbol>]
389
+ def memo_groups
390
+ self.class.send(:__safe_memo_groups__).keys
391
+ end
392
+
362
393
  # Clears all cached entries for every method on this instance.
363
394
  # Each evicted entry fires the +:on_evict+ hook.
364
395
  #
@@ -2,5 +2,5 @@
2
2
 
3
3
  module SafeMemoize
4
4
  # The current gem version string.
5
- VERSION = "1.3.0"
5
+ VERSION = "1.5.0"
6
6
  end
data/lib/safe_memoize.rb CHANGED
@@ -55,6 +55,9 @@ module SafeMemoize
55
55
  # Rescue this to catch any error raised by the library itself.
56
56
  class Error < StandardError; end
57
57
 
58
+ # @api private — sentinel distinguishing "not passed" from explicit nil/false in memoize kwargs
59
+ UNSET = Object.new.freeze
60
+
58
61
  # @api private
59
62
  SHARED_CACHE_REGISTRY = {}
60
63
  # @api private
data/sig/safe_memoize.rbs CHANGED
@@ -17,7 +17,9 @@ module SafeMemoize
17
17
  @__safe_memo_shared_cache__: Hash[memo_key, memo_record]?
18
18
  @__safe_memo_shared_mutex__: Mutex?
19
19
  @__safe_memo_shared_lru_order__: Hash[Symbol, Hash[memo_key, true]]?
20
+ @__safe_memo_groups__: Hash[Symbol, Array[Symbol]]?
20
21
 
22
+ UNSET: untyped
21
23
  SHARED_CACHE_REGISTRY: Hash[String, Stores::Base]
22
24
  SHARED_CACHE_MUTEX: Mutex
23
25
  EXTENSION_REGISTRY: Hash[Symbol, untyped]
@@ -64,18 +66,22 @@ module SafeMemoize
64
66
  end
65
67
 
66
68
  module ClassMethods
67
- 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)?, ?store: Stores::Base?, ?fiber_local: bool, ?ractor_safe: bool, ?namespace: String?, ?shared_cache: String?, ?cache_bust: (^() -> untyped) | Symbol | nil, **untyped extension_options) -> void
69
+ 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)?, ?store: Stores::Base?, ?fiber_local: bool, ?ractor_safe: bool, ?namespace: String?, ?shared_cache: String?, ?cache_bust: (^() -> untyped) | Symbol | nil, ?copy_on_read: bool, ?group: Symbol | String | nil, **untyped extension_options) -> void
68
70
  def safe_memoize_store: () -> Stores::Base?
69
71
  def safe_memoize_store=: (Stores::Base?) -> Stores::Base?
70
72
  def safe_memoize_namespace: () -> String?
71
73
  def safe_memoize_namespace=: (String?) -> String?
72
- 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)?, ?fiber_local: bool, ?namespace: String?, ?shared_cache: String?) -> void
74
+ def safe_memoize_options: (**untyped opts) -> Hash[Symbol, untyped]?
75
+ 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)?, ?fiber_local: bool, ?namespace: String?, ?shared_cache: String?, ?copy_on_read: bool) -> void
73
76
  def reset_shared_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
74
77
  def reset_all_shared_memos: () -> void
75
78
  def shared_memoized?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool
76
79
  def shared_memo_count: (?Symbol | String method_name) -> Integer
77
80
  def shared_memo_age: (Symbol | String method_name, *untyped args, **untyped kwargs) -> Float?
78
81
  def shared_memo_stale?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool
82
+ def reset_shared_memo_group: (Symbol | String group_name) -> void
83
+ def safe_memo_group_methods: (Symbol | String group_name) -> Array[Symbol]
84
+ def safe_memo_groups: () -> Array[Symbol]
79
85
 
80
86
  private
81
87
 
@@ -85,6 +91,9 @@ module SafeMemoize
85
91
  def __safe_memo_class_key_generators__: () -> Hash[Symbol, Proc]
86
92
  def __safe_memo_method_namespaces__: () -> Hash[Symbol, String]
87
93
  def __safe_memo_class_cache_bust_generators__: () -> Hash[Symbol, Proc | Symbol]
94
+ def __safe_memoize_defaults__: () -> Hash[Symbol, untyped]?
95
+ def __safe_memo_groups__: () -> Hash[Symbol, Array[Symbol]]
96
+ def __safe_memo_register_group__: (Symbol method_name, Symbol group) -> void
88
97
  def memoized_method_visibility: (Symbol method_name) -> Symbol
89
98
  end
90
99
 
@@ -111,6 +120,9 @@ module SafeMemoize
111
120
  def memo_age: (Symbol | String method_name, *untyped args, **untyped kwargs) -> Float?
112
121
  def memo_stale?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool
113
122
  def reset_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
123
+ def reset_memo_group: (Symbol | String group_name) -> void
124
+ def memo_group_methods: (Symbol | String group_name) -> Array[Symbol]
125
+ def memo_groups: () -> Array[Symbol]
114
126
  def reset_all_memos: () -> void
115
127
  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? }?
116
128
  end
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: 1.3.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -39,6 +39,7 @@ executables: []
39
39
  extensions: []
40
40
  extra_rdoc_files: []
41
41
  files:
42
+ - ".github/FUNDING.yml"
42
43
  - ".github/workflows/ci.yml"
43
44
  - ".github/workflows/release.yml"
44
45
  - ".yardopts"