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.
@@ -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
- if memo_record_live?(record)
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
- if record_live
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: used when LRU tracking, conditional storage, or TTL refresh is needed.
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
- tag = Thread.current.object_id
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
- supervisor.send(Ractor.make_shareable([Ractor.current, tag, :store, cache_key, record]))
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 so concurrent callers do not
19
- # consume each other's replies.
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
- tag = Thread.current.object_id
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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module SafeMemoize
4
4
  # The current gem version string.
5
- VERSION = "1.6.0"
5
+ VERSION = "1.7.1"
6
6
  end
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"