safe_memoize 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/FUNDING.yml +15 -0
- data/CHANGELOG.md +7 -0
- data/README.md +86 -0
- data/ROADMAP.md +31 -0
- data/lib/safe_memoize/class_methods.rb +106 -18
- data/lib/safe_memoize/version.rb +1 -1
- data/lib/safe_memoize.rb +3 -0
- data/sig/safe_memoize.rbs +5 -2
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e34f9a8aaa025eca2f817b633409686788e99442f10de2fede62f30443f9a0fc
|
|
4
|
+
data.tar.gz: 21560a08d7060ee77da109da4a91024899674da14ec41e051ff5cb08a5d913f2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '0408a2a2322c7861ec0eab88a62e02a15c59c552301e8fe3b7712691d03f385c81a2efbcf8ef06da57b50ae304edc18a7794001c43093b9fc49c180449a68199'
|
|
7
|
+
data.tar.gz: 421ad6f07416395bef2723fd00a20d7e20c4becd2fec0da9f774ca476d370b1bb459b8d895c2cb7535da9898e27afcfdfcf3d089923ff8aac426dee0c89c5aa8
|
data/.github/FUNDING.yml
ADDED
|
@@ -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,13 @@ from v1.0.0 onwards. Prior 0.x releases may include breaking changes between min
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [1.4.0] - 2026-06-02
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `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.
|
|
16
|
+
- `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`.
|
|
17
|
+
|
|
11
18
|
## [1.3.0] - 2026-05-28
|
|
12
19
|
|
|
13
20
|
### Added
|
data/README.md
CHANGED
|
@@ -77,6 +77,8 @@ 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)
|
|
80
82
|
|
|
81
83
|
## Installation
|
|
82
84
|
|
|
@@ -1372,6 +1374,83 @@ end
|
|
|
1372
1374
|
|
|
1373
1375
|
[↑ Back to features](#features)
|
|
1374
1376
|
|
|
1377
|
+
## Per-class default options (`safe_memoize_options`)
|
|
1378
|
+
|
|
1379
|
+
`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.
|
|
1380
|
+
|
|
1381
|
+
```ruby
|
|
1382
|
+
class ApiClient
|
|
1383
|
+
prepend SafeMemoize
|
|
1384
|
+
safe_memoize_options ttl: 60, max_size: 200, copy_on_read: true
|
|
1385
|
+
|
|
1386
|
+
def fetch(id) = http.get(id)
|
|
1387
|
+
memoize :fetch # uses ttl: 60, max_size: 200, copy_on_read: true
|
|
1388
|
+
|
|
1389
|
+
def list = http.get("/all")
|
|
1390
|
+
memoize :list, ttl: 300 # uses max_size: 200, copy_on_read: true; ttl: 300 overrides
|
|
1391
|
+
end
|
|
1392
|
+
```
|
|
1393
|
+
|
|
1394
|
+
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:
|
|
1395
|
+
|
|
1396
|
+
```ruby
|
|
1397
|
+
safe_memoize_options(
|
|
1398
|
+
ttl: 60,
|
|
1399
|
+
max_size: 100,
|
|
1400
|
+
ttl_refresh: true,
|
|
1401
|
+
copy_on_read: true,
|
|
1402
|
+
namespace: "v2",
|
|
1403
|
+
if: ->(v) { v.present? },
|
|
1404
|
+
cache_bust: :updated_at
|
|
1405
|
+
)
|
|
1406
|
+
```
|
|
1407
|
+
|
|
1408
|
+
Call with no arguments to clear all class-level defaults:
|
|
1409
|
+
|
|
1410
|
+
```ruby
|
|
1411
|
+
MyClass.safe_memoize_options # clears — subsequent memoize calls use global config or per-call options only
|
|
1412
|
+
```
|
|
1413
|
+
|
|
1414
|
+
[↑ Back to features](#features)
|
|
1415
|
+
|
|
1416
|
+
## Copy-on-read
|
|
1417
|
+
|
|
1418
|
+
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:
|
|
1419
|
+
|
|
1420
|
+
```ruby
|
|
1421
|
+
class ConfigService
|
|
1422
|
+
prepend SafeMemoize
|
|
1423
|
+
|
|
1424
|
+
def settings = {host: "localhost", port: 8080}
|
|
1425
|
+
memoize :settings, copy_on_read: true
|
|
1426
|
+
end
|
|
1427
|
+
|
|
1428
|
+
svc = ConfigService.new
|
|
1429
|
+
result = svc.settings
|
|
1430
|
+
result[:host] = "mutated" # only affects the caller's copy
|
|
1431
|
+
|
|
1432
|
+
svc.settings[:host] # => "localhost" — cache is unaffected
|
|
1433
|
+
```
|
|
1434
|
+
|
|
1435
|
+
`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).
|
|
1436
|
+
|
|
1437
|
+
Set it as a class-wide default with `safe_memoize_options`:
|
|
1438
|
+
|
|
1439
|
+
```ruby
|
|
1440
|
+
class ReportService
|
|
1441
|
+
prepend SafeMemoize
|
|
1442
|
+
safe_memoize_options copy_on_read: true
|
|
1443
|
+
|
|
1444
|
+
def summary = build_summary
|
|
1445
|
+
memoize :summary
|
|
1446
|
+
|
|
1447
|
+
def details = build_details
|
|
1448
|
+
memoize :details
|
|
1449
|
+
end
|
|
1450
|
+
```
|
|
1451
|
+
|
|
1452
|
+
[↑ Back to features](#features)
|
|
1453
|
+
|
|
1375
1454
|
## Ractor-safe shared cache
|
|
1376
1455
|
|
|
1377
1456
|
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 +1610,7 @@ Anything **not** listed here — internal modules, private methods, `@__safe_mem
|
|
|
1531
1610
|
| `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
1611
|
| `shared_cache:` | `String \| nil` | `nil` | Name of a globally-registered shared store; incompatible with `shared:`, `store:`, `fiber_local:`, `ractor_safe:`, and `max_size:` |
|
|
1533
1612
|
| `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
|
+
| `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:` |
|
|
1534
1614
|
| *(extension options)* | any | — | Unknown kwargs are validated against registered extensions; raise `ArgumentError` if unclaimed |
|
|
1535
1615
|
|
|
1536
1616
|
### `memoize_all` options (class method)
|
|
@@ -1544,6 +1624,12 @@ All `memoize` option keys above, plus:
|
|
|
1544
1624
|
| `include_protected:` | `Boolean` | `false` |
|
|
1545
1625
|
| `include_private:` | `Boolean` | `false` |
|
|
1546
1626
|
|
|
1627
|
+
### `safe_memoize_options` (class method)
|
|
1628
|
+
|
|
1629
|
+
| Option key | Type | Default | Notes |
|
|
1630
|
+
|---|---|---|---|
|
|
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:` |
|
|
1632
|
+
|
|
1547
1633
|
### Instance methods (public)
|
|
1548
1634
|
|
|
1549
1635
|
**Inspection**
|
data/ROADMAP.md
CHANGED
|
@@ -4,6 +4,37 @@ 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
|
+
## v1.6.0 — Resilience
|
|
18
|
+
|
|
19
|
+
*Goal: make external-store memoization resilient to infrastructure failures.*
|
|
20
|
+
|
|
21
|
+
| Feature | Description | Status |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| 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 |
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## v1.7.0 — Advanced Store Features
|
|
28
|
+
|
|
29
|
+
*Goal: multi-process performance patterns for high-traffic deployments.*
|
|
30
|
+
|
|
31
|
+
| Feature | Description | Status |
|
|
32
|
+
|---|---|---|
|
|
33
|
+
| 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 |
|
|
34
|
+
| 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 |
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
7
38
|
## v2.0.0 — Next Generation (Long Horizon)
|
|
8
39
|
|
|
9
40
|
*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 +
|
|
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,11 @@ 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).
|
|
62
67
|
# @return [void]
|
|
63
68
|
# @raise [ArgumentError] if the method does not exist, or option values are invalid
|
|
64
69
|
#
|
|
@@ -73,10 +78,13 @@ module SafeMemoize
|
|
|
73
78
|
# @example Conditional — only cache successful responses
|
|
74
79
|
# memoize :fetch, if: ->(v) { v[:status] == 200 }
|
|
75
80
|
#
|
|
81
|
+
# @example Copy-on-read — protect mutable cached config
|
|
82
|
+
# memoize :config, copy_on_read: true
|
|
83
|
+
#
|
|
76
84
|
# @example With a custom store
|
|
77
85
|
# STORE = SafeMemoize::Stores::Memory.new
|
|
78
86
|
# memoize :fetch, store: STORE, ttl: 300
|
|
79
|
-
def memoize(method_name, ttl:
|
|
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)
|
|
80
88
|
method_name = method_name.to_sym
|
|
81
89
|
|
|
82
90
|
unless method_defined?(method_name) || private_method_defined?(method_name) || protected_method_defined?(method_name)
|
|
@@ -102,18 +110,46 @@ module SafeMemoize
|
|
|
102
110
|
cache_bust = injected[:cache_bust] if injected.key?(:cache_bust)
|
|
103
111
|
end
|
|
104
112
|
|
|
113
|
+
# :if and :unless are reserved Ruby keywords; use binding to extract them
|
|
114
|
+
cond_if = binding.local_variable_get(:if)
|
|
115
|
+
cond_unless = binding.local_variable_get(:unless)
|
|
116
|
+
|
|
117
|
+
# Apply class-level defaults (safe_memoize_options) for any still-unset options
|
|
118
|
+
if (cls_defaults = __safe_memoize_defaults__)
|
|
119
|
+
ttl = cls_defaults[:ttl] if ttl.equal?(UNSET) && cls_defaults.key?(:ttl)
|
|
120
|
+
max_size = cls_defaults[:max_size] if max_size.equal?(UNSET) && cls_defaults.key?(:max_size)
|
|
121
|
+
ttl_refresh = cls_defaults[:ttl_refresh] if ttl_refresh.equal?(UNSET) && cls_defaults.key?(:ttl_refresh)
|
|
122
|
+
cond_if = cls_defaults[:if] if cond_if.equal?(UNSET) && cls_defaults.key?(:if)
|
|
123
|
+
cond_unless = cls_defaults[:unless] if cond_unless.equal?(UNSET) && cls_defaults.key?(:unless)
|
|
124
|
+
key = cls_defaults[:key] if key.equal?(UNSET) && cls_defaults.key?(:key)
|
|
125
|
+
cache_bust = cls_defaults[:cache_bust] if cache_bust.equal?(UNSET) && cls_defaults.key?(:cache_bust)
|
|
126
|
+
copy_on_read = cls_defaults[:copy_on_read] if copy_on_read.equal?(UNSET) && cls_defaults.key?(:copy_on_read)
|
|
127
|
+
namespace = cls_defaults[:namespace] if namespace.equal?(UNSET) && cls_defaults.key?(:namespace)
|
|
128
|
+
store = cls_defaults[:store] if store.equal?(UNSET) && cls_defaults.key?(:store)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Normalize remaining UNSET to original per-call defaults
|
|
132
|
+
ttl = nil if ttl.equal?(UNSET)
|
|
133
|
+
max_size = nil if max_size.equal?(UNSET)
|
|
134
|
+
ttl_refresh = false if ttl_refresh.equal?(UNSET)
|
|
135
|
+
shared = false if shared.equal?(UNSET)
|
|
136
|
+
key = nil if key.equal?(UNSET)
|
|
137
|
+
store = nil if store.equal?(UNSET)
|
|
138
|
+
fiber_local = false if fiber_local.equal?(UNSET)
|
|
139
|
+
ractor_safe = false if ractor_safe.equal?(UNSET)
|
|
140
|
+
namespace = nil if namespace.equal?(UNSET)
|
|
141
|
+
shared_cache = nil if shared_cache.equal?(UNSET)
|
|
142
|
+
cache_bust = nil if cache_bust.equal?(UNSET)
|
|
143
|
+
copy_on_read = false if copy_on_read.equal?(UNSET)
|
|
144
|
+
cond_if = nil if cond_if.equal?(UNSET)
|
|
145
|
+
cond_unless = nil if cond_unless.equal?(UNSET)
|
|
146
|
+
|
|
105
147
|
visibility = memoized_method_visibility(method_name)
|
|
106
148
|
|
|
107
149
|
config = SafeMemoize.configuration
|
|
108
150
|
ttl = config.default_ttl if ttl.nil?
|
|
109
151
|
max_size = config.default_max_size if max_size.nil?
|
|
110
152
|
|
|
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
153
|
ttl = if ttl.nil?
|
|
118
154
|
nil
|
|
119
155
|
else
|
|
@@ -167,6 +203,7 @@ module SafeMemoize
|
|
|
167
203
|
raise ArgumentError, "ractor_safe: is incompatible with ttl_refresh:" if ttl_refresh
|
|
168
204
|
raise ArgumentError, "ractor_safe: is incompatible with key:" if key
|
|
169
205
|
raise ArgumentError, "ractor_safe: is incompatible with store:" if store
|
|
206
|
+
raise ArgumentError, "ractor_safe: is incompatible with copy_on_read:" if copy_on_read
|
|
170
207
|
end
|
|
171
208
|
|
|
172
209
|
if namespace
|
|
@@ -217,6 +254,18 @@ module SafeMemoize
|
|
|
217
254
|
->(result) { !cond_unless.call(result) }
|
|
218
255
|
end
|
|
219
256
|
|
|
257
|
+
# Build a value-duplication function for copy_on_read: true.
|
|
258
|
+
# Frozen and nil values are returned as-is; deep_dup is preferred when available
|
|
259
|
+
# (e.g. ActiveRecord objects) so nested mutable structures are also protected.
|
|
260
|
+
dup_fn = if copy_on_read
|
|
261
|
+
lambda do |v|
|
|
262
|
+
return v if v.nil? || v.frozen?
|
|
263
|
+
v.respond_to?(:deep_dup) ? v.deep_dup : v.dup
|
|
264
|
+
end
|
|
265
|
+
else
|
|
266
|
+
->(v) { v }
|
|
267
|
+
end
|
|
268
|
+
|
|
220
269
|
if effective_store
|
|
221
270
|
miss = SafeMemoize::Stores::Base::MISS
|
|
222
271
|
|
|
@@ -231,7 +280,7 @@ module SafeMemoize
|
|
|
231
280
|
effective_store.write(cache_key, cached, expires_in: ttl) if ttl_refresh
|
|
232
281
|
record_cache_hit(cache_key)
|
|
233
282
|
call_memo_hooks(:on_hit, cache_key, {value: cached, expires_at: nil, cached_at: nil})
|
|
234
|
-
return cached
|
|
283
|
+
return dup_fn.call(cached)
|
|
235
284
|
end
|
|
236
285
|
|
|
237
286
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
@@ -249,7 +298,7 @@ module SafeMemoize
|
|
|
249
298
|
record_cache_miss(cache_key, elapsed_time)
|
|
250
299
|
call_memo_hooks(:on_miss, cache_key, {value: value, expires_at: nil, cached_at: now})
|
|
251
300
|
|
|
252
|
-
value
|
|
301
|
+
dup_fn.call(value)
|
|
253
302
|
end
|
|
254
303
|
|
|
255
304
|
send(visibility, method_name)
|
|
@@ -278,7 +327,7 @@ module SafeMemoize
|
|
|
278
327
|
record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
|
|
279
328
|
record_cache_hit(cache_key)
|
|
280
329
|
call_memo_hooks(:on_hit, cache_key, record)
|
|
281
|
-
memo_record_value(record)
|
|
330
|
+
dup_fn.call(memo_record_value(record))
|
|
282
331
|
else
|
|
283
332
|
call_memo_hooks(:on_expire, cache_key, record) if record
|
|
284
333
|
|
|
@@ -312,7 +361,7 @@ module SafeMemoize
|
|
|
312
361
|
record_cache_miss(cache_key, elapsed_time)
|
|
313
362
|
call_memo_hooks(:on_miss, cache_key, new_record)
|
|
314
363
|
|
|
315
|
-
value
|
|
364
|
+
dup_fn.call(value)
|
|
316
365
|
end
|
|
317
366
|
end
|
|
318
367
|
|
|
@@ -356,7 +405,7 @@ module SafeMemoize
|
|
|
356
405
|
record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
|
|
357
406
|
record_cache_hit(cache_key)
|
|
358
407
|
call_memo_hooks(:on_hit, cache_key, record)
|
|
359
|
-
record[:value]
|
|
408
|
+
dup_fn.call(record[:value])
|
|
360
409
|
else
|
|
361
410
|
call_memo_hooks(:on_expire, cache_key, record) if record && !record_live
|
|
362
411
|
|
|
@@ -388,7 +437,7 @@ module SafeMemoize
|
|
|
388
437
|
record_cache_miss(cache_key, elapsed_time)
|
|
389
438
|
call_memo_hooks(:on_miss, cache_key, new_record)
|
|
390
439
|
|
|
391
|
-
value
|
|
440
|
+
dup_fn.call(value)
|
|
392
441
|
end
|
|
393
442
|
end
|
|
394
443
|
end
|
|
@@ -417,7 +466,7 @@ module SafeMemoize
|
|
|
417
466
|
record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
|
|
418
467
|
record_cache_hit(cache_key)
|
|
419
468
|
call_memo_hooks(:on_hit, cache_key, record)
|
|
420
|
-
memo_record_value(record)
|
|
469
|
+
dup_fn.call(memo_record_value(record))
|
|
421
470
|
else
|
|
422
471
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
423
472
|
value = Adapters::OpenTelemetry.trace(SafeMemoize.configuration.opentelemetry_tracer, method_name, self.class.name) { super(*args, **kwargs) }
|
|
@@ -434,7 +483,7 @@ module SafeMemoize
|
|
|
434
483
|
record_cache_miss(cache_key, elapsed_time)
|
|
435
484
|
call_memo_hooks(:on_miss, cache_key, new_record)
|
|
436
485
|
|
|
437
|
-
value
|
|
486
|
+
dup_fn.call(value)
|
|
438
487
|
end
|
|
439
488
|
end
|
|
440
489
|
else
|
|
@@ -442,7 +491,7 @@ module SafeMemoize
|
|
|
442
491
|
if (record = memo_cache_record(cache_key))
|
|
443
492
|
record_cache_hit(cache_key)
|
|
444
493
|
call_memo_hooks(:on_hit, cache_key, record)
|
|
445
|
-
return memo_record_value(record)
|
|
494
|
+
return dup_fn.call(memo_record_value(record))
|
|
446
495
|
end
|
|
447
496
|
|
|
448
497
|
# Cache miss - compute and store
|
|
@@ -459,7 +508,7 @@ module SafeMemoize
|
|
|
459
508
|
call_memo_hooks(:on_miss, cache_key, new_record)
|
|
460
509
|
end
|
|
461
510
|
|
|
462
|
-
result
|
|
511
|
+
dup_fn.call(result)
|
|
463
512
|
end
|
|
464
513
|
end
|
|
465
514
|
|
|
@@ -520,6 +569,41 @@ module SafeMemoize
|
|
|
520
569
|
@__safe_memoize_namespace__ = ns
|
|
521
570
|
end
|
|
522
571
|
|
|
572
|
+
# Sets class-wide default options applied to every subsequent {#memoize} call
|
|
573
|
+
# on this class. Per-call options take precedence; class defaults take
|
|
574
|
+
# precedence over global {SafeMemoize::Configuration} defaults.
|
|
575
|
+
#
|
|
576
|
+
# Call with no arguments (or an empty hash) to clear all class-level defaults.
|
|
577
|
+
#
|
|
578
|
+
# @example Apply a TTL and LRU cap to every memoized method on the class
|
|
579
|
+
# class ApiClient
|
|
580
|
+
# prepend SafeMemoize
|
|
581
|
+
# safe_memoize_options ttl: 60, max_size: 200
|
|
582
|
+
#
|
|
583
|
+
# def fetch(id) = http.get(id)
|
|
584
|
+
# memoize :fetch # uses ttl: 60, max_size: 200
|
|
585
|
+
#
|
|
586
|
+
# def list = http.get("/all")
|
|
587
|
+
# memoize :list, ttl: 300 # uses max_size: 200, ttl: 300
|
|
588
|
+
# end
|
|
589
|
+
#
|
|
590
|
+
# @example Protect all cached values from mutation
|
|
591
|
+
# safe_memoize_options copy_on_read: true
|
|
592
|
+
#
|
|
593
|
+
# @param opts [Hash] any subset of {#memoize} options except mode-switch options
|
|
594
|
+
# (+shared:+, +fiber_local:+, +ractor_safe:+, +shared_cache:+)
|
|
595
|
+
# @return [Hash] the stored defaults
|
|
596
|
+
# @raise [ArgumentError] for disallowed options
|
|
597
|
+
def safe_memoize_options(**opts)
|
|
598
|
+
disallowed = %i[shared fiber_local ractor_safe shared_cache]
|
|
599
|
+
bad = opts.keys & disallowed
|
|
600
|
+
unless bad.empty?
|
|
601
|
+
raise ArgumentError,
|
|
602
|
+
"safe_memoize_options does not accept #{bad.map { |k| ":#{k}" }.join(", ")} — pass mode-switch options per memoize call"
|
|
603
|
+
end
|
|
604
|
+
@__safe_memoize_defaults__ = opts.empty? ? nil : opts
|
|
605
|
+
end
|
|
606
|
+
|
|
523
607
|
# Memoizes every eligible public instance method defined directly on the class.
|
|
524
608
|
#
|
|
525
609
|
# Accepts all options that {#memoize} accepts, plus +:except:+ and +:only:+.
|
|
@@ -705,6 +789,10 @@ module SafeMemoize
|
|
|
705
789
|
@__safe_memo_class_cache_bust_generators__ ||= {}
|
|
706
790
|
end
|
|
707
791
|
|
|
792
|
+
def __safe_memoize_defaults__
|
|
793
|
+
@__safe_memoize_defaults__
|
|
794
|
+
end
|
|
795
|
+
|
|
708
796
|
# Resolves the effective first-element key sym for a given bare method name,
|
|
709
797
|
# applying the active namespace. Used by class-level cache operations where
|
|
710
798
|
# instance methods (compute_cache_key) are unavailable.
|
data/lib/safe_memoize/version.rb
CHANGED
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
|
@@ -18,6 +18,7 @@ module SafeMemoize
|
|
|
18
18
|
@__safe_memo_shared_mutex__: Mutex?
|
|
19
19
|
@__safe_memo_shared_lru_order__: Hash[Symbol, Hash[memo_key, true]]?
|
|
20
20
|
|
|
21
|
+
UNSET: untyped
|
|
21
22
|
SHARED_CACHE_REGISTRY: Hash[String, Stores::Base]
|
|
22
23
|
SHARED_CACHE_MUTEX: Mutex
|
|
23
24
|
EXTENSION_REGISTRY: Hash[Symbol, untyped]
|
|
@@ -64,12 +65,13 @@ module SafeMemoize
|
|
|
64
65
|
end
|
|
65
66
|
|
|
66
67
|
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
|
|
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
|
|
68
69
|
def safe_memoize_store: () -> Stores::Base?
|
|
69
70
|
def safe_memoize_store=: (Stores::Base?) -> Stores::Base?
|
|
70
71
|
def safe_memoize_namespace: () -> String?
|
|
71
72
|
def safe_memoize_namespace=: (String?) -> String?
|
|
72
|
-
def
|
|
73
|
+
def safe_memoize_options: (**untyped opts) -> Hash[Symbol, untyped]?
|
|
74
|
+
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
75
|
def reset_shared_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
|
|
74
76
|
def reset_all_shared_memos: () -> void
|
|
75
77
|
def shared_memoized?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool
|
|
@@ -85,6 +87,7 @@ module SafeMemoize
|
|
|
85
87
|
def __safe_memo_class_key_generators__: () -> Hash[Symbol, Proc]
|
|
86
88
|
def __safe_memo_method_namespaces__: () -> Hash[Symbol, String]
|
|
87
89
|
def __safe_memo_class_cache_bust_generators__: () -> Hash[Symbol, Proc | Symbol]
|
|
90
|
+
def __safe_memoize_defaults__: () -> Hash[Symbol, untyped]?
|
|
88
91
|
def memoized_method_visibility: (Symbol method_name) -> Symbol
|
|
89
92
|
end
|
|
90
93
|
|
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
|
+
version: 1.4.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"
|