safe_memoize 1.6.0 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +31 -0
- data/CHANGELOG.md +28 -1
- data/README.md +185 -91
- data/ROADMAP.md +0 -11
- data/benchmarks/BENCHMARK.md +114 -0
- data/benchmarks/README.md +3 -1
- data/lib/safe_memoize/cache_record_methods.rb +4 -2
- data/lib/safe_memoize/class_methods.rb +65 -15
- data/lib/safe_memoize/ractor_shared_methods.rb +59 -8
- data/lib/safe_memoize/release_tooling.rb +19 -1
- data/lib/safe_memoize/stores/multilevel.rb +86 -0
- data/lib/safe_memoize/stores/xfetch.rb +129 -0
- data/lib/safe_memoize/version.rb +1 -1
- data/lib/safe_memoize.rb +2 -0
- data/sig/safe_memoize.rbs +42 -2
- metadata +5 -16
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# SafeMemoize Benchmark Results
|
|
2
|
+
|
|
3
|
+
Run with `bundle exec ruby benchmarks/benchmark.rb`.
|
|
4
|
+
|
|
5
|
+
**Environment:** Ruby 3.4.8 (arm64-darwin25, PRISM) · `benchmark-ips` 5 s measurement / 2 s warmup
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## What is being measured
|
|
10
|
+
|
|
11
|
+
SafeMemoize is compared against two vanilla Ruby baselines:
|
|
12
|
+
|
|
13
|
+
| Label | Pattern | Correctness |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| `raw \|\|= (unsafe)` | `@v \|\|= compute` | Broken — re-runs if result is `nil` or `false` |
|
|
16
|
+
| `raw safe ivar` | `return @v if defined?(@v); @v = compute` | Correct |
|
|
17
|
+
| `safe_memoize` | `prepend SafeMemoize` + `memoize :method` | Correct |
|
|
18
|
+
|
|
19
|
+
The raw patterns are intentionally simple and carry none of SafeMemoize's feature overhead (thread safety, argument keying, TTL, hooks, metrics). The numbers below quantify exactly what that feature set costs.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Results
|
|
24
|
+
|
|
25
|
+
### 1. Zero-arg cache HIT — steady-state throughput
|
|
26
|
+
|
|
27
|
+
A primed object called repeatedly. This is the hot path.
|
|
28
|
+
|
|
29
|
+
| Implementation | i/s | ns/call | vs fastest |
|
|
30
|
+
|---|---|---|---|
|
|
31
|
+
| `raw safe ivar` | 21.51 M | 46.5 | — |
|
|
32
|
+
| `raw \|\|= (unsafe)` | 16.69 M | 59.9 | 1.29× slower |
|
|
33
|
+
| `safe_memoize` | 296.8 K | 3,370 | **72×** slower |
|
|
34
|
+
|
|
35
|
+
**Takeaway:** SafeMemoize's hit path costs ~3.4 µs per call — the price of a hash lookup, mutex check, and key construction. For methods that are called millions of times per second in a tight loop, raw ivars win. For methods that do real work (DB queries, HTTP, serialization), this overhead is negligible.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
### 2. Zero-arg cache MISS — first-call overhead
|
|
40
|
+
|
|
41
|
+
A fresh instance is created each iteration so every call is a miss.
|
|
42
|
+
|
|
43
|
+
| Implementation | i/s | µs/call | vs fastest |
|
|
44
|
+
|---|---|---|---|
|
|
45
|
+
| `raw safe ivar` | 8.47 M | 0.12 | — |
|
|
46
|
+
| `safe_memoize` | 189.6 K | 5.28 | **45×** slower |
|
|
47
|
+
|
|
48
|
+
**Takeaway:** The miss path allocates the cache hash, builds the key, and acquires the mutex — ~5 µs. Still fast in absolute terms; any method body doing more than a trivial computation will dominate.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
### 3. With-argument cache HIT — keyed lookup
|
|
53
|
+
|
|
54
|
+
A primed object called with the same argument each iteration.
|
|
55
|
+
|
|
56
|
+
| Implementation | i/s | ns/call | vs fastest |
|
|
57
|
+
|---|---|---|---|
|
|
58
|
+
| `raw safe ivar` | 14.17 M | 70.6 | — |
|
|
59
|
+
| `safe_memoize` | 289.2 K | 3,460 | **49×** slower |
|
|
60
|
+
|
|
61
|
+
**Takeaway:** The argument hash lookup adds roughly the same overhead as the zero-arg case. Key construction (deep-freeze copy of args) dominates, not the hash lookup itself.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
### 4. Fast path vs locked path (internal)
|
|
66
|
+
|
|
67
|
+
Both are `safe_memoize`. Adding `max_size:` enables LRU tracking, which holds the mutex for the full read/write cycle.
|
|
68
|
+
|
|
69
|
+
| Configuration | i/s | µs/call | vs fast path |
|
|
70
|
+
|---|---|---|---|
|
|
71
|
+
| No `max_size:` (fast path) | 287.7 K | 3.48 | — |
|
|
72
|
+
| `max_size: 100` (locked path) | 218.0 K | 4.59 | 1.32× slower |
|
|
73
|
+
|
|
74
|
+
**Takeaway:** LRU costs an extra ~1.1 µs per hit. Use it when eviction matters; skip it for unbounded caches.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
### 5. Shared cache vs instance cache (internal)
|
|
79
|
+
|
|
80
|
+
Both are `safe_memoize`. `shared: true` stores results on the class rather than the instance.
|
|
81
|
+
|
|
82
|
+
| Configuration | i/s | µs/call | difference |
|
|
83
|
+
|---|---|---|---|
|
|
84
|
+
| Instance cache | 292.5 K | 3.42 | — |
|
|
85
|
+
| Shared cache | 275.3 K | 3.63 | within margin of error |
|
|
86
|
+
|
|
87
|
+
**Takeaway:** Class-level shared storage is essentially free compared to per-instance caching. The mutex contention at the class level does not measurably affect single-threaded throughput.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
### 6. Concurrent throughput — 8 threads × 50,000 iterations
|
|
92
|
+
|
|
93
|
+
| Implementation | Total time | i/s |
|
|
94
|
+
|---|---|---|
|
|
95
|
+
| `raw safe ivar` | 0.033 s | 12.01 M |
|
|
96
|
+
| `safe_memoize` (instance cache) | 1.363 s | 293.4 K |
|
|
97
|
+
| `safe_memoize` (shared cache) | 1.393 s | 287.1 K |
|
|
98
|
+
|
|
99
|
+
**Takeaway:** Under real thread contention the raw ivar baseline has no locking at all — it is technically unsafe here. SafeMemoize's per-instance mutex serialises writes correctly at ~293 K i/s; the class-level shared mutex performs identically at ~287 K i/s.
|
|
100
|
+
|
|
101
|
+
---
|
|
102
|
+
|
|
103
|
+
## Summary
|
|
104
|
+
|
|
105
|
+
| Scenario | SafeMemoize overhead |
|
|
106
|
+
|---|---|
|
|
107
|
+
| Hot cache hit (zero-arg) | ~3.4 µs — 72× vs raw ivar |
|
|
108
|
+
| Hot cache hit (with args) | ~3.5 µs — 49× vs raw ivar |
|
|
109
|
+
| First call / cache miss | ~5.3 µs — 45× vs raw ivar |
|
|
110
|
+
| LRU enabled (`max_size:`) | +1.1 µs vs fast path |
|
|
111
|
+
| Shared vs instance cache | no measurable difference |
|
|
112
|
+
| Concurrent throughput | ~293 K i/s (thread-safe) |
|
|
113
|
+
|
|
114
|
+
SafeMemoize is slower than a raw instance variable in absolute terms, but the overhead is measured in **microseconds**, not milliseconds. Any method that does I/O, calls a database, hits an external API, or performs non-trivial computation will completely dominate the memoization overhead — and that is precisely the use case SafeMemoize is designed for.
|
data/benchmarks/README.md
CHANGED
|
@@ -65,4 +65,6 @@ bundle exec ruby benchmarks/benchmark.rb
|
|
|
65
65
|
safe_memoize (shared) ~323 K i/s
|
|
66
66
|
```
|
|
67
67
|
|
|
68
|
-
Results vary by hardware, Ruby version, and GVL scheduling. Run on your own hardware for authoritative numbers.
|
|
68
|
+
Results vary by hardware, Ruby version, and GVL scheduling. Run on your own hardware for authoritative numbers.
|
|
69
|
+
|
|
70
|
+
For a full recorded run with analysis, see [BENCHMARK.md](BENCHMARK.md).
|
|
@@ -22,8 +22,10 @@ module SafeMemoize
|
|
|
22
22
|
Process.clock_gettime(Process::CLOCK_MONOTONIC) + ttl
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
def memo_record(value, expires_at:)
|
|
26
|
-
{value: value, expires_at: expires_at, cached_at: Process.clock_gettime(Process::CLOCK_MONOTONIC)}
|
|
25
|
+
def memo_record(value, expires_at:, delta: nil)
|
|
26
|
+
rec = {value: value, expires_at: expires_at, cached_at: Process.clock_gettime(Process::CLOCK_MONOTONIC)}
|
|
27
|
+
rec[:delta] = delta if delta
|
|
28
|
+
rec
|
|
27
29
|
end
|
|
28
30
|
|
|
29
31
|
def memo_record_value(record)
|
|
@@ -74,6 +74,12 @@ module SafeMemoize
|
|
|
74
74
|
# +probe_interval: 30+), or a +Hash+ with +:error_threshold+ and/or +:probe_interval+
|
|
75
75
|
# keys to customise. Requires a +store:+ to be set (per-method, class-level, or
|
|
76
76
|
# global default); raises +ArgumentError+ otherwise.
|
|
77
|
+
# @param stampede_protection [Boolean, Numeric, nil] enables probabilistic early
|
|
78
|
+
# expiry (XFetch algorithm) on the per-instance in-process cache. Pass +true+ to
|
|
79
|
+
# use the default beta scalar (1.0), or a +Numeric+ to set a custom beta. A higher
|
|
80
|
+
# beta causes early recomputation to trigger more aggressively. Requires +ttl:+ to
|
|
81
|
+
# be set. Incompatible with +store:+ (use {Stores::XFetch} instead) and
|
|
82
|
+
# +ractor_safe:+. For the store-backed path, wrap the store with {Stores::XFetch}.
|
|
77
83
|
# @return [void]
|
|
78
84
|
# @raise [ArgumentError] if the method does not exist, or option values are invalid
|
|
79
85
|
#
|
|
@@ -94,7 +100,7 @@ module SafeMemoize
|
|
|
94
100
|
# @example With a custom store
|
|
95
101
|
# STORE = SafeMemoize::Stores::Memory.new
|
|
96
102
|
# memoize :fetch, store: STORE, ttl: 300
|
|
97
|
-
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, circuit_breaker: UNSET, **extension_options)
|
|
103
|
+
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, circuit_breaker: UNSET, stampede_protection: UNSET, **extension_options)
|
|
98
104
|
method_name = method_name.to_sym
|
|
99
105
|
|
|
100
106
|
unless method_defined?(method_name) || private_method_defined?(method_name) || protected_method_defined?(method_name)
|
|
@@ -138,6 +144,7 @@ module SafeMemoize
|
|
|
138
144
|
store = cls_defaults[:store] if store.equal?(UNSET) && cls_defaults.key?(:store)
|
|
139
145
|
group = cls_defaults[:group] if group.equal?(UNSET) && cls_defaults.key?(:group)
|
|
140
146
|
circuit_breaker = cls_defaults[:circuit_breaker] if circuit_breaker.equal?(UNSET) && cls_defaults.key?(:circuit_breaker)
|
|
147
|
+
stampede_protection = cls_defaults[:stampede_protection] if stampede_protection.equal?(UNSET) && cls_defaults.key?(:stampede_protection)
|
|
141
148
|
end
|
|
142
149
|
|
|
143
150
|
# Normalize remaining UNSET to original per-call defaults
|
|
@@ -155,6 +162,7 @@ module SafeMemoize
|
|
|
155
162
|
copy_on_read = false if copy_on_read.equal?(UNSET)
|
|
156
163
|
group = nil if group.equal?(UNSET)
|
|
157
164
|
circuit_breaker = nil if circuit_breaker.equal?(UNSET)
|
|
165
|
+
stampede_protection = nil if stampede_protection.equal?(UNSET)
|
|
158
166
|
cond_if = nil if cond_if.equal?(UNSET)
|
|
159
167
|
cond_unless = nil if cond_unless.equal?(UNSET)
|
|
160
168
|
|
|
@@ -199,6 +207,24 @@ module SafeMemoize
|
|
|
199
207
|
raise ArgumentError, "cache_bust: and key: cannot be combined" if key
|
|
200
208
|
end
|
|
201
209
|
|
|
210
|
+
if store.is_a?(Array)
|
|
211
|
+
store.each_with_index do |s, i|
|
|
212
|
+
unless s.is_a?(SafeMemoize::Stores::Base)
|
|
213
|
+
raise ArgumentError, "store: Array entry [#{i}] must be a Stores::Base instance (got #{s.class})"
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
store = SafeMemoize::Stores::Multilevel.new(*store)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
if stampede_protection
|
|
220
|
+
unless stampede_protection == true || stampede_protection.is_a?(Numeric)
|
|
221
|
+
raise ArgumentError, "stampede_protection: must be true or a Numeric beta value (got #{stampede_protection.class})"
|
|
222
|
+
end
|
|
223
|
+
raise ArgumentError, "stampede_protection: requires ttl: to be set" if ttl.nil?
|
|
224
|
+
raise ArgumentError, "stampede_protection: is incompatible with store: — use Stores::XFetch instead" if store
|
|
225
|
+
raise ArgumentError, "stampede_protection: is incompatible with ractor_safe:" if ractor_safe
|
|
226
|
+
end
|
|
227
|
+
|
|
202
228
|
if store
|
|
203
229
|
raise ArgumentError, "store: must be a SafeMemoize::Stores::Base instance (got #{store.class})" unless store.is_a?(SafeMemoize::Stores::Base)
|
|
204
230
|
raise ArgumentError, "max_size: is not supported with store: — use the store adapter's own eviction" if max_size
|
|
@@ -354,7 +380,15 @@ module SafeMemoize
|
|
|
354
380
|
fiber_cache = fiber_memo_cache!
|
|
355
381
|
record = fiber_cache[cache_key]
|
|
356
382
|
|
|
357
|
-
|
|
383
|
+
sp_early = stampede_protection && memo_record_live?(record) && record[:expires_at] &&
|
|
384
|
+
(record[:delta].to_f > 0) &&
|
|
385
|
+
begin
|
|
386
|
+
sp_beta = stampede_protection.is_a?(Numeric) ? stampede_protection.to_f : 1.0
|
|
387
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
388
|
+
now - record[:delta].to_f * sp_beta * Math.log(rand) >= record[:expires_at]
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
if memo_record_live?(record) && !sp_early
|
|
358
392
|
if max_size
|
|
359
393
|
lru = fiber_memo_lru![method_name] ||= {}
|
|
360
394
|
lru.delete(cache_key)
|
|
@@ -365,7 +399,7 @@ module SafeMemoize
|
|
|
365
399
|
call_memo_hooks(:on_hit, cache_key, record)
|
|
366
400
|
dup_fn.call(memo_record_value(record))
|
|
367
401
|
else
|
|
368
|
-
call_memo_hooks(:on_expire, cache_key, record) if record
|
|
402
|
+
call_memo_hooks(:on_expire, cache_key, record) if record && (!memo_record_live?(record) || sp_early)
|
|
369
403
|
|
|
370
404
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
371
405
|
value = Adapters::OpenTelemetry.trace(
|
|
@@ -373,7 +407,7 @@ module SafeMemoize
|
|
|
373
407
|
) { super(*args, **kwargs) }
|
|
374
408
|
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
375
409
|
|
|
376
|
-
new_record = memo_record(value, expires_at: memo_expires_at(ttl))
|
|
410
|
+
new_record = memo_record(value, expires_at: memo_expires_at(ttl), delta: stampede_protection ? elapsed_time : nil)
|
|
377
411
|
|
|
378
412
|
if !condition || condition.call(value)
|
|
379
413
|
if max_size
|
|
@@ -432,7 +466,14 @@ module SafeMemoize
|
|
|
432
466
|
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
433
467
|
record_live = record && (record[:expires_at].nil? || record[:expires_at] > now)
|
|
434
468
|
|
|
435
|
-
|
|
469
|
+
sp_early = record_live && stampede_protection && record[:expires_at] &&
|
|
470
|
+
(record[:delta].to_f > 0) &&
|
|
471
|
+
begin
|
|
472
|
+
sp_beta = stampede_protection.is_a?(Numeric) ? stampede_protection.to_f : 1.0
|
|
473
|
+
now - record[:delta].to_f * sp_beta * Math.log(rand) >= record[:expires_at]
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
if record_live && !sp_early
|
|
436
477
|
if max_size
|
|
437
478
|
lru = klass.send(:__safe_memo_shared_lru_order__)[method_name] ||= {}
|
|
438
479
|
lru.delete(cache_key)
|
|
@@ -443,13 +484,13 @@ module SafeMemoize
|
|
|
443
484
|
call_memo_hooks(:on_hit, cache_key, record)
|
|
444
485
|
dup_fn.call(record[:value])
|
|
445
486
|
else
|
|
446
|
-
call_memo_hooks(:on_expire, cache_key, record) if record && !record_live
|
|
487
|
+
call_memo_hooks(:on_expire, cache_key, record) if record && (!record_live || sp_early)
|
|
447
488
|
|
|
448
489
|
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
449
490
|
value = Adapters::OpenTelemetry.trace(SafeMemoize.configuration.opentelemetry_tracer, method_name, klass.name) { super(*args, **kwargs) }
|
|
450
491
|
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
451
492
|
|
|
452
|
-
new_record = memo_record(value, expires_at: memo_expires_at(ttl))
|
|
493
|
+
new_record = memo_record(value, expires_at: memo_expires_at(ttl), delta: stampede_protection ? elapsed_time : nil)
|
|
453
494
|
|
|
454
495
|
if !condition || condition.call(value)
|
|
455
496
|
if max_size
|
|
@@ -493,10 +534,22 @@ module SafeMemoize
|
|
|
493
534
|
|
|
494
535
|
cache_key = compute_cache_key(method_name, args, kwargs)
|
|
495
536
|
|
|
496
|
-
if max_size || condition || ttl_refresh
|
|
497
|
-
# Locked path:
|
|
537
|
+
if max_size || condition || ttl_refresh || stampede_protection
|
|
538
|
+
# Locked path: LRU, conditional storage, TTL refresh, or stampede protection.
|
|
539
|
+
sp_beta = stampede_protection.is_a?(Numeric) ? stampede_protection.to_f : 1.0
|
|
540
|
+
|
|
498
541
|
memo_mutex!.synchronize do
|
|
499
542
|
record = memo_cache_record(cache_key)
|
|
543
|
+
|
|
544
|
+
if record && stampede_protection && record[:expires_at]
|
|
545
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
546
|
+
delta = record[:delta].to_f
|
|
547
|
+
if delta > 0 && now - delta * sp_beta * Math.log(rand) >= record[:expires_at]
|
|
548
|
+
call_memo_hooks(:on_expire, cache_key, record)
|
|
549
|
+
record = nil
|
|
550
|
+
end
|
|
551
|
+
end
|
|
552
|
+
|
|
500
553
|
if record
|
|
501
554
|
lru_touch(method_name, cache_key) if max_size
|
|
502
555
|
record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
|
|
@@ -508,7 +561,7 @@ module SafeMemoize
|
|
|
508
561
|
value = Adapters::OpenTelemetry.trace(SafeMemoize.configuration.opentelemetry_tracer, method_name, self.class.name) { super(*args, **kwargs) }
|
|
509
562
|
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
510
563
|
|
|
511
|
-
new_record = memo_record(value, expires_at: memo_expires_at(ttl))
|
|
564
|
+
new_record = memo_record(value, expires_at: memo_expires_at(ttl), delta: stampede_protection ? elapsed_time : nil)
|
|
512
565
|
if !condition || condition.call(value)
|
|
513
566
|
lru_evict_if_over_limit(method_name, max_size) if max_size
|
|
514
567
|
@__safe_memo_cache__ ||= {}
|
|
@@ -905,9 +958,7 @@ module SafeMemoize
|
|
|
905
958
|
|
|
906
959
|
cache_key = Ractor.make_shareable([method_name, deep_freeze_copy(args), deep_freeze_copy(kwargs)])
|
|
907
960
|
|
|
908
|
-
|
|
909
|
-
supervisor.send(Ractor.make_shareable([Ractor.current, tag, :fetch, cache_key]))
|
|
910
|
-
response = Ractor.receive_if { |m| m.is_a?(Array) && m[0] == tag }[1]
|
|
961
|
+
response = RactorSharedMethods.round_trip(supervisor, :fetch, cache_key)
|
|
911
962
|
|
|
912
963
|
if response[:hit]
|
|
913
964
|
record_cache_hit(cache_key)
|
|
@@ -932,8 +983,7 @@ module SafeMemoize
|
|
|
932
983
|
cached_at: now
|
|
933
984
|
})
|
|
934
985
|
|
|
935
|
-
|
|
936
|
-
stored = Ractor.receive_if { |m| m.is_a?(Array) && m[0] == tag }[1]
|
|
986
|
+
stored = RactorSharedMethods.round_trip(supervisor, :store, cache_key, record)
|
|
937
987
|
stored_record = stored[:stored]
|
|
938
988
|
|
|
939
989
|
record_cache_miss(cache_key, elapsed_time)
|
|
@@ -15,9 +15,65 @@ module SafeMemoize
|
|
|
15
15
|
# - +if:+, +unless:+, +max_size:+, +ttl_refresh:+, +key:+, and +store:+ are
|
|
16
16
|
# incompatible and raise +ArgumentError+ at +memoize+ time.
|
|
17
17
|
# - When calling a ractor-safe memoized method from the main Ractor with multiple
|
|
18
|
-
# threads, responses are matched by thread identity
|
|
19
|
-
# consume each other's
|
|
18
|
+
# threads, responses are matched by thread identity and each send/receive pair
|
|
19
|
+
# is serialized per Ractor, so concurrent callers do not consume each other's
|
|
20
|
+
# replies or lose a wakeup.
|
|
20
21
|
module RactorSharedMethods
|
|
22
|
+
MUTEX_KEY = :__safe_memo_ractor_mutex__
|
|
23
|
+
|
|
24
|
+
# Ruby 4.0 replaced +Ractor.receive_if+ with +Ractor::Port+.
|
|
25
|
+
PORTS = defined?(Ractor::Port) ? true : false
|
|
26
|
+
|
|
27
|
+
# Sends a request to +supervisor+ and blocks until its reply arrives,
|
|
28
|
+
# returning the reply payload.
|
|
29
|
+
def self.round_trip(supervisor, op, *args)
|
|
30
|
+
# :nocov:
|
|
31
|
+
return round_trip_via_port(supervisor, op, *args) if PORTS
|
|
32
|
+
# :nocov:
|
|
33
|
+
|
|
34
|
+
round_trip_via_queue(supervisor, op, *args)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Ruby 4.0+: each call gets its own port, so callers never share a queue.
|
|
38
|
+
# :nocov:
|
|
39
|
+
def self.round_trip_via_port(supervisor, op, *args)
|
|
40
|
+
port = Ractor::Port.new
|
|
41
|
+
supervisor.send(Ractor.make_shareable([port, Thread.current.object_id, op, *args]))
|
|
42
|
+
port.receive[1]
|
|
43
|
+
ensure
|
|
44
|
+
port&.close
|
|
45
|
+
end
|
|
46
|
+
# :nocov:
|
|
47
|
+
|
|
48
|
+
# Ruby 3.x: replies are matched by thread identity, but every thread in a
|
|
49
|
+
# Ractor waits on that Ractor's single incoming queue. Ruby 3.x can lose a
|
|
50
|
+
# wakeup when several threads sit in +Ractor.receive_if+ at once, leaving one
|
|
51
|
+
# waiting forever, so the send/receive pair is serialized per Ractor. The
|
|
52
|
+
# supervisor handles requests one at a time anyway, so this costs no
|
|
53
|
+
# throughput.
|
|
54
|
+
def self.round_trip_via_queue(supervisor, op, *args)
|
|
55
|
+
tag = Thread.current.object_id
|
|
56
|
+
msg = Ractor.make_shareable([Ractor.current, tag, op, *args])
|
|
57
|
+
|
|
58
|
+
ractor_mutex.synchronize do
|
|
59
|
+
supervisor.send(msg)
|
|
60
|
+
Ractor.receive_if { |m| m.is_a?(Array) && m[0] == tag }[1]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Per-Ractor mutex held in Ractor-local storage (a plain +Mutex+ constant is
|
|
65
|
+
# not Ractor-shareable).
|
|
66
|
+
def self.ractor_mutex
|
|
67
|
+
Ractor.current[MUTEX_KEY] ||
|
|
68
|
+
if Ractor.respond_to?(:store_if_absent)
|
|
69
|
+
Ractor.store_if_absent(MUTEX_KEY) { Mutex.new }
|
|
70
|
+
else
|
|
71
|
+
# :nocov:
|
|
72
|
+
Ractor.current[MUTEX_KEY] ||= Mutex.new # Ruby 3.3 lacks store_if_absent
|
|
73
|
+
# :nocov:
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
21
77
|
# Clears one or all entries from the Ractor-safe shared cache.
|
|
22
78
|
#
|
|
23
79
|
# @param method_name [Symbol, String]
|
|
@@ -76,13 +132,8 @@ module SafeMemoize
|
|
|
76
132
|
private
|
|
77
133
|
|
|
78
134
|
# Sends a message to the supervisor and blocks until the tagged response arrives.
|
|
79
|
-
# Uses Thread.current.object_id as a per-call tag so concurrent threads in the
|
|
80
|
-
# main Ractor do not steal each other's replies.
|
|
81
135
|
def __ractor_cache_send__(supervisor, op, *args)
|
|
82
|
-
|
|
83
|
-
msg = Ractor.make_shareable([Ractor.current, tag, op, *args])
|
|
84
|
-
supervisor.send(msg)
|
|
85
|
-
Ractor.receive_if { |m| m.is_a?(Array) && m[0] == tag }[1]
|
|
136
|
+
RactorSharedMethods.round_trip(supervisor, op, *args)
|
|
86
137
|
end
|
|
87
138
|
|
|
88
139
|
# Creates the supervisor Ractor that owns this class's Ractor-safe shared cache.
|
|
@@ -10,6 +10,7 @@ module SafeMemoize
|
|
|
10
10
|
VERSION_PATTERN = /VERSION = "[^"]+"/
|
|
11
11
|
SEMVER_PATTERN = /\A\d+\.\d+\.\d+(?:[-.][0-9A-Za-z]+(?:[.-][0-9A-Za-z]+)*)?\z/
|
|
12
12
|
UNRELEASED_HEADING = "## [Unreleased]"
|
|
13
|
+
UNRELEASED_LINK = %r{^\[Unreleased\]: (?<base>\S+)/compare/v(?<previous>[^.\s]\S*?)\.\.\.HEAD$}
|
|
13
14
|
|
|
14
15
|
def normalize_version(version)
|
|
15
16
|
normalized_version = version.to_s.sub(/\Av/, "")
|
|
@@ -43,7 +44,24 @@ module SafeMemoize
|
|
|
43
44
|
raise ArgumentError, "CHANGELOG.md already contains #{normalized_version}"
|
|
44
45
|
end
|
|
45
46
|
|
|
46
|
-
contents.sub(UNRELEASED_HEADING, "#{UNRELEASED_HEADING}\n\n#{release_heading}")
|
|
47
|
+
contents = contents.sub(UNRELEASED_HEADING, "#{UNRELEASED_HEADING}\n\n#{release_heading}")
|
|
48
|
+
update_version_links(contents, normalized_version)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Keeps the reference-style link definitions at the bottom of the CHANGELOG
|
|
52
|
+
# in sync: points [Unreleased] at the new tag and adds a compare link for
|
|
53
|
+
# the new version. Left untouched when no [Unreleased] link is present.
|
|
54
|
+
def update_version_links(contents, version)
|
|
55
|
+
match = contents.match(UNRELEASED_LINK)
|
|
56
|
+
return contents unless match
|
|
57
|
+
|
|
58
|
+
base = match[:base]
|
|
59
|
+
replacement = [
|
|
60
|
+
"[Unreleased]: #{base}/compare/v#{version}...HEAD",
|
|
61
|
+
"[#{version}]: #{base}/compare/v#{match[:previous]}...v#{version}"
|
|
62
|
+
].join("\n")
|
|
63
|
+
|
|
64
|
+
contents.sub(UNRELEASED_LINK) { replacement }
|
|
47
65
|
end
|
|
48
66
|
|
|
49
67
|
# Removes milestone sections from ROADMAP.md where every feature row has
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module Stores
|
|
5
|
+
# Multi-level (L1/L2/…) cache store that checks faster layers first and
|
|
6
|
+
# promotes values up on a miss, reducing latency and load on slower backends.
|
|
7
|
+
#
|
|
8
|
+
# Reads walk the store list from first (fastest) to last (slowest). On a
|
|
9
|
+
# miss at level N the value is read from level N+1 and written back into
|
|
10
|
+
# all preceding levels ("read-through promotion"). Writes always go to every
|
|
11
|
+
# level so all layers stay consistent.
|
|
12
|
+
#
|
|
13
|
+
# @example In-process L1 + Redis L2
|
|
14
|
+
# l1 = SafeMemoize::Stores::Memory.new
|
|
15
|
+
# l2 = MyRedisStore.new
|
|
16
|
+
#
|
|
17
|
+
# memoize :fetch, store: SafeMemoize::Stores::Multilevel.new(l1, l2)
|
|
18
|
+
#
|
|
19
|
+
# @example Via the store: Array shorthand
|
|
20
|
+
# memoize :fetch, store: [l1, l2], ttl: 300
|
|
21
|
+
#
|
|
22
|
+
# @example With a short promote_expires_in for the L1 layer
|
|
23
|
+
# memoize :fetch, store: SafeMemoize::Stores::Multilevel.new(l1, l2, promote_expires_in: 60)
|
|
24
|
+
class Multilevel < Base
|
|
25
|
+
# @return [Array<Stores::Base>] the ordered store layers (fastest first)
|
|
26
|
+
attr_reader :stores
|
|
27
|
+
|
|
28
|
+
# @param stores [Array<Stores::Base>] two or more store instances, ordered
|
|
29
|
+
# from fastest (L1) to slowest (last)
|
|
30
|
+
# @param promote_expires_in [Numeric, nil] TTL applied when promoting a
|
|
31
|
+
# value from a deeper layer into a shallower one; +nil+ means no expiry
|
|
32
|
+
# on the promoted entry (the L1 store's own eviction — e.g. LRU — handles
|
|
33
|
+
# memory bounds instead)
|
|
34
|
+
# @raise [ArgumentError] if fewer than two stores are supplied, or any
|
|
35
|
+
# element is not a {Stores::Base} instance
|
|
36
|
+
def initialize(*stores, promote_expires_in: nil)
|
|
37
|
+
raise ArgumentError, "Multilevel requires at least 2 stores" if stores.size < 2
|
|
38
|
+
|
|
39
|
+
stores.each_with_index do |s, i|
|
|
40
|
+
unless s.is_a?(Base)
|
|
41
|
+
raise ArgumentError,
|
|
42
|
+
"Multilevel store[#{i}] must be a Stores::Base instance (got #{s.class})"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
@stores = stores.freeze
|
|
47
|
+
@promote_expires_in = promote_expires_in ? Float(promote_expires_in) : nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Walk levels from fastest to slowest; return the first hit, promoting
|
|
51
|
+
# the value into all shallower layers.
|
|
52
|
+
def read(key)
|
|
53
|
+
@stores.each_with_index do |store, i|
|
|
54
|
+
result = store.read(key)
|
|
55
|
+
next if result.equal?(MISS)
|
|
56
|
+
|
|
57
|
+
# Promote into every shallower level
|
|
58
|
+
@stores.first(i).each { |s| s.write(key, result, expires_in: @promote_expires_in) }
|
|
59
|
+
return result
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
MISS
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Write to every level simultaneously.
|
|
66
|
+
def write(key, value, expires_in: nil)
|
|
67
|
+
@stores.each { |s| s.write(key, value, expires_in: expires_in) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Delete from every level.
|
|
71
|
+
def delete(key)
|
|
72
|
+
@stores.each { |s| s.delete(key) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Clear every level.
|
|
76
|
+
def clear
|
|
77
|
+
@stores.each(&:clear)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Union of live keys across all levels.
|
|
81
|
+
def keys
|
|
82
|
+
@stores.flat_map(&:keys).uniq
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module Stores
|
|
5
|
+
# Wraps any {Base} store adapter with probabilistic early expiry (the
|
|
6
|
+
# XFetch algorithm) to prevent cache stampedes — the thundering-herd
|
|
7
|
+
# problem where many processes simultaneously recompute a value the moment
|
|
8
|
+
# it expires under high load.
|
|
9
|
+
#
|
|
10
|
+
# Instead of waiting until {#read} returns {MISS} at the hard expiry
|
|
11
|
+
# deadline, the wrapper stochastically returns {MISS} slightly before
|
|
12
|
+
# expiry, giving one process a head start on recomputation while everyone
|
|
13
|
+
# else still gets the cached value. The probability of early expiry rises
|
|
14
|
+
# as the entry approaches its deadline.
|
|
15
|
+
#
|
|
16
|
+
# === XFetch formula
|
|
17
|
+
#
|
|
18
|
+
# early_expire = now − (delta × beta × log(rand)) ≥ expires_at
|
|
19
|
+
#
|
|
20
|
+
# * +delta+ — estimated computation time in seconds (default 0.1 s).
|
|
21
|
+
# Configure this to the typical duration of the underlying computation.
|
|
22
|
+
# * +beta+ — aggressiveness scalar (default 1.0); higher values trigger
|
|
23
|
+
# early recomputation more eagerly.
|
|
24
|
+
#
|
|
25
|
+
# Values are stored internally as an envelope +{value:, expires_at:}+ so
|
|
26
|
+
# the wrapper always knows the hard deadline regardless of what the inner
|
|
27
|
+
# store exposes on read. The envelope survives standard Ruby Marshal
|
|
28
|
+
# serialization (Redis via the +redis-store+ or +redis-client+ gems,
|
|
29
|
+
# Rails.cache, etc.). Values that cannot be serialized alongside a small
|
|
30
|
+
# hash are not supported.
|
|
31
|
+
#
|
|
32
|
+
# @example Wrap a Redis store
|
|
33
|
+
# store = SafeMemoize::Stores::XFetch.new(
|
|
34
|
+
# MyRedisStore.new,
|
|
35
|
+
# delta: 0.2, # typical computation time in seconds
|
|
36
|
+
# beta: 1.5 # slightly aggressive early expiry
|
|
37
|
+
# )
|
|
38
|
+
# memoize :fetch, store: store, ttl: 300
|
|
39
|
+
#
|
|
40
|
+
# @example Compose with CircuitBreaker
|
|
41
|
+
# store = SafeMemoize::Stores::XFetch.new(
|
|
42
|
+
# SafeMemoize::Stores::CircuitBreaker.new(MyRedisStore.new),
|
|
43
|
+
# delta: 0.1
|
|
44
|
+
# )
|
|
45
|
+
# memoize :fetch, store: store, ttl: 60
|
|
46
|
+
class XFetch < Base
|
|
47
|
+
ENVELOPE_KEY = :__sm_xfetch_v1__
|
|
48
|
+
|
|
49
|
+
DEFAULT_BETA = 1.0
|
|
50
|
+
DEFAULT_DELTA = 0.1
|
|
51
|
+
|
|
52
|
+
# @return [Stores::Base] the wrapped inner store
|
|
53
|
+
attr_reader :wrapped_store
|
|
54
|
+
# @return [Float] aggressiveness scalar
|
|
55
|
+
attr_reader :beta
|
|
56
|
+
# @return [Float] estimated computation time in seconds
|
|
57
|
+
attr_reader :delta
|
|
58
|
+
|
|
59
|
+
# @param store [Stores::Base] the backing store to wrap
|
|
60
|
+
# @param beta [Numeric] aggressiveness scalar (default 1.0)
|
|
61
|
+
# @param delta [Numeric] estimated computation time in seconds (default 0.1)
|
|
62
|
+
# @raise [ArgumentError] if +store+ is not a {Stores::Base} instance, or
|
|
63
|
+
# if +beta+/+delta+ are not positive numbers
|
|
64
|
+
def initialize(store, beta: DEFAULT_BETA, delta: DEFAULT_DELTA)
|
|
65
|
+
unless store.is_a?(Base)
|
|
66
|
+
raise ArgumentError, "XFetch requires a Stores::Base instance (got #{store.class})"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
@wrapped_store = store
|
|
70
|
+
@beta = Float(beta)
|
|
71
|
+
@delta = Float(delta)
|
|
72
|
+
|
|
73
|
+
raise ArgumentError, "beta must be positive" unless @beta > 0
|
|
74
|
+
raise ArgumentError, "delta must be positive" unless @delta > 0
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Read from the wrapped store and apply the XFetch probabilistic check.
|
|
78
|
+
#
|
|
79
|
+
# Returns {MISS} when:
|
|
80
|
+
# * the inner store has no entry for +key+
|
|
81
|
+
# * the stored value is not an XFetch envelope (possibly written by an
|
|
82
|
+
# older version or a different store wrapper)
|
|
83
|
+
# * the XFetch formula triggers early expiry
|
|
84
|
+
def read(key)
|
|
85
|
+
raw = @wrapped_store.read(key)
|
|
86
|
+
return MISS if raw.equal?(MISS)
|
|
87
|
+
return MISS unless envelope?(raw)
|
|
88
|
+
|
|
89
|
+
expires_at = raw[:expires_at]
|
|
90
|
+
|
|
91
|
+
if expires_at
|
|
92
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
93
|
+
early = now - @delta * @beta * Math.log(rand) >= expires_at
|
|
94
|
+
return MISS if early
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
raw[:value]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Write the value to the wrapped store inside an XFetch envelope.
|
|
101
|
+
def write(key, value, expires_in: nil)
|
|
102
|
+
expires_at = expires_in ? Process.clock_gettime(Process::CLOCK_MONOTONIC) + expires_in.to_f : nil
|
|
103
|
+
envelope = {ENVELOPE_KEY => true, :value => value, :expires_at => expires_at}
|
|
104
|
+
@wrapped_store.write(key, envelope, expires_in: expires_in)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Delete from the wrapped store.
|
|
108
|
+
def delete(key)
|
|
109
|
+
@wrapped_store.delete(key)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Clear the wrapped store.
|
|
113
|
+
def clear
|
|
114
|
+
@wrapped_store.clear
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Returns live keys from the wrapped store.
|
|
118
|
+
def keys
|
|
119
|
+
@wrapped_store.keys
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def envelope?(value)
|
|
125
|
+
value.is_a?(Hash) && value[ENVELOPE_KEY]
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
data/lib/safe_memoize/version.rb
CHANGED
data/lib/safe_memoize.rb
CHANGED
|
@@ -6,6 +6,8 @@ require_relative "safe_memoize/extension"
|
|
|
6
6
|
require_relative "safe_memoize/stores/base"
|
|
7
7
|
require_relative "safe_memoize/stores/memory"
|
|
8
8
|
require_relative "safe_memoize/stores/circuit_breaker"
|
|
9
|
+
require_relative "safe_memoize/stores/multilevel"
|
|
10
|
+
require_relative "safe_memoize/stores/xfetch"
|
|
9
11
|
require_relative "safe_memoize/adapters/statsd"
|
|
10
12
|
require_relative "safe_memoize/adapters/opentelemetry"
|
|
11
13
|
require_relative "safe_memoize/adapters/concurrent_ruby"
|