safe_memoize 1.4.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: e34f9a8aaa025eca2f817b633409686788e99442f10de2fede62f30443f9a0fc
4
- data.tar.gz: 21560a08d7060ee77da109da4a91024899674da14ec41e051ff5cb08a5d913f2
3
+ metadata.gz: 8ce254347308d6a616bc17df194ff5e91f9c151f6606d3b77963a963c8a3186c
4
+ data.tar.gz: 7399a03d13d297b798cfc60716376129c0162697ae845e5420409c0da33ecf68
5
5
  SHA512:
6
- metadata.gz: '0408a2a2322c7861ec0eab88a62e02a15c59c552301e8fe3b7712691d03f385c81a2efbcf8ef06da57b50ae304edc18a7794001c43093b9fc49c180449a68199'
7
- data.tar.gz: 421ad6f07416395bef2723fd00a20d7e20c4becd2fec0da9f774ca476d370b1bb459b8d895c2cb7535da9898e27afcfdfcf3d089923ff8aac426dee0c89c5aa8
6
+ metadata.gz: 8a034c60f1f200e3971be9828563d43b406e1fc1feef4d2e044f880456ac2d1e0b7cf98b3d1e3292fb2641bb8c5e9d3e21c387f1a0041ae8996fa004db863d92
7
+ data.tar.gz: 1f72d605f086951abc9bff9a2dc452ecece2694cd7ad27a118589ba8c48b4e02fb6a35b97271e0007e1dde55ff1af798bf278fabedc07b2c0b74d955af270a56
data/CHANGELOG.md CHANGED
@@ -8,6 +8,18 @@ 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
+
11
23
  ## [1.4.0] - 2026-06-02
12
24
 
13
25
  ### Added
data/README.md CHANGED
@@ -79,6 +79,7 @@ SafeMemoize uses Ruby's `prepend` mechanism. When you call `memoize :method_name
79
79
  - [Plugin / extension architecture — `SafeMemoize::Extension` DSL for adding custom `memoize` options and global lifecycle handlers without monkey-patching](#plugin--extension-architecture)
80
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
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)
82
83
 
83
84
  ## Installation
84
85
 
@@ -194,6 +195,79 @@ obj.reset_all_memos # Clears all memoized values
194
195
 
195
196
  [↑ Back to features](#features)
196
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
+
197
271
  ### Lifecycle hooks
198
272
 
199
273
  Register callbacks that fire when cached entries are evicted or expire.
@@ -1611,6 +1685,7 @@ Anything **not** listed here — internal modules, private methods, `@__safe_mem
1611
1685
  | `shared_cache:` | `String \| nil` | `nil` | Name of a globally-registered shared store; incompatible with `shared:`, `store:`, `fiber_local:`, `ractor_safe:`, and `max_size:` |
1612
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:` |
1613
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 |
1614
1689
  | *(extension options)* | any | — | Unknown kwargs are validated against registered extensions; raise `ArgumentError` if unclaimed |
1615
1690
 
1616
1691
  ### `memoize_all` options (class method)
@@ -1628,7 +1703,7 @@ All `memoize` option keys above, plus:
1628
1703
 
1629
1704
  | Option key | Type | Default | Notes |
1630
1705
  |---|---|---|---|
1631
- | any `memoize` key except mode-switches | — | — | Accepts `ttl:`, `max_size:`, `ttl_refresh:`, `if:`, `unless:`, `key:`, `cache_bust:`, `copy_on_read:`, `namespace:`, `store:`; raises `ArgumentError` for `shared:`, `fiber_local:`, `ractor_safe:`, `shared_cache:` |
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:` |
1632
1707
 
1633
1708
  ### Instance methods (public)
1634
1709
 
@@ -1650,10 +1725,18 @@ All `memoize` option keys above, plus:
1650
1725
  | Method | Returns |
1651
1726
  |---|---|
1652
1727
  | `reset_memo(method_name, *args, **kwargs)` | `nil` |
1728
+ | `reset_memo_group(group_name)` | `nil` |
1653
1729
  | `reset_all_memos` | `nil` |
1654
1730
  | `memo_touch(method_name, *args, ttl: nil, **kwargs)` | `Boolean` |
1655
1731
  | `memo_refresh(method_name, *args, **kwargs)` | cached value |
1656
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
+
1657
1740
  **Warm-up and persistence**
1658
1741
 
1659
1742
  | Method | Returns |
@@ -1705,11 +1788,19 @@ All `memoize` option keys above, plus:
1705
1788
  |---|---|
1706
1789
  | `reset_shared_memo(method_name, *args, **kwargs)` | `nil` |
1707
1790
  | `reset_all_shared_memos` | `nil` |
1791
+ | `reset_shared_memo_group(group_name)` | `nil` |
1708
1792
  | `shared_memoized?(method_name, *args, **kwargs)` | `Boolean` |
1709
1793
  | `shared_memo_count(method_name = nil)` | `Integer` |
1710
1794
  | `shared_memo_age(method_name, *args, **kwargs)` | `Numeric \| nil` |
1711
1795
  | `shared_memo_stale?(method_name, *args, **kwargs)` | `Boolean` |
1712
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
+
1713
1804
  **Ractor-safe shared cache (added when any method uses `ractor_safe: true`)**
1714
1805
 
1715
1806
  | Method | Returns |
data/ROADMAP.md CHANGED
@@ -4,16 +4,6 @@ This document tracks the planned evolution of SafeMemoize through v1.0.0 and bey
4
4
 
5
5
  ---
6
6
 
7
- ## v1.5.0 — Cache Invalidation
8
-
9
- *Goal: group-level cache invalidation so related methods can be busted in one operation.*
10
-
11
- | Feature | Description | Status |
12
- |---|---|---|
13
- | Memoization groups | `memoize :find, group: :database` then `reset_memo_group(:database)` to invalidate all methods tagged with the same group at once; groups can span multiple methods on the same class | Planned |
14
-
15
- ---
16
-
17
7
  ## v1.6.0 — Resilience
18
8
 
19
9
  *Goal: make external-store memoization resilient to infrastructure failures.*
@@ -64,6 +64,11 @@ module SafeMemoize
64
64
  # itself. Prevents callers from mutating shared cached state. Frozen and +nil+
65
65
  # values are returned as-is. Incompatible with +ractor_safe:+ (ractor values are
66
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.
67
72
  # @return [void]
68
73
  # @raise [ArgumentError] if the method does not exist, or option values are invalid
69
74
  #
@@ -84,7 +89,7 @@ module SafeMemoize
84
89
  # @example With a custom store
85
90
  # STORE = SafeMemoize::Stores::Memory.new
86
91
  # memoize :fetch, store: STORE, ttl: 300
87
- 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, **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)
88
93
  method_name = method_name.to_sym
89
94
 
90
95
  unless method_defined?(method_name) || private_method_defined?(method_name) || protected_method_defined?(method_name)
@@ -126,6 +131,7 @@ module SafeMemoize
126
131
  copy_on_read = cls_defaults[:copy_on_read] if copy_on_read.equal?(UNSET) && cls_defaults.key?(:copy_on_read)
127
132
  namespace = cls_defaults[:namespace] if namespace.equal?(UNSET) && cls_defaults.key?(:namespace)
128
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)
129
135
  end
130
136
 
131
137
  # Normalize remaining UNSET to original per-call defaults
@@ -141,6 +147,7 @@ module SafeMemoize
141
147
  shared_cache = nil if shared_cache.equal?(UNSET)
142
148
  cache_bust = nil if cache_bust.equal?(UNSET)
143
149
  copy_on_read = false if copy_on_read.equal?(UNSET)
150
+ group = nil if group.equal?(UNSET)
144
151
  cond_if = nil if cond_if.equal?(UNSET)
145
152
  cond_unless = nil if cond_unless.equal?(UNSET)
146
153
 
@@ -213,6 +220,15 @@ module SafeMemoize
213
220
  __safe_memo_method_namespaces__[method_name] = namespace
214
221
  end
215
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
+
216
232
  if shared_cache
217
233
  raise ArgumentError, "shared_cache: must be a String (got #{shared_cache.class})" unless shared_cache.is_a?(String)
218
234
  raise ArgumentError, "shared_cache: must not be empty" if shared_cache.empty?
@@ -763,6 +779,35 @@ module SafeMemoize
763
779
  end
764
780
  end
765
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
+
766
811
  private
767
812
 
768
813
  def __safe_memo_shared_cache__
@@ -793,6 +838,17 @@ module SafeMemoize
793
838
  @__safe_memoize_defaults__
794
839
  end
795
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
+
796
852
  # Resolves the effective first-element key sym for a given bare method name,
797
853
  # applying the active namespace. Used by class-level cache operations where
798
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.4.0"
5
+ VERSION = "1.5.0"
6
6
  end
data/sig/safe_memoize.rbs CHANGED
@@ -17,6 +17,7 @@ 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
 
21
22
  UNSET: untyped
22
23
  SHARED_CACHE_REGISTRY: Hash[String, Stores::Base]
@@ -65,7 +66,7 @@ module SafeMemoize
65
66
  end
66
67
 
67
68
  module ClassMethods
68
- 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, **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
69
70
  def safe_memoize_store: () -> Stores::Base?
70
71
  def safe_memoize_store=: (Stores::Base?) -> Stores::Base?
71
72
  def safe_memoize_namespace: () -> String?
@@ -78,6 +79,9 @@ module SafeMemoize
78
79
  def shared_memo_count: (?Symbol | String method_name) -> Integer
79
80
  def shared_memo_age: (Symbol | String method_name, *untyped args, **untyped kwargs) -> Float?
80
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]
81
85
 
82
86
  private
83
87
 
@@ -88,6 +92,8 @@ module SafeMemoize
88
92
  def __safe_memo_method_namespaces__: () -> Hash[Symbol, String]
89
93
  def __safe_memo_class_cache_bust_generators__: () -> Hash[Symbol, Proc | Symbol]
90
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
91
97
  def memoized_method_visibility: (Symbol method_name) -> Symbol
92
98
  end
93
99
 
@@ -114,6 +120,9 @@ module SafeMemoize
114
120
  def memo_age: (Symbol | String method_name, *untyped args, **untyped kwargs) -> Float?
115
121
  def memo_stale?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool
116
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]
117
126
  def reset_all_memos: () -> void
118
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? }?
119
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.4.0
4
+ version: 1.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith