safe_memoize 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1920c31de4a855b818c5a4f37f35260f38489b7ff6ffe221b43d91e862133bde
4
- data.tar.gz: 948979dfc49f4e42b16d21926344869fc06a39da3252ab1033697a5748a7362a
3
+ metadata.gz: e7a646739aa7f6c178b20192ddc8e94768df0c6b000097586796153f5d2138bc
4
+ data.tar.gz: 9454f9a6cf6b8e420d27b82f39740484eddab506d871e94034483ae8caf12622
5
5
  SHA512:
6
- metadata.gz: 415c4095ce702b5823a887858f342fdf5742887394dc2cc113ccf711ef1f5789430e036d69ee3660d663a2153f34375f3622f1ff92d73143072e2376d2ae087b
7
- data.tar.gz: 1fed071b0feb6591d4b95c69604f4c14440c88cefbdb13ce9f2e50a2eba5fad4723b28c1584f2cb7aaa33f7ba4e2140b5a16c45b99ba0cd9b13f47b2a16b0d58
6
+ metadata.gz: f569af43ba1ca765dcdd9490f5629bb572f1dbedaa676538a08c199282fe55a6b40afd29f8870ddbab86c091a20a89c10e87c3b060a124f4994313b2a2ffdda4
7
+ data.tar.gz: 2d0634278dd32e98fc0c23a74abfb98bf1c8d5d087e7eb7a8006dd54846bd11f0bbce27d704e4f82bf0fc81c961c34b1c62fb8dc92cddafbcec26c2b930e79d8
data/CHANGELOG.md CHANGED
@@ -8,6 +8,15 @@ from v1.0.0 onwards. Prior 0.x releases may include breaking changes between min
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.7.0] - 2026-06-02
12
+
13
+ ### Added
14
+
15
+ - `SafeMemoize::Stores::Multilevel` — multi-level (L1/L2/…) cache store that checks faster layers first and promotes values from deeper layers into shallower ones on a miss ("read-through promotion"). Reads walk the list from first (fastest) to last; writes go to every level simultaneously; deletes and clears apply to all levels. Accepts `promote_expires_in:` to control the TTL of promoted entries (default: no TTL, relying on the L1 store's own eviction). Raises `ArgumentError` if fewer than two stores are supplied.
16
+ - `store: [l1, l2]` shorthand on `memoize` — passing an `Array` of `Stores::Base` instances is automatically converted to `Stores::Multilevel.new(*stores)`, enabling multi-level caching without explicit wrapper construction.
17
+ - `SafeMemoize::Stores::XFetch` — wraps any `Stores::Base` adapter with probabilistic early expiry (the XFetch algorithm) to prevent cache stampedes. Values are stored with an envelope that includes `expires_at`; on read the XFetch formula `now − (delta × beta × log(rand)) ≥ expires_at` decides whether to return the value or `MISS` (triggering early recomputation). Configurable via `beta:` (aggressiveness scalar, default 1.0) and `delta:` (estimated computation time in seconds, default 0.1). Composes naturally with `Multilevel` and `CircuitBreaker`.
18
+ - `stampede_protection:` option on `memoize` — enables the XFetch algorithm for the per-instance in-process cache. Pass `true` (default beta 1.0) or a `Numeric` for a custom beta. Records actual computation time as `delta` on each miss so the XFetch probability adapts to real observed latency. Requires `ttl:`. Incompatible with `store:` (use `Stores::XFetch` for external stores) and `ractor_safe:`. Accepted by `safe_memoize_options` as a class-wide default.
19
+
11
20
  ## [1.6.0] - 2026-06-02
12
21
 
13
22
  ### Added
data/README.md CHANGED
@@ -81,6 +81,8 @@ SafeMemoize uses Ruby's `prepend` mechanism. When you call `memoize :method_name
81
81
  - [Copy-on-read via `copy_on_read: true` — returns a `dup`/`deep_dup` on every cache read to protect shared cached state from caller mutation](#copy-on-read)
82
82
  - [Cache invalidation groups via `group:` — tag related methods with a group name and bust them all with a single `reset_memo_group` call](#cache-invalidation-groups)
83
83
  - [Circuit breaker for external stores — `Stores::CircuitBreaker` wraps any store adapter and falls back to the per-instance cache when the store is down; configurable error threshold and probe interval](#circuit-breaker-for-external-stores)
84
+ - [Multi-level (L1/L2) caching — `Stores::Multilevel` or `store: [l1, l2]` shorthand; reads from the fastest layer first and promotes on miss](#multi-level-caching)
85
+ - [Stampede protection — `stampede_protection:` option applies the XFetch algorithm to the per-instance cache; `Stores::XFetch` applies it to external stores](#stampede-protection)
84
86
 
85
87
  ## Installation
86
88
 
@@ -1533,6 +1535,102 @@ end
1533
1535
 
1534
1536
  [↑ Back to features](#features)
1535
1537
 
1538
+ ## Multi-level caching
1539
+
1540
+ `SafeMemoize::Stores::Multilevel` chains two or more store adapters from fastest (L1) to slowest. Reads walk the chain until a hit is found; on a miss in an earlier layer the value is fetched from the next layer and written back ("promoted") into all shallower layers. Writes and deletes reach every layer.
1541
+
1542
+ ```ruby
1543
+ l1 = SafeMemoize::Stores::Memory.new # fast, in-process
1544
+ l2 = MyRedisStore.new # slower, cross-process
1545
+
1546
+ class ProductService
1547
+ prepend SafeMemoize
1548
+
1549
+ def catalog = fetch_catalog_from_db
1550
+ memoize :catalog, store: [l1, l2], ttl: 300 # Array shorthand
1551
+ end
1552
+ ```
1553
+
1554
+ The `store: [l1, l2]` shorthand is equivalent to `store: Stores::Multilevel.new(l1, l2)`.
1555
+
1556
+ ### Promotion TTL
1557
+
1558
+ By default promoted entries have no TTL (the L1 store's own eviction — e.g. LRU — handles memory bounds). Set `promote_expires_in:` to give L1 entries a shorter lifetime than L2:
1559
+
1560
+ ```ruby
1561
+ store = SafeMemoize::Stores::Multilevel.new(l1, l2, promote_expires_in: 60)
1562
+ memoize :catalog, store: store, ttl: 300
1563
+ ```
1564
+
1565
+ ### Composition
1566
+
1567
+ `Multilevel` composes with `CircuitBreaker` and `XFetch`:
1568
+
1569
+ ```ruby
1570
+ safe_l2 = SafeMemoize::Stores::CircuitBreaker.new(MyRedisStore.new)
1571
+ store = SafeMemoize::Stores::Multilevel.new(SafeMemoize::Stores::Memory.new, safe_l2)
1572
+ memoize :catalog, store: store, ttl: 300
1573
+ ```
1574
+
1575
+ [↑ Back to features](#features)
1576
+
1577
+ ## Stampede protection
1578
+
1579
+ Cache stampedes (a.k.a. thundering-herd) happen when a popular entry expires and many processes simultaneously recompute it. SafeMemoize offers two mechanisms:
1580
+
1581
+ ### `stampede_protection:` — per-instance cache
1582
+
1583
+ The `stampede_protection:` option applies the **XFetch algorithm** to the per-instance in-process cache. Instead of expiring at a hard deadline, each cache read probabilistically triggers early recomputation as the entry approaches its TTL:
1584
+
1585
+ ```ruby
1586
+ class ApiClient
1587
+ prepend SafeMemoize
1588
+
1589
+ def catalog = fetch_catalog
1590
+ memoize :catalog, ttl: 300, stampede_protection: true # default beta=1.0
1591
+ # or
1592
+ memoize :catalog, ttl: 300, stampede_protection: 2.0 # custom beta (more aggressive)
1593
+ end
1594
+ ```
1595
+
1596
+ The measured computation time from each cache miss is stored as `delta` and used in subsequent reads, so the XFetch probability adapts to real observed latency automatically.
1597
+
1598
+ **Requirements:** `ttl:` must be set. Incompatible with `store:` (see `Stores::XFetch` below) and `ractor_safe:`.
1599
+
1600
+ ### `Stores::XFetch` — external stores
1601
+
1602
+ For external stores (Redis, Rails.cache, etc.) wrap the adapter with `Stores::XFetch`. Values are stored with an `expires_at` envelope so the wrapper can apply the formula even though the store's `read` returns only the plain value:
1603
+
1604
+ ```ruby
1605
+ store = SafeMemoize::Stores::XFetch.new(
1606
+ MyRedisStore.new,
1607
+ delta: 0.2, # estimated typical computation time (seconds)
1608
+ beta: 1.5 # aggressiveness scalar
1609
+ )
1610
+
1611
+ class CatalogService
1612
+ prepend SafeMemoize
1613
+
1614
+ def products = db_fetch
1615
+ memoize :products, store: store, ttl: 300
1616
+ end
1617
+ ```
1618
+
1619
+ **XFetch formula:** `now − (delta × beta × log(rand)) ≥ expires_at`
1620
+
1621
+ A higher `beta` triggers early recomputation more aggressively. A larger `delta` (longer computation) also increases the recomputation window.
1622
+
1623
+ `Stores::XFetch` composes with `Multilevel` and `CircuitBreaker`:
1624
+
1625
+ ```ruby
1626
+ store = SafeMemoize::Stores::XFetch.new(
1627
+ SafeMemoize::Stores::CircuitBreaker.new(MyRedisStore.new),
1628
+ delta: 0.1
1629
+ )
1630
+ ```
1631
+
1632
+ [↑ Back to features](#features)
1633
+
1536
1634
  ## Per-class default options (`safe_memoize_options`)
1537
1635
 
1538
1636
  `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.
@@ -1772,6 +1870,7 @@ Anything **not** listed here — internal modules, private methods, `@__safe_mem
1772
1870
  | `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:` |
1773
1871
  | `group:` | `Symbol \| String \| nil` | `nil` | Assigns the method to a named invalidation group; call `reset_memo_group` / `reset_shared_memo_group` to bust all methods in the group at once; a method belongs to at most one group |
1774
1872
  | `circuit_breaker:` | `true \| Hash \| nil` | `nil` | Wraps the configured `store:` in a `Stores::CircuitBreaker`; `true` uses defaults (`error_threshold: 5`, `probe_interval: 30`); pass a Hash to customise; requires a store to be set; does not double-wrap |
1873
+ | `stampede_protection:` | `true \| Numeric \| nil` | `nil` | Enables XFetch probabilistic early expiry on the per-instance cache; `true` uses beta=1.0; pass a `Numeric` for a custom beta; requires `ttl:`; incompatible with `store:` and `ractor_safe:` |
1775
1874
  | *(extension options)* | any | — | Unknown kwargs are validated against registered extensions; raise `ArgumentError` if unclaimed |
1776
1875
 
1777
1876
  ### `memoize_all` options (class method)
@@ -1789,7 +1888,7 @@ All `memoize` option keys above, plus:
1789
1888
 
1790
1889
  | Option key | Type | Default | Notes |
1791
1890
  |---|---|---|---|
1792
- | any `memoize` key except mode-switches | — | — | Accepts `ttl:`, `max_size:`, `ttl_refresh:`, `if:`, `unless:`, `key:`, `cache_bust:`, `copy_on_read:`, `namespace:`, `store:`, `group:`, `circuit_breaker:`; raises `ArgumentError` for `shared:`, `fiber_local:`, `ractor_safe:`, `shared_cache:` |
1891
+ | any `memoize` key except mode-switches | — | — | Accepts `ttl:`, `max_size:`, `ttl_refresh:`, `if:`, `unless:`, `key:`, `cache_bust:`, `copy_on_read:`, `namespace:`, `store:`, `group:`, `circuit_breaker:`, `stampede_protection:`; raises `ArgumentError` for `shared:`, `fiber_local:`, `ractor_safe:`, `shared_cache:` |
1793
1892
 
1794
1893
  ### Instance methods (public)
1795
1894
 
data/ROADMAP.md CHANGED
@@ -4,17 +4,6 @@ This document tracks the planned evolution of SafeMemoize through v1.0.0 and bey
4
4
 
5
5
  ---
6
6
 
7
- ## v1.7.0 — Advanced Store Features
8
-
9
- *Goal: multi-process performance patterns for high-traffic deployments.*
10
-
11
- | Feature | Description | Status |
12
- |---|---|---|
13
- | 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 |
14
- | 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 |
15
-
16
- ---
17
-
18
7
  ## v2.0.0 — Next Generation (Long Horizon)
19
8
 
20
9
  *Goal: incorporate real-world usage feedback, clean up accumulated API surface, and open a path for advanced extension.*
@@ -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__ ||= {}
@@ -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.0"
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"
data/sig/safe_memoize.rbs CHANGED
@@ -66,7 +66,7 @@ module SafeMemoize
66
66
  end
67
67
 
68
68
  module ClassMethods
69
- def memoize: (Symbol | String method_name, ?ttl: Numeric?, ?max_size: Integer?, ?ttl_refresh: bool, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool, ?key: (^(*untyped args, **untyped kwargs) -> untyped)?, ?store: Stores::Base?, ?fiber_local: bool, ?ractor_safe: bool, ?namespace: String?, ?shared_cache: String?, ?cache_bust: (^() -> untyped) | Symbol | nil, ?copy_on_read: bool, ?group: Symbol | String | nil, ?circuit_breaker: bool | Hash[Symbol, untyped] | nil, **untyped extension_options) -> void
69
+ def memoize: (Symbol | String method_name, ?ttl: Numeric?, ?max_size: Integer?, ?ttl_refresh: bool, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool, ?key: (^(*untyped args, **untyped kwargs) -> untyped)?, ?store: Stores::Base | Array[Stores::Base] | nil, ?fiber_local: bool, ?ractor_safe: bool, ?namespace: String?, ?shared_cache: String?, ?cache_bust: (^() -> untyped) | Symbol | nil, ?copy_on_read: bool, ?group: Symbol | String | nil, ?circuit_breaker: bool | Hash[Symbol, untyped] | nil, ?stampede_protection: bool | Numeric | nil, **untyped extension_options) -> void
70
70
  def safe_memoize_store: () -> Stores::Base?
71
71
  def safe_memoize_store=: (Stores::Base?) -> Stores::Base?
72
72
  def safe_memoize_namespace: () -> String?
@@ -148,7 +148,7 @@ module SafeMemoize
148
148
 
149
149
  def memo_ttl: (Numeric? ttl) -> Float?
150
150
  def memo_expires_at: (Float? ttl) -> Float?
151
- def memo_record: (untyped value, expires_at: Float?) -> memo_record
151
+ def memo_record: (untyped value, expires_at: Float?, ?delta: Float?) -> memo_record
152
152
  def memo_record_value: (memo_record record) -> untyped
153
153
  def memo_record_live?: (memo_record? record) -> bool
154
154
  def memo_prune_expired_entries!: (Hash[memo_key, memo_record] cache) -> void
@@ -301,6 +301,38 @@ module SafeMemoize
301
301
  def expired?: ({ expires_at: Float?, value: untyped, cached_at: Float }) -> bool
302
302
  end
303
303
 
304
+ class Multilevel < Base
305
+ attr_reader stores: Array[Base]
306
+
307
+ def initialize: (*Base stores, ?promote_expires_in: Numeric?) -> void
308
+ def read: (untyped key) -> untyped
309
+ def write: (untyped key, untyped value, ?expires_in: Numeric?) -> void
310
+ def delete: (untyped key) -> void
311
+ def clear: () -> void
312
+ def keys: () -> Array[untyped]
313
+ end
314
+
315
+ class XFetch < Base
316
+ ENVELOPE_KEY: Symbol
317
+ DEFAULT_BETA: Float
318
+ DEFAULT_DELTA: Float
319
+
320
+ attr_reader wrapped_store: Base
321
+ attr_reader beta: Float
322
+ attr_reader delta: Float
323
+
324
+ def initialize: (Base store, ?beta: Numeric, ?delta: Numeric) -> void
325
+ def read: (untyped key) -> untyped
326
+ def write: (untyped key, untyped value, ?expires_in: Numeric?) -> void
327
+ def delete: (untyped key) -> void
328
+ def clear: () -> void
329
+ def keys: () -> Array[untyped]
330
+
331
+ private
332
+
333
+ def envelope?: (untyped value) -> bool
334
+ end
335
+
304
336
  class CircuitBreaker < Base
305
337
  DEFAULT_ERROR_THRESHOLD: Integer
306
338
  DEFAULT_PROBE_INTERVAL: Float
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.6.0
4
+ version: 1.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -79,8 +79,10 @@ files:
79
79
  - lib/safe_memoize/stores/base.rb
80
80
  - lib/safe_memoize/stores/circuit_breaker.rb
81
81
  - lib/safe_memoize/stores/memory.rb
82
+ - lib/safe_memoize/stores/multilevel.rb
82
83
  - lib/safe_memoize/stores/rails_cache.rb
83
84
  - lib/safe_memoize/stores/redis.rb
85
+ - lib/safe_memoize/stores/xfetch.rb
84
86
  - lib/safe_memoize/version.rb
85
87
  - rbi/safe_memoize.rbi
86
88
  - sig/safe_memoize.rbs