safe_memoize 1.1.0 → 1.2.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: 12783636e7ebfd6b21453d9262eb81157d9c82de3a9a538fb8825fd173f072f6
4
- data.tar.gz: fff5a502ff49712cf365b3fc67d337ad279f1b28e7ed604ee63b8dcf43e0f6bd
3
+ metadata.gz: 24faf0555ba5e11e37092aea14e1db090d7f5bb13dd40edc33ea30d8adcba552
4
+ data.tar.gz: '09beaa5cf9e25284462bdac9636929642213a9e8d657900d30913832da230959'
5
5
  SHA512:
6
- metadata.gz: 0eedea81071d36fb8891c8767b43b07e5873aaca7d0f14bafdc339acf9c0c040b1e9b050ff72ec840d0edeccf33ef422082423576b5ae58e371dec13f50e4ed0
7
- data.tar.gz: 506cadf95e962b1dccf167be9566663ee44d9d4de8ee7eed75bad4189596674b0dc6e485b9063a37f222b1136698b28f031f56d2a035f04e6d3ffa13fcc34b50
6
+ metadata.gz: 8ed21a58fc95a122794bcb91a1cbccae2bad598b38f736d76eb6f95551245dba070e047eaaea56b26fca6aace181e138acd37a0a027dbb7c0a6a43ae728f2cf0
7
+ data.tar.gz: f51afd15884dd10c771cd3f3ffcb7d481b0fd3012ecc74148fc9ff82e056abc1a2bb044d5344d635abb5380d200f0ec62a4db528d8fc79697358f775cc4aba3e
@@ -53,12 +53,12 @@ jobs:
53
53
  bundler-cache: true
54
54
 
55
55
  - name: Run test suite
56
- run: bundle exec rspec
56
+ run: bundle exec rake spec
57
57
 
58
58
  - name: Upload coverage to Codecov
59
59
  uses: codecov/codecov-action@v5
60
60
  if: matrix.ruby == '3.4'
61
61
  with:
62
62
  token: ${{ secrets.CODECOV_TOKEN }}
63
- files: coverage/.resultset.json
63
+ files: coverage/coverage.json
64
64
 
data/CHANGELOG.md CHANGED
@@ -8,6 +8,30 @@ from v1.0.0 onwards. Prior 0.x releases may include breaking changes between min
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.2.0] - 2026-05-27
12
+
13
+ ### Added
14
+
15
+ - `SafeMemoize::Adapters::ConcurrentRuby` — optional store adapter backed by `concurrent-ruby`; uses `Concurrent::Map` as the backing hash and `Concurrent::ReentrantReadWriteLock` to allow multiple readers to proceed in parallel while writers still get exclusive access; reduces lock contention on hot read paths compared to the default `Mutex`-backed `Stores::Memory`; `concurrent-ruby` is a soft dependency — a `LoadError` with an actionable message is raised at instantiation if the gem is not available
16
+ - `.safe_memoize_store=` / `.safe_memoize_store` — class-level attribute for setting a default store on an individual class without touching the global `SafeMemoize.configure` default; takes precedence over `SafeMemoize.configuration.default_store` but is overridden by a per-method `store:` argument; accepts any `SafeMemoize::Stores::Base` instance or `nil`; raises `ArgumentError` for invalid values
17
+
18
+ - `ractor_safe: true` option on `memoize` — replaces the `Mutex`-backed class-level shared cache with a supervisor `Ractor` that owns the mutable cache hash; all reads and writes are serialised through message passing so the cache is safe to use from multiple Ractors; requires `shared: true`; cached values are deep-frozen via `Ractor.make_shareable`; the memoize wrapper Proc is frozen with `Ractor.make_shareable` before being passed to `define_method` so that classes using `ractor_safe: true` can be passed directly into worker Ractors; incompatible with `if:`, `unless:`, `max_size:`, `ttl_refresh:`, `key:`, and `store:` (raises `ArgumentError`); `ttl:` is supported
19
+ - `.reset_ractor_memo(method_name, *args, **kwargs)` — class method to clear one or all entries from the Ractor-safe shared cache for a given method
20
+ - `.reset_all_ractor_memos` — class method to clear the entire Ractor-safe shared cache for this class
21
+ - `.ractor_memoized?(method_name, *args, **kwargs)` — returns `true` if a live entry exists in the Ractor-safe shared cache for the given call signature
22
+ - `.ractor_memo_count(method_name = nil)` — returns the number of live entries in the Ractor-safe shared cache; scoped to one method when a name is given
23
+ - `fiber_local: true` option on `memoize` — stores results in `Fiber[:__safe_memoize__]` rather than instance variables, giving each fiber its own isolated cache that is automatically discarded when the fiber terminates; no `Mutex` is acquired because fibers are cooperative; a per-fiber ownership sentinel ensures inherited storage from parent fibers is replaced with a fresh isolated store on first write; supports all standard options (`ttl:`, `ttl_refresh:`, `max_size:`, `if:`, `unless:`, `key:`); incompatible with `shared:` and `store:` (raises `ArgumentError`)
24
+ - `#fiber_local_memoized?(method_name, *args, **kwargs)` — returns `true` if the given call is currently cached in the current fiber's store
25
+ - `#reset_fiber_memo(method_name, *args, **kwargs)` — clears one or all fiber-local cached entries for a method in the current fiber
26
+ - `#reset_all_fiber_memos` — clears all fiber-local cached entries for this instance in the current fiber
27
+
28
+ ### Fixed
29
+
30
+ - `call_memo_hooks` no longer raises `Ractor::IsolationError` when called from a worker Ractor — `SafeMemoize.configuration` (a module-level ivar) is now accessed only from the main Ractor; `ActiveSupport::Notifications` and `StatsD` dispatch are silently skipped from worker Ractors; hook-error handling falls back to `warn` rather than reading the configuration handler
31
+ - CI coverage ordering — ractor specs now run *last* (after all other specs) so that Ractor background threads cannot disrupt Ruby's Coverage counters while collecting coverage for non-Ractor code; previously only store specs were ordered first
32
+ - Codecov reporting accuracy — switched SimpleCov output from `.resultset.json` (internal format, misread by Codecov as ~85%) to `coverage/coverage.json` via `simplecov_json_formatter`; CI now uploads the correct file
33
+ - CI coverage ordering — `bundle exec rspec` ran files alphabetically, causing `ractor_spec.rb` to execute before `spec/stores/`, disrupting Ruby's Coverage counters and dropping reported coverage to ~96%; CI now uses `bundle exec rake spec`, which enforces the store-first ordering already documented in the Rakefile
34
+
11
35
  ## [1.1.0] - 2026-05-22
12
36
 
13
37
  ### Added
@@ -25,7 +49,7 @@ from v1.0.0 onwards. Prior 0.x releases may include breaking changes between min
25
49
  - `store:` type guard in `ClassMethods#memoize` collapsed to an inline guard clause so Ruby's Coverage module counts the raise correctly
26
50
  - Hook-error isolation tests (`concurrency_spec`, `hooks_spec`) now configure `on_hook_error = ->(*) {}` to silence expected stderr warnings rather than leaking them into test output; StatsD error-resilience test asserts on the emitted warning with `expect { }.to output(...).to_stderr`
27
51
 
28
- ## [1.0.0] - 2026-05-22
52
+ ## [1.0.0] - 2026-05-22
29
53
 
30
54
  ### Added
31
55
 
data/README.md CHANGED
@@ -67,6 +67,12 @@ SafeMemoize uses Ruby's `prepend` mechanism. When you call `memoize :method_name
67
67
  - [`memo_age` and `memo_stale?` for TTL introspection](#cache-inspection)
68
68
  - [Class-level `key:` option for shared cache key generation](#custom-cache-keys)
69
69
  - [`shared_memo_age` and `shared_memo_stale?` for shared cache TTL inspection](#shared-cache)
70
+ - [Pluggable external cache stores — Redis, Rails.cache, or any custom adapter](#pluggable-cache-stores)
71
+ - [Global default store via `Configuration#default_store`](#pluggable-cache-stores)
72
+ - [`SafeMemoize::Adapters::ConcurrentRuby` — optional `concurrent-ruby` store with parallel-read locking](#concurrent-ruby-adapter)
73
+ - [Class-level `.safe_memoize_store=` — set a per-class default store without touching global config](#class-level-default-store-safe_memoize_store)
74
+ - [Fiber-local memoization via `fiber_local: true` — isolated per-fiber cache, no mutex, works with Async/Falcon](#fiber-local-memoization)
75
+ - [Ractor-safe shared cache via `ractor_safe: true` — supervisor Ractor replaces the Mutex; worker Ractors can call the memoized method directly](#ractor-safe-shared-cache)
70
76
 
71
77
  ## Installation
72
78
 
@@ -498,6 +504,48 @@ Hooks (`on_memo_hit`, `on_memo_miss`, `on_memo_expire`, `on_memo_evict`) fire on
498
504
 
499
505
  [↑ Back to features](#features)
500
506
 
507
+ ### Fiber-local memoization
508
+
509
+ Pass `fiber_local: true` to store results in `Fiber[:__safe_memoize__]` rather than instance variables. Each fiber gets its own isolated cache that is automatically discarded when the fiber terminates — no explicit cleanup required.
510
+
511
+ This is the right choice for Fiber-based concurrency frameworks like [Async](https://github.com/socketry/async), [Falcon](https://github.com/socketry/falcon), and Rails async controllers, where multiple fibers share the same object instance and must not see each other's cached values.
512
+
513
+ ```ruby
514
+ class ApiClient
515
+ prepend SafeMemoize
516
+
517
+ def fetch(path)
518
+ http_get(path)
519
+ end
520
+
521
+ memoize :fetch, fiber_local: true
522
+ end
523
+
524
+ client = ApiClient.new
525
+
526
+ Fiber.new { client.fetch("/a") }.resume # computes in this fiber
527
+ Fiber.new { client.fetch("/a") }.resume # computes again — isolated cache
528
+ ```
529
+
530
+ `fiber_local: true` works with all standard options: `ttl:`, `ttl_refresh:`, `max_size:`, `if:`, `unless:`, and `key:`. It is incompatible with `shared:` and `store:` (both raise `ArgumentError`).
531
+
532
+ No `Mutex` is acquired because fibers within a single thread are cooperative — only one fiber executes at a time.
533
+
534
+ **Fiber isolation guarantee**: Ruby's `Fiber.new` inherits the parent fiber's local storage by default. SafeMemoize detects inherited stores via an ownership sentinel and replaces them with a fresh, isolated store on first write, so child fibers never see the parent's cached entries.
535
+
536
+ Instance-level inspection and reset for fiber-local entries use dedicated methods:
537
+
538
+ ```ruby
539
+ obj.fiber_local_memoized?(:fetch, "/a") # true / false for the current fiber
540
+ obj.reset_fiber_memo(:fetch) # clear all entries for :fetch in current fiber
541
+ obj.reset_fiber_memo(:fetch, "/a") # clear one specific entry
542
+ obj.reset_all_fiber_memos # clear all fiber-local entries for this instance
543
+ ```
544
+
545
+ Lifecycle hooks and cache metrics work the same as for regular memoization. The existing `memoized?`, `reset_memo`, and `memo_count` methods operate on the instance-variable cache; use the `fiber_local_*` / `reset_fiber_*` API for fiber-local entries.
546
+
547
+ [↑ Back to features](#features)
548
+
501
549
  ### Bulk memoization
502
550
 
503
551
  Use `memoize_all` to memoize every public method defined on the class in one call:
@@ -698,7 +746,7 @@ Both settings apply at definition time — methods already memoized before `conf
698
746
  SafeMemoize.reset_configuration!
699
747
  ```
700
748
 
701
- The configure block also accepts `on_hook_error`, `on_deprecation`, `active_support_notifications`, and `statsd_client` (covered in [Hook error isolation](#hook-error-isolation), [Deprecation](#deprecation), [ActiveSupport::Notifications](#activesupportnotifications), and [StatsD](#statsd)).
749
+ The configure block also accepts `on_hook_error`, `on_deprecation`, `active_support_notifications`, `statsd_client`, and `default_store` (covered in [Hook error isolation](#hook-error-isolation), [Deprecation](#deprecation), [ActiveSupport::Notifications](#activesupportnotifications), [StatsD](#statsd), and [Pluggable cache stores](#pluggable-cache-stores)).
702
750
 
703
751
  [↑ Back to features](#features)
704
752
 
@@ -864,6 +912,155 @@ end
864
912
 
865
913
  [↑ Back to features](#features)
866
914
 
915
+ ### Pluggable cache stores
916
+
917
+ By default, memoized results live in a per-instance hash — fast, but private to each object. Pass `store:` to route reads and writes through any external backend, enabling cross-process and distributed memoization.
918
+
919
+ #### Built-in: `Stores::Memory`
920
+
921
+ `Stores::Memory` is the built-in in-process store. It is used automatically by the `store:` default and is the reference implementation for custom adapters. You can pass your own instance to share a cache across multiple classes or to set a TTL on the shared store:
922
+
923
+ ```ruby
924
+ SHARED_STORE = SafeMemoize::Stores::Memory.new
925
+
926
+ class UserService
927
+ prepend SafeMemoize
928
+ def find(id) = User.find(id)
929
+ memoize :find, store: SHARED_STORE, ttl: 60
930
+ end
931
+
932
+ class PostService
933
+ prepend SafeMemoize
934
+ def author(post) = User.find(post.user_id)
935
+ memoize :author, store: SHARED_STORE
936
+ end
937
+ ```
938
+
939
+ The store is shared across all instances of a class, so the method is computed only once per unique argument set regardless of how many objects exist.
940
+
941
+ #### Redis adapter
942
+
943
+ Requires a Redis-compatible client (the `redis` gem or any drop-in replacement):
944
+
945
+ ```ruby
946
+ require "safe_memoize/stores/redis"
947
+ require "redis"
948
+
949
+ REDIS_STORE = SafeMemoize::Stores::Redis.new(::Redis.new)
950
+
951
+ class PricingService
952
+ prepend SafeMemoize
953
+ def quote(sku) = api_fetch(sku)
954
+ memoize :quote, store: REDIS_STORE, ttl: 300
955
+ end
956
+ ```
957
+
958
+ Values and keys are serialized with `Marshal` (Base64-encoded via `Array#pack("m0")`). TTL is forwarded to Redis as `PX` (milliseconds) for sub-second precision. `clear` uses `SCAN` so it never blocks the Redis event loop. All keys are namespace-scoped (default: `"safe_memoize"`) so multiple stores or applications can share one Redis instance:
959
+
960
+ ```ruby
961
+ REDIS_STORE = SafeMemoize::Stores::Redis.new(::Redis.new, namespace: "myapp:memo")
962
+ ```
963
+
964
+ #### Rails.cache adapter
965
+
966
+ Wraps any `ActiveSupport::Cache::Store`, including `Rails.cache`:
967
+
968
+ ```ruby
969
+ require "safe_memoize/stores/rails_cache"
970
+
971
+ RAILS_STORE = SafeMemoize::Stores::RailsCache.new(Rails.cache)
972
+
973
+ class CatalogService
974
+ prepend SafeMemoize
975
+ def fetch(slug) = Catalog.find_by!(slug: slug)
976
+ memoize :fetch, store: RAILS_STORE, ttl: 600
977
+ end
978
+ ```
979
+
980
+ Cached `nil` and `false` values are distinguished from a cache miss via a sentinel envelope, so falsy results are preserved correctly. TTL is forwarded as `expires_in:` for native store expiry. `clear` uses `delete_matched` scoped to the namespace.
981
+
982
+ #### Custom adapters
983
+
984
+ Subclass `SafeMemoize::Stores::Base` and implement the six-method contract:
985
+
986
+ ```ruby
987
+ class MyStore < SafeMemoize::Stores::Base
988
+ def read(key) = ... # return MISS if absent
989
+ def write(key, value, expires_in: nil) = ...
990
+ def delete(key) = ...
991
+ def clear = ...
992
+ def keys = ... # Array of stored keys
993
+ end
994
+ ```
995
+
996
+ Use `SafeMemoize::Stores::Base::MISS` (a frozen sentinel object) as the return value from `read` when the key is absent — this distinguishes a cache miss from a cached `nil` or `false`.
997
+
998
+ #### concurrent-ruby adapter
999
+
1000
+ `SafeMemoize::Adapters::ConcurrentRuby` replaces the default `Mutex`-backed store with `Concurrent::Map` and `Concurrent::ReentrantReadWriteLock` from the [`concurrent-ruby`](https://github.com/ruby-concurrency/concurrent-ruby) gem. Multiple readers proceed in parallel; writers still get exclusive access. For read-heavy hot paths this can meaningfully reduce lock contention.
1001
+
1002
+ `concurrent-ruby` is a **soft dependency** — it is not required at runtime unless you instantiate the adapter. Add it to your own `Gemfile`:
1003
+
1004
+ ```ruby
1005
+ gem "concurrent-ruby"
1006
+ ```
1007
+
1008
+ Opt in per class:
1009
+
1010
+ ```ruby
1011
+ class HotService
1012
+ prepend SafeMemoize
1013
+ self.safe_memoize_store = SafeMemoize::Adapters::ConcurrentRuby.new
1014
+
1015
+ def expensive(id) = db.find(id)
1016
+ memoize :expensive
1017
+ end
1018
+ ```
1019
+
1020
+ Or set it globally:
1021
+
1022
+ ```ruby
1023
+ SafeMemoize.configure do |c|
1024
+ c.default_store = SafeMemoize::Adapters::ConcurrentRuby.new
1025
+ end
1026
+ ```
1027
+
1028
+ A `LoadError` with an actionable message is raised at instantiation if `concurrent-ruby` is not installed. The adapter is incompatible with `max_size:` and `shared:` (same constraints as all external stores).
1029
+
1030
+ #### Class-level default store (`safe_memoize_store=`)
1031
+
1032
+ Set a default store for every `memoize` call on a single class without touching the global configuration:
1033
+
1034
+ ```ruby
1035
+ class ReportService
1036
+ prepend SafeMemoize
1037
+ self.safe_memoize_store = SafeMemoize::Adapters::ConcurrentRuby.new
1038
+
1039
+ def summary = compute_summary # routed through ConcurrentRuby
1040
+ memoize :summary
1041
+ end
1042
+ ```
1043
+
1044
+ The resolution order is: per-method `store:` → class-level `.safe_memoize_store` → global `SafeMemoize.configuration.default_store`. Assign `nil` to clear. An invalid value (not a `Stores::Base` instance) raises `ArgumentError`.
1045
+
1046
+ #### Global default store
1047
+
1048
+ Set a default store for all compatible `memoize` calls without specifying `store:` on each one:
1049
+
1050
+ ```ruby
1051
+ SafeMemoize.configure do |c|
1052
+ c.default_store = SafeMemoize::Stores::Redis.new(::Redis.new)
1053
+ end
1054
+ ```
1055
+
1056
+ A per-method `store:` option always takes precedence. Methods using `max_size:` or `shared:` silently bypass the global default (LRU and shared-mode use their own storage). An invalid value raises `ArgumentError` at `memoize` time. Reset with `SafeMemoize.reset_configuration!`.
1057
+
1058
+ #### Compatibility
1059
+
1060
+ The `store:` option composes with `ttl:`, `ttl_refresh:`, `if:`, `unless:`, lifecycle hooks, and cache metrics. It is incompatible with `max_size:` (use the store adapter's own eviction) and `shared:` (raise `ArgumentError` if combined).
1061
+
1062
+ [↑ Back to features](#features)
1063
+
867
1064
  ### Deprecation
868
1065
 
869
1066
  SafeMemoize ships a structured deprecation helper for gem authors who build on top of it:
@@ -895,17 +1092,72 @@ end
895
1092
 
896
1093
  [↑ Back to features](#features)
897
1094
 
1095
+ ## Ractor-safe shared cache
1096
+
1097
+ Pass `ractor_safe: true` (together with `shared: true`) to replace the `Mutex`-backed class-level shared cache with a supervisor `Ractor` that owns the mutable cache hash. All reads and writes are serialised through message passing, so the cache is safe to use from multiple Ractors.
1098
+
1099
+ ```ruby
1100
+ class PriceService
1101
+ prepend SafeMemoize
1102
+
1103
+ def fetch_price(item_id)
1104
+ external_api.get("/prices/#{item_id}")
1105
+ end
1106
+
1107
+ memoize :fetch_price, shared: true, ractor_safe: true, ttl: 300
1108
+ end
1109
+
1110
+ # Main Ractor — multiple threads share one cache entry
1111
+ 20.times.map { Thread.new { PriceService.new.fetch_price(42) } }.map(&:value)
1112
+
1113
+ # Worker Ractors also read from and write to the same supervisor cache
1114
+ result = Ractor.new(PriceService) { |s| s.new.fetch_price(42) }.take
1115
+ ```
1116
+
1117
+ ### How it works
1118
+
1119
+ - A **supervisor `Ractor`** is created once per class the first time a `ractor_safe: true` method is memoized. It owns a plain Ruby `Hash` and responds to `:fetch`, `:store`, `:delete_all`, `:delete_one`, `:clear`, `:memoized`, and `:count` messages.
1120
+ - The memoize **wrapper Proc** is frozen via `Ractor.make_shareable` before being registered with `define_method`, so the class can be passed directly into `Ractor.new` blocks.
1121
+ - Cached values are deep-frozen via `Ractor.make_shareable`. Values that cannot be made shareable (e.g. a `Mutex`) raise `ArgumentError`.
1122
+ - **Thread safety** inside the main Ractor (multiple threads) is handled by per-call tags (`Thread.current.object_id`) combined with `Ractor.receive_if`, so concurrent threads never consume each other's replies.
1123
+ - `ttl:` is supported. Expired entries are skipped by the supervisor's `:fetch` handler.
1124
+
1125
+ ### Constraints
1126
+
1127
+ `ractor_safe: true` is intentionally limited. The following options are incompatible and raise `ArgumentError` at `memoize` time:
1128
+
1129
+ | Option | Reason |
1130
+ |---|---|
1131
+ | `if:` / `unless:` | Conditional Procs are non-Ractor-shareable |
1132
+ | `max_size:` | LRU order tracking requires a non-shareable Ruby `Hash` |
1133
+ | `ttl_refresh:` | Requires re-examining the record on every hit |
1134
+ | `key:` | Custom key Procs are non-Ractor-shareable |
1135
+ | `store:` | External adapters are incompatible with the supervisor model |
1136
+
1137
+ ### Class-level API
1138
+
1139
+ ```ruby
1140
+ PriceService.ractor_memoized?(:fetch_price, 42) # → true / false
1141
+ PriceService.ractor_memo_count # → total live entries
1142
+ PriceService.ractor_memo_count(:fetch_price) # → entries for one method
1143
+ PriceService.reset_ractor_memo(:fetch_price, 42) # → clear one entry
1144
+ PriceService.reset_ractor_memo(:fetch_price) # → clear all entries for method
1145
+ PriceService.reset_all_ractor_memos # → clear entire shared cache
1146
+ ```
1147
+
1148
+ [↑ Back to features](#features)
1149
+
898
1150
  ## Ractor compatibility
899
1151
 
900
- SafeMemoize is **not Ractor-compatible** in its current form. Passing a class that uses `memoize` into a `Ractor.new` block raises `RuntimeError: defined with an un-shareable Proc in a different Ractor`. There are two root causes:
1152
+ Regular `memoize` (without `ractor_safe: true`) is **not Ractor-compatible**. Passing a class that uses `memoize` into a `Ractor.new` block raises `RuntimeError: defined with an un-shareable Proc in a different Ractor`. There are two root causes:
901
1153
 
902
1154
  1. **Non-shareable closures.** `ClassMethods#memoize` builds anonymous modules using `define_method` with blocks that close over local variables (`ttl`, `max_size`, `condition`, `shared_mutex`, …). Ruby marks those Procs as non-Ractor-shareable, so the host class cannot be sent to a Ractor.
903
1155
 
904
- 2. **Mutable module-level state.** `SafeMemoize.configuration` reads `@configuration` from the `SafeMemoize` module — a mutable ivar on a shared constant — which raises `Ractor::IsolationError` from a non-main Ractor. This affects every memoized call because hooks and adapters always read the configuration.
1156
+ 2. **Mutable module-level state.** `SafeMemoize.configuration` reads `@configuration` from the `SafeMemoize` module — a mutable ivar on a shared constant — which raises `Ractor::IsolationError` from a non-main Ractor.
905
1157
 
906
- **Workaround:** Use Ruby Threads instead of Ractors SafeMemoize is fully thread-safe via double-check locking and per-instance Mutexes. If you need true parallelism with Ractors, perform computation inside the Ractor without memoization and send frozen results back via `Ractor#send`.
1158
+ **Workaround for shared caches:** use `memoize :method, shared: true, ractor_safe: true` (see [Ractor-safe shared cache](#ractor-safe-shared-cache) above).
907
1159
 
908
- Ractor support is tracked in the v1.0.0 roadmap. The fix would require replacing closed-over variables with frozen shareable bindings and making `Configuration` a frozen value object, which is a significant redesign.
1160
+ **Workaround for per-instance caches:** Use Ruby Threads instead of Ractors SafeMemoize is fully thread-safe via double-check locking and per-instance Mutexes. If you need true parallelism with Ractors, perform computation inside the Ractor without memoization and send frozen results back via `Ractor#send`.
909
1161
 
910
1162
  ## Development
911
1163
 
@@ -982,6 +1234,9 @@ Anything **not** listed here — internal modules, private methods, `@__safe_mem
982
1234
  | `unless:` | `Symbol \| Proc \| nil` | `nil` | Store only when falsy |
983
1235
  | `shared:` | `Boolean` | `false` | Class-level shared cache |
984
1236
  | `key:` | `Proc \| nil` | `nil` | Class-level custom key generator |
1237
+ | `store:` | `Stores::Base \| nil` | `nil` | External cache store adapter; incompatible with `max_size:` and `shared:` |
1238
+ | `fiber_local:` | `Boolean` | `false` | Fiber-local cache; each fiber gets an isolated store; incompatible with `shared:` and `store:` |
1239
+ | `ractor_safe:` | `Boolean` | `false` | Supervisor-Ractor shared cache; replaces the `Mutex`; worker Ractors can call the method; requires `shared: true`; cached values are deep-frozen; incompatible with `if:`, `unless:`, `max_size:`, `ttl_refresh:`, `key:`, and `store:` |
985
1240
 
986
1241
  ### `memoize_all` options (class method)
987
1242
 
@@ -1055,6 +1310,14 @@ All `memoize` option keys above, plus:
1055
1310
  | `memoize_with_custom_key(method_name) { \|*args, **kwargs\| … }` | Instance-level key generator |
1056
1311
  | `clear_custom_keys(method_name = nil)` | Remove one or all key generators |
1057
1312
 
1313
+ **Fiber-local cache (when any method uses `fiber_local: true`)**
1314
+
1315
+ | Method | Returns |
1316
+ |---|---|
1317
+ | `fiber_local_memoized?(method_name, *args, **kwargs)` | `Boolean` — cached in the current fiber? |
1318
+ | `reset_fiber_memo(method_name, *args, **kwargs)` | `nil` — clear one or all entries in current fiber |
1319
+ | `reset_all_fiber_memos` | `nil` — clear all fiber-local entries for this instance |
1320
+
1058
1321
  ### Shared-cache class methods (added when any method uses `shared: true`)
1059
1322
 
1060
1323
  | Method | Returns |
@@ -1066,6 +1329,15 @@ All `memoize` option keys above, plus:
1066
1329
  | `shared_memo_age(method_name, *args, **kwargs)` | `Numeric \| nil` |
1067
1330
  | `shared_memo_stale?(method_name, *args, **kwargs)` | `Boolean` |
1068
1331
 
1332
+ **Ractor-safe shared cache (added when any method uses `ractor_safe: true`)**
1333
+
1334
+ | Method | Returns |
1335
+ |---|---|
1336
+ | `reset_ractor_memo(method_name, *args, **kwargs)` | `nil` — clear one or all entries |
1337
+ | `reset_all_ractor_memos` | `nil` — clear the entire Ractor-safe shared cache |
1338
+ | `ractor_memoized?(method_name, *args, **kwargs)` | `Boolean` — live entry exists? |
1339
+ | `ractor_memo_count(method_name = nil)` | `Integer` — live entry count |
1340
+
1069
1341
  ### `SafeMemoize::Configuration` attributes
1070
1342
 
1071
1343
  | Attribute | Type | Default |
@@ -1077,10 +1349,20 @@ All `memoize` option keys above, plus:
1077
1349
  | `active_support_notifications` | `Boolean` | `false` |
1078
1350
  | `statsd_client` | `Object \| nil` | `nil` |
1079
1351
  | `opentelemetry_tracer` | `Object \| nil` | `nil` |
1352
+ | `default_store` | `Stores::Base \| nil` | `nil` |
1353
+
1354
+ ### Store adapter classes (v1.1.0+)
1355
+
1356
+ | Class | Require | Notes |
1357
+ |---|---|---|
1358
+ | `SafeMemoize::Stores::Base` | auto | Abstract base — subclass to build custom adapters; exposes `MISS` sentinel |
1359
+ | `SafeMemoize::Stores::Memory` | auto | Built-in in-process store; reference implementation |
1360
+ | `SafeMemoize::Stores::Redis` | `"safe_memoize/stores/redis"` | Redis-backed adapter; Marshal serialization; `PX` TTL |
1361
+ | `SafeMemoize::Stores::RailsCache` | `"safe_memoize/stores/rails_cache"` | `ActiveSupport::Cache::Store` wrapper |
1080
1362
 
1081
1363
  ### Opt-in extensions (not guaranteed until their owning milestone ships)
1082
1364
 
1083
- The following are available now but reside under `require "safe_memoize/rails"` and are not covered by the v1.0.0 semver guarantee until the v1.x milestone that owns them is declared stable:
1365
+ The following are available now but reside under `require "safe_memoize/rails"` and are not covered by the semver guarantee until the v1.x milestone that owns them is declared stable:
1084
1366
 
1085
1367
  - `SafeMemoize::Rails` module (`track`, `reset_tracked!`)
1086
1368
  - `SafeMemoize::Rails::RequestScoped` concern
data/ROADMAP.md CHANGED
@@ -4,32 +4,6 @@ This document tracks the planned evolution of SafeMemoize through v1.0.0 and bey
4
4
 
5
5
  ---
6
6
 
7
- ## v1.1.0 — Pluggable Cache Stores
8
-
9
- *Goal: allow the in-process hash cache to be swapped for an external store, enabling cross-process and distributed memoization.*
10
-
11
- | Feature | Description | Status |
12
- |---|---|---|
13
- | Cache store adapter interface | Define a minimal read/write/delete/clear/keys contract that external backends must implement | Shipped |
14
- | `store:` option on `memoize` | Accept any store adapter object; defaults to the existing in-process hash store | Shipped |
15
- | Redis adapter | Reference implementation (`SafeMemoize::Stores::Redis`) with TTL, LRU-like expiry, and serialization handled transparently | Shipped |
16
- | Rails.cache adapter | Thin wrapper around `ActiveSupport::Cache::Store` for projects already using a configured Rails cache | Shipped |
17
- | Global default store | Set via `SafeMemoize.configure` — applies a default store to every memoized method without per-call configuration | Shipped |
18
-
19
- ---
20
-
21
- ## v1.2.0 — Async & Fiber-Safe Memoization
22
-
23
- *Goal: first-class support for Fiber-based concurrency frameworks (Async, Falcon, Rails async controllers).*
24
-
25
- | Feature | Description | Status |
26
- |---|---|---|
27
- | Fiber-local memoization mode | `memoize :method, fiber_local: true` stores results in `Fiber[:safe_memoize_cache]` rather than instance variables, giving each fiber its own isolated cache automatically reset when the fiber terminates | Planned |
28
- | Ractor-compatible shared cache | Revisit `shared: true` using `Ractor::TVar` or shareable frozen objects so class-level caches work across Ractors | Planned |
29
- | concurrent-ruby integration | Optional adapter using `Concurrent::Map` and `Concurrent::ReentrantReadWriteLock` as a drop-in replacement for `Mutex` where higher read-concurrency is desirable | Planned |
30
-
31
- ---
32
-
33
7
  ## v2.0.0 — Next Generation (Long Horizon)
34
8
 
35
9
  *Goal: incorporate real-world usage feedback, clean up accumulated API surface, and open a path for advanced extension.*
data/Rakefile CHANGED
@@ -4,12 +4,16 @@ require "bundler/gem_tasks"
4
4
  require "rspec/core/rake_task"
5
5
 
6
6
  RSpec::Core::RakeTask.new(:spec) do |t|
7
- # Run store specs first: Ruby Coverage counters for opt-in adapters
8
- # (redis, rails_cache) must be exercised before Ractor/concurrency tests
9
- # run, which can disrupt coverage tracking in certain Ruby 3.4 builds.
7
+ # Ordering ensures accurate coverage tracking in Ruby 3.4:
8
+ # 1. Store specs first: opt-in adapter lines must be hit before Ractor tests
9
+ # can disrupt Ruby's Coverage counters.
10
+ # 2. Ractor specs last: Ractor-based tests spin up background Ractors whose
11
+ # internal threads can cause later coverage samples to be missed if they
12
+ # interleave with SimpleCov's collection phase.
10
13
  store_specs = Dir["spec/stores/**/*_spec.rb"].sort
11
- other_specs = Dir["spec/**/*_spec.rb"].sort - store_specs
12
- t.rspec_opts = (store_specs + other_specs).join(" ")
14
+ ractor_specs = Dir["spec/ractor*_spec.rb"].sort
15
+ other_specs = Dir["spec/**/*_spec.rb"].sort - store_specs - ractor_specs
16
+ t.rspec_opts = (store_specs + other_specs + ractor_specs).join(" ")
13
17
  t.pattern = "non_existent_placeholder" # overridden by rspec_opts file args
14
18
  end
15
19
 
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeMemoize
4
+ module Adapters
5
+ # Optional store adapter backed by +concurrent-ruby+.
6
+ #
7
+ # Replaces the default +Mutex+-guarded +Hash+ with +Concurrent::Map+ and
8
+ # +Concurrent::ReentrantReadWriteLock+. Multiple readers proceed in parallel;
9
+ # writers still get exclusive access. For hot paths with many concurrent readers
10
+ # this can meaningfully reduce lock contention compared to {Stores::Memory}.
11
+ #
12
+ # Opt in per class:
13
+ #
14
+ # class MyService
15
+ # prepend SafeMemoize
16
+ # self.safe_memoize_store = SafeMemoize::Adapters::ConcurrentRuby.new
17
+ # end
18
+ #
19
+ # Or globally via {SafeMemoize.configure}:
20
+ #
21
+ # SafeMemoize.configure do |c|
22
+ # c.default_store = SafeMemoize::Adapters::ConcurrentRuby.new
23
+ # end
24
+ #
25
+ # Requires the +concurrent-ruby+ gem, which is *not* a runtime dependency of
26
+ # +safe_memoize+. Add it to your own Gemfile or gemspec:
27
+ #
28
+ # gem "concurrent-ruby"
29
+ #
30
+ # A {LoadError} with an actionable message is raised at instantiation time if
31
+ # the gem is not available.
32
+ class ConcurrentRuby < Stores::Base
33
+ def initialize
34
+ require "concurrent/map"
35
+ require "concurrent/atomic/reentrant_read_write_lock"
36
+ @data = Concurrent::Map.new
37
+ @lock = Concurrent::ReentrantReadWriteLock.new
38
+ rescue LoadError
39
+ raise LoadError,
40
+ "SafeMemoize::Adapters::ConcurrentRuby requires the concurrent-ruby gem. " \
41
+ "Add `gem 'concurrent-ruby'` to your Gemfile."
42
+ end
43
+
44
+ # @param key [Object]
45
+ # @return [Object] the stored value, or {MISS} if absent or expired
46
+ def read(key)
47
+ @lock.with_read_lock do
48
+ entry = @data[key]
49
+ return MISS unless entry
50
+ return MISS if expired?(entry)
51
+
52
+ entry[:value]
53
+ end
54
+ end
55
+
56
+ # @param key [Object]
57
+ # @param value [Object]
58
+ # @param expires_in [Numeric, nil] seconds until expiry; +nil+ means no expiry
59
+ # @return [void]
60
+ def write(key, value, expires_in: nil)
61
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
62
+ expires_at = expires_in ? now + expires_in.to_f : nil
63
+ @lock.with_write_lock do
64
+ @data[key] = {value: value, expires_at: expires_at, cached_at: now}
65
+ end
66
+ end
67
+
68
+ # @param key [Object]
69
+ # @return [void]
70
+ def delete(key)
71
+ @lock.with_write_lock { @data.delete(key) }
72
+ end
73
+
74
+ # @return [void]
75
+ def clear
76
+ @lock.with_write_lock { @data.clear }
77
+ end
78
+
79
+ # Returns all live (non-expired) keys.
80
+ # @return [Array<Object>]
81
+ def keys
82
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
83
+ @lock.with_read_lock do
84
+ result = []
85
+ @data.each_pair { |k, entry| result << k unless entry[:expires_at] && entry[:expires_at] <= now }
86
+ result
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def expired?(entry)
93
+ expires_at = entry[:expires_at]
94
+ expires_at && expires_at <= Process.clock_gettime(Process::CLOCK_MONOTONIC)
95
+ end
96
+ end
97
+ end
98
+ end
@@ -29,6 +29,15 @@ module SafeMemoize
29
29
  # {Stores::Base} subclass instance. The store is shared across all instances of the
30
30
  # class. When +nil+, the default per-instance in-process hash is used.
31
31
  # Cannot be combined with +max_size:+ or +shared:+.
32
+ # @param fiber_local [Boolean] when +true+, results are stored in
33
+ # +Fiber[:__safe_memoize__]+ rather than instance variables. Each fiber gets its
34
+ # own isolated cache that is automatically discarded when the fiber terminates. No
35
+ # mutex is acquired. Cannot be combined with +shared:+ or +store:+.
36
+ # @param ractor_safe [Boolean] when +true+, the class-level shared cache is owned by
37
+ # a supervisor +Ractor+ rather than a +Mutex+-protected ivar, making it accessible
38
+ # from worker Ractors. Requires +shared: true+. Cached values are deep-frozen via
39
+ # +Ractor.make_shareable+. Incompatible with +if:+, +unless:+, +max_size:+,
40
+ # +ttl_refresh:+, +key:+, and +store:+.
32
41
  # @return [void]
33
42
  # @raise [ArgumentError] if the method does not exist, or option values are invalid
34
43
  #
@@ -46,7 +55,7 @@ module SafeMemoize
46
55
  # @example With a custom store
47
56
  # STORE = SafeMemoize::Stores::Memory.new
48
57
  # memoize :fetch, store: STORE, ttl: 300
49
- def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, unless: nil, shared: false, key: nil, store: nil)
58
+ def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, unless: nil, shared: false, key: nil, store: nil, fiber_local: false, ractor_safe: false)
50
59
  method_name = method_name.to_sym
51
60
 
52
61
  unless method_defined?(method_name) || private_method_defined?(method_name) || protected_method_defined?(method_name)
@@ -99,17 +108,37 @@ module SafeMemoize
99
108
  raise ArgumentError, "shared: and store: cannot be combined" if shared
100
109
  end
101
110
 
102
- # Resolve effective store: explicit store: wins; global default applies when
103
- # compatible (max_size: and shared: are incompatible fall back silently).
111
+ if fiber_local
112
+ raise ArgumentError, "fiber_local: and shared: cannot be combined" if shared
113
+ raise ArgumentError, "fiber_local: and store: cannot be combined" if store
114
+ end
115
+
116
+ if ractor_safe
117
+ raise ArgumentError, "ractor_safe: requires shared: true" unless shared
118
+ raise ArgumentError, "ractor_safe: is incompatible with if:/unless:" if cond_if || cond_unless
119
+ raise ArgumentError, "ractor_safe: is incompatible with max_size:" if max_size
120
+ raise ArgumentError, "ractor_safe: is incompatible with ttl_refresh:" if ttl_refresh
121
+ raise ArgumentError, "ractor_safe: is incompatible with key:" if key
122
+ raise ArgumentError, "ractor_safe: is incompatible with store:" if store
123
+ end
124
+
125
+ # Resolve effective store: per-method store: wins; then class-level
126
+ # safe_memoize_store; then global default_store. max_size: and shared:
127
+ # are incompatible with external stores — fall back silently.
104
128
  effective_store = store
105
129
  if effective_store.nil? && !max_size && !shared
106
- global_default = SafeMemoize.configuration.default_store
107
- if global_default
108
- unless global_default.is_a?(SafeMemoize::Stores::Base)
109
- raise ArgumentError,
110
- "SafeMemoize.configuration.default_store must be a Stores::Base instance (got #{global_default.class})"
130
+ class_store = safe_memoize_store
131
+ if class_store
132
+ effective_store = class_store
133
+ else
134
+ global_default = SafeMemoize.configuration.default_store
135
+ if global_default
136
+ unless global_default.is_a?(SafeMemoize::Stores::Base)
137
+ raise ArgumentError,
138
+ "SafeMemoize.configuration.default_store must be a Stores::Base instance (got #{global_default.class})"
139
+ end
140
+ effective_store = global_default
111
141
  end
112
- effective_store = global_default
113
142
  end
114
143
  end
115
144
 
@@ -165,6 +194,77 @@ module SafeMemoize
165
194
  return
166
195
  end
167
196
 
197
+ if fiber_local
198
+ mod = Module.new do
199
+ define_method(method_name) do |*args, **kwargs, &block|
200
+ return super(*args, **kwargs, &block) if block
201
+
202
+ cache_key = compute_cache_key(method_name, args, kwargs)
203
+ fiber_cache = fiber_memo_cache!
204
+ record = fiber_cache[cache_key]
205
+
206
+ if memo_record_live?(record)
207
+ if max_size
208
+ lru = fiber_memo_lru![method_name] ||= {}
209
+ lru.delete(cache_key)
210
+ lru[cache_key] = true
211
+ end
212
+ record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
213
+ record_cache_hit(method_name, args, kwargs)
214
+ call_memo_hooks(:on_hit, cache_key, record)
215
+ memo_record_value(record)
216
+ else
217
+ call_memo_hooks(:on_expire, cache_key, record) if record
218
+
219
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
220
+ value = Adapters::OpenTelemetry.trace(
221
+ SafeMemoize.configuration.opentelemetry_tracer, method_name, self.class.name
222
+ ) { super(*args, **kwargs) }
223
+ elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
224
+
225
+ new_record = memo_record(value, expires_at: memo_expires_at(ttl))
226
+
227
+ if !condition || condition.call(value)
228
+ if max_size
229
+ lru = fiber_memo_lru![method_name] ||= {}
230
+ if lru.size >= max_size
231
+ evict_key = lru.keys.first
232
+ lru.delete(evict_key)
233
+ evicted = fiber_cache.delete(evict_key)
234
+ call_memo_hooks(:on_evict, evict_key, evicted) if evicted
235
+ end
236
+ end
237
+ fiber_cache[cache_key] = new_record
238
+ if max_size
239
+ lru = fiber_memo_lru![method_name] ||= {}
240
+ lru.delete(cache_key)
241
+ lru[cache_key] = true
242
+ end
243
+ call_memo_hooks(:on_store, cache_key, new_record)
244
+ end
245
+
246
+ record_cache_miss(method_name, args, kwargs, elapsed_time)
247
+ call_memo_hooks(:on_miss, cache_key, new_record)
248
+
249
+ value
250
+ end
251
+ end
252
+
253
+ send(visibility, method_name)
254
+ end
255
+
256
+ prepend mod
257
+
258
+ return
259
+ end
260
+
261
+ if ractor_safe
262
+ extend(RactorSharedMethods) unless is_a?(RactorSharedMethods)
263
+ supervisor = __safe_memo_ractor_supervisor__
264
+ __memoize_ractor_safe__(method_name, ttl, visibility, supervisor)
265
+ return
266
+ end
267
+
168
268
  if shared
169
269
  klass = self
170
270
  shared_mutex = klass.send(:__safe_memo_shared_mutex__)
@@ -303,6 +403,32 @@ module SafeMemoize
303
403
  prepend mod
304
404
  end
305
405
 
406
+ # Returns the class-level default cache store, or +nil+ if not set.
407
+ #
408
+ # Set this to any {Stores::Base} instance to route every +memoize+ call on
409
+ # this class through that store, without needing to pass +store:+ to each
410
+ # individual +memoize+ call. A per-method +store:+ option still takes
411
+ # precedence, and the global {SafeMemoize::Configuration#default_store} is
412
+ # the final fallback.
413
+ #
414
+ # @return [Stores::Base, nil]
415
+ def safe_memoize_store
416
+ @__safe_memoize_store__
417
+ end
418
+
419
+ # Sets the class-level default cache store.
420
+ #
421
+ # @param store [Stores::Base, nil] a store instance, or +nil+ to clear
422
+ # @return [Stores::Base, nil]
423
+ # @raise [ArgumentError] if +store+ is not a {Stores::Base} instance (and not +nil+)
424
+ def safe_memoize_store=(store)
425
+ if store && !store.is_a?(SafeMemoize::Stores::Base)
426
+ raise ArgumentError,
427
+ "safe_memoize_store= must be a SafeMemoize::Stores::Base instance (got #{store.class})"
428
+ end
429
+ @__safe_memoize_store__ = store
430
+ end
431
+
306
432
  # Memoizes every eligible public instance method defined directly on the class.
307
433
  #
308
434
  # Accepts all options that {#memoize} accepts, plus +:except:+ and +:only:+.
@@ -477,5 +603,67 @@ module SafeMemoize
477
603
 
478
604
  :public
479
605
  end
606
+
607
+ # Builds and prepends the ractor_safe memoize wrapper in its own method so
608
+ # the Proc only closes over the four Ractor-shareable locals (method_name,
609
+ # ttl, visibility, supervisor) rather than the full memoize binding, which
610
+ # contains non-shareable objects like SafeMemoize.configuration.
611
+ #
612
+ # The Proc is created inside module_eval so its self is the anonymous
613
+ # module (a shareable object), then frozen via Ractor.make_shareable before
614
+ # being passed to define_method. Without that step, ANY define_method Proc
615
+ # is considered non-shareable by Ruby 3.x even when it captures nothing.
616
+ def __memoize_ractor_safe__(method_name, ttl, visibility, supervisor)
617
+ mod = Module.new
618
+ wrapper = mod.module_eval do
619
+ Ractor.make_shareable(
620
+ proc do |*args, **kwargs, &block|
621
+ return super(*args, **kwargs, &block) if block
622
+
623
+ cache_key = Ractor.make_shareable([method_name, deep_freeze_copy(args), deep_freeze_copy(kwargs)])
624
+
625
+ tag = Thread.current.object_id
626
+ supervisor.send(Ractor.make_shareable([Ractor.current, tag, :fetch, cache_key]))
627
+ response = Ractor.receive_if { |m| m.is_a?(Array) && m[0] == tag }[1]
628
+
629
+ if response[:hit]
630
+ record_cache_hit(method_name, args, kwargs)
631
+ call_memo_hooks(:on_hit, cache_key, response[:record])
632
+ return response[:record][:value]
633
+ end
634
+
635
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
636
+ value = super(*args, **kwargs)
637
+ elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
638
+
639
+ begin
640
+ shareable_value = Ractor.make_shareable(value)
641
+ rescue => e
642
+ raise ArgumentError, "ractor_safe: memoized values must be Ractor-shareable (#{e.message})"
643
+ end
644
+
645
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
646
+ record = Ractor.make_shareable({
647
+ value: shareable_value,
648
+ expires_at: ttl ? now + ttl : nil,
649
+ cached_at: now
650
+ })
651
+
652
+ supervisor.send(Ractor.make_shareable([Ractor.current, tag, :store, cache_key, record]))
653
+ stored = Ractor.receive_if { |m| m.is_a?(Array) && m[0] == tag }[1]
654
+ stored_record = stored[:stored]
655
+
656
+ record_cache_miss(method_name, args, kwargs, elapsed_time)
657
+ call_memo_hooks(:on_store, cache_key, stored_record)
658
+ call_memo_hooks(:on_miss, cache_key, stored_record)
659
+
660
+ stored_record[:value]
661
+ end
662
+ )
663
+ end
664
+ mod.define_method(method_name, wrapper)
665
+ mod.send(visibility, method_name)
666
+ prepend mod
667
+ end
480
668
  end
481
669
  end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeMemoize
4
+ # Public and private helpers for fiber-local memoization.
5
+ #
6
+ # When a method is memoized with +fiber_local: true+, its cached values are
7
+ # stored in +Fiber[:__safe_memoize__]+ rather than instance variables, giving
8
+ # each fiber an isolated cache that is automatically discarded when the fiber
9
+ # terminates. No mutex is required because fibers are cooperative: only one
10
+ # fiber runs at a time within a thread.
11
+ module FiberLocalMethods
12
+ FIBER_STORE_KEY = :__safe_memoize__
13
+
14
+ # Returns +true+ if the given call is currently cached in the current fiber.
15
+ #
16
+ # @note In Ruby, +Fiber.new+ inherits the parent fiber's local storage. SafeMemoize
17
+ # detects inherited stores via an +:__owner__+ sentinel and creates a fresh
18
+ # isolated store for each fiber on first write.
19
+ #
20
+ # @param method_name [Symbol, String]
21
+ # @param args [Array]
22
+ # @param kwargs [Hash]
23
+ # @return [Boolean]
24
+ def fiber_local_memoized?(method_name, *args, **kwargs, &block)
25
+ return false if block
26
+
27
+ method_name = method_name.to_sym
28
+ cache_key = compute_cache_key(method_name, args, kwargs)
29
+ record = fiber_memo_cache_or_nil&.[](cache_key)
30
+ memo_record_live?(record)
31
+ end
32
+
33
+ # Removes one or all fiber-local cached entries for a method in the current fiber.
34
+ #
35
+ # @param method_name [Symbol, String]
36
+ # @param args [Array]
37
+ # @param kwargs [Hash]
38
+ # @return [void]
39
+ def reset_fiber_memo(method_name, *args, **kwargs)
40
+ method_name = method_name.to_sym
41
+ cache = fiber_memo_cache_or_nil
42
+ return unless cache
43
+
44
+ if args.empty? && kwargs.empty?
45
+ cache.delete_if { |key, _| key[0] == method_name }
46
+ fiber_memo_lru_or_nil&.delete(method_name)
47
+ else
48
+ cache_key = compute_cache_key(method_name, args, kwargs)
49
+ cache.delete(cache_key)
50
+ fiber_memo_lru_or_nil&.[](method_name)&.delete(cache_key)
51
+ end
52
+ end
53
+
54
+ # Clears all fiber-local cached entries for this instance in the current fiber.
55
+ #
56
+ # @return [void]
57
+ def reset_all_fiber_memos
58
+ store = Fiber[FIBER_STORE_KEY]
59
+ return unless store&.[](:__owner__) == Fiber.current.object_id
60
+
61
+ store.delete(object_id)
62
+ end
63
+
64
+ private
65
+
66
+ # Returns the per-fiber top-level store hash, creating a fresh one for
67
+ # this fiber if the current store was inherited from a parent fiber.
68
+ def fiber_root_store!
69
+ store = Fiber[FIBER_STORE_KEY]
70
+ unless store&.[](:__owner__) == Fiber.current.object_id
71
+ store = {__owner__: Fiber.current.object_id}
72
+ Fiber[FIBER_STORE_KEY] = store
73
+ end
74
+ store
75
+ end
76
+
77
+ def fiber_memo_store!
78
+ fiber_root_store![object_id] ||= {cache: {}, lru: {}}
79
+ end
80
+
81
+ def fiber_memo_cache!
82
+ fiber_memo_store![:cache]
83
+ end
84
+
85
+ def fiber_memo_lru!
86
+ fiber_memo_store![:lru]
87
+ end
88
+
89
+ def fiber_root_store_or_nil
90
+ store = Fiber[FIBER_STORE_KEY]
91
+ return nil unless store&.[](:__owner__) == Fiber.current.object_id
92
+
93
+ store
94
+ end
95
+
96
+ def fiber_memo_store_or_nil
97
+ fiber_root_store_or_nil&.[](object_id)
98
+ end
99
+
100
+ def fiber_memo_cache_or_nil
101
+ fiber_memo_store_or_nil&.[](:cache)
102
+ end
103
+
104
+ def fiber_memo_lru_or_nil
105
+ fiber_memo_store_or_nil&.[](:lru)
106
+ end
107
+ end
108
+ end
@@ -31,7 +31,8 @@ module SafeMemoize
31
31
  hooks.each do |hook|
32
32
  hook.call(cache_key, record)
33
33
  rescue => error
34
- handler = SafeMemoize.configuration.on_hook_error
34
+ # SafeMemoize.configuration is not accessible from non-main Ractors
35
+ handler = (Ractor.current == Ractor.main) ? SafeMemoize.configuration.on_hook_error : nil
35
36
  if handler
36
37
  handler.call(error, hook_type, cache_key)
37
38
  else
@@ -39,6 +40,10 @@ module SafeMemoize
39
40
  end
40
41
  end
41
42
 
43
+ # ActiveSupport::Notifications and StatsD integration require main-Ractor
44
+ # configuration access; skip them from worker Ractors.
45
+ return if Ractor.current != Ractor.main
46
+
42
47
  safe_memo_notify(hook_type, cache_key) if SafeMemoize.configuration.active_support_notifications
43
48
 
44
49
  if (client = SafeMemoize.configuration.statsd_client)
@@ -13,5 +13,6 @@ module SafeMemoize
13
13
  include CustomKeyMethods
14
14
  include PublicCustomKeyMethods
15
15
  include LruMethods
16
+ include FiberLocalMethods
16
17
  end
17
18
  end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeMemoize
4
+ # Class-level methods for Ractor-safe shared caching.
5
+ #
6
+ # Mixed into a class (via ClassMethods) when any method is memoized with
7
+ # +shared: true, ractor_safe: true+. The class owns a supervisor +Ractor+ that
8
+ # holds the mutable cache hash. All cache reads and writes are serialized through
9
+ # the supervisor's message loop, removing the need for a +Mutex+ (which is not
10
+ # Ractor-shareable).
11
+ #
12
+ # Constraints for +ractor_safe: true+ memoization:
13
+ # - Cached return values are made Ractor-shareable via +Ractor.make_shareable+
14
+ # (deep-frozen in place). Ensure return values can be frozen.
15
+ # - +if:+, +unless:+, +max_size:+, +ttl_refresh:+, +key:+, and +store:+ are
16
+ # incompatible and raise +ArgumentError+ at +memoize+ time.
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.
20
+ module RactorSharedMethods
21
+ # Clears one or all entries from the Ractor-safe shared cache.
22
+ #
23
+ # @param method_name [Symbol, String]
24
+ # @param args [Array] positional args identifying a specific entry; omit to clear all
25
+ # @param kwargs [Hash]
26
+ # @return [void]
27
+ def reset_ractor_memo(method_name, *args, **kwargs)
28
+ method_name = method_name.to_sym
29
+ sup = @__safe_memo_ractor_supervisor__
30
+ return unless sup
31
+
32
+ if args.empty? && kwargs.empty?
33
+ __ractor_cache_send__(sup, :delete_all, method_name)
34
+ else
35
+ key = Ractor.make_shareable([method_name, args.freeze, kwargs.freeze])
36
+ __ractor_cache_send__(sup, :delete_one, key)
37
+ end
38
+ end
39
+
40
+ # Clears the entire Ractor-safe shared cache for this class.
41
+ # @return [void]
42
+ def reset_all_ractor_memos
43
+ sup = @__safe_memo_ractor_supervisor__
44
+ return unless sup
45
+
46
+ __ractor_cache_send__(sup, :clear)
47
+ end
48
+
49
+ # Returns +true+ if a live entry exists in the Ractor-safe shared cache.
50
+ #
51
+ # @param method_name [Symbol, String]
52
+ # @param args [Array]
53
+ # @param kwargs [Hash]
54
+ # @return [Boolean]
55
+ def ractor_memoized?(method_name, *args, **kwargs)
56
+ method_name = method_name.to_sym
57
+ sup = @__safe_memo_ractor_supervisor__
58
+ return false unless sup
59
+
60
+ key = Ractor.make_shareable([method_name, args.freeze, kwargs.freeze])
61
+ __ractor_cache_send__(sup, :memoized, key)
62
+ end
63
+
64
+ # Returns the number of live entries in the Ractor-safe shared cache.
65
+ #
66
+ # @param method_name [Symbol, String, nil] when given, counts only entries for
67
+ # that method; when +nil+, counts all.
68
+ # @return [Integer]
69
+ def ractor_memo_count(method_name = nil)
70
+ sup = @__safe_memo_ractor_supervisor__
71
+ return 0 unless sup
72
+
73
+ __ractor_cache_send__(sup, :count, method_name&.to_sym)
74
+ end
75
+
76
+ private
77
+
78
+ # 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
+ 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]
86
+ end
87
+
88
+ # Creates the supervisor Ractor that owns this class's Ractor-safe shared cache.
89
+ # Must be called from the main Ractor at class-definition time.
90
+ def __safe_memo_ractor_supervisor__
91
+ # :nocov:
92
+ @__safe_memo_ractor_supervisor__ ||= Ractor.new do
93
+ cache = {}
94
+
95
+ loop do
96
+ caller_ractor, tag, op, *args = Ractor.receive
97
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
98
+
99
+ result = case op
100
+ when :fetch
101
+ key = args[0]
102
+ record = cache[key]
103
+ live = record && (record[:expires_at].nil? || record[:expires_at] > now)
104
+ live ? {hit: true, record: record} : {hit: false, record: nil}
105
+
106
+ when :store
107
+ key, new_record = args
108
+ existing = cache[key]
109
+ live = existing && (existing[:expires_at].nil? || existing[:expires_at] > now)
110
+ cache[key] = new_record unless live
111
+ {stored: live ? existing : new_record}
112
+
113
+ when :delete_all
114
+ method_name = args[0]
115
+ cache.delete_if { |k, _| k[0] == method_name }
116
+ :ok
117
+
118
+ when :delete_one
119
+ cache.delete(args[0])
120
+ :ok
121
+
122
+ when :clear
123
+ cache.clear
124
+ :ok
125
+
126
+ when :memoized
127
+ key = args[0]
128
+ record = cache[key]
129
+ !!(record && (record[:expires_at].nil? || record[:expires_at] > now))
130
+
131
+ when :count
132
+ method_name = args[0]
133
+ cache.count do |k, r|
134
+ next false if r[:expires_at] && r[:expires_at] <= now
135
+ method_name.nil? || k[0] == method_name
136
+ end
137
+ end
138
+
139
+ response = Ractor.make_shareable([tag, result])
140
+ caller_ractor.send(response)
141
+ end
142
+ end
143
+ # :nocov:
144
+ end
145
+ end
146
+ end
@@ -46,6 +46,31 @@ module SafeMemoize
46
46
  contents.sub(UNRELEASED_HEADING, "#{UNRELEASED_HEADING}\n\n#{release_heading}")
47
47
  end
48
48
 
49
+ # Removes milestone sections from ROADMAP.md where every feature row has
50
+ # "Shipped" status. Non-milestone sections (Versioning policy, Contributing,
51
+ # etc.) and sections with any non-Shipped row are left untouched.
52
+ #
53
+ # Sections are delimited by the +\n\n---\n\n+ horizontal-rule separator that
54
+ # the ROADMAP uses between headings. A milestone section is any section whose
55
+ # first non-blank line starts with +## v+.
56
+ def prune_roadmap(contents)
57
+ separator = "\n\n---\n\n"
58
+ sections = contents.split(separator)
59
+
60
+ pruned = sections.reject do |section|
61
+ next false unless section.lstrip.start_with?("## v")
62
+
63
+ # Table rows: lines starting with "|"; drop alignment rows (only |, -, :, whitespace)
64
+ rows = section.lines.select { |l| l.strip.start_with?("|") }
65
+ rows = rows.reject { |l| l.match?(/\A[\s|:-]+\z/) }
66
+ data_rows = rows.drop(1) # first row is the header
67
+
68
+ data_rows.any? && data_rows.all? { |row| row.strip.end_with?("Shipped |") }
69
+ end
70
+
71
+ pruned.join(separator)
72
+ end
73
+
49
74
  def extract_release_notes(contents, version)
50
75
  normalized_version = normalize_version(version)
51
76
  lines = contents.lines
@@ -2,5 +2,5 @@
2
2
 
3
3
  module SafeMemoize
4
4
  # The current gem version string.
5
- VERSION = "1.1.0"
5
+ VERSION = "1.2.0"
6
6
  end
data/lib/safe_memoize.rb CHANGED
@@ -6,6 +6,7 @@ require_relative "safe_memoize/stores/base"
6
6
  require_relative "safe_memoize/stores/memory"
7
7
  require_relative "safe_memoize/adapters/statsd"
8
8
  require_relative "safe_memoize/adapters/opentelemetry"
9
+ require_relative "safe_memoize/adapters/concurrent_ruby"
9
10
  require_relative "safe_memoize/class_methods"
10
11
  require_relative "safe_memoize/public_methods"
11
12
  require_relative "safe_memoize/cache_store_methods"
@@ -17,6 +18,8 @@ require_relative "safe_memoize/public_metrics_methods"
17
18
  require_relative "safe_memoize/custom_key_methods"
18
19
  require_relative "safe_memoize/public_custom_key_methods"
19
20
  require_relative "safe_memoize/lru_methods"
21
+ require_relative "safe_memoize/fiber_local_methods"
22
+ require_relative "safe_memoize/ractor_shared_methods"
20
23
  require_relative "safe_memoize/instance_methods"
21
24
 
22
25
  # Thread-safe memoization for Ruby that correctly handles +nil+ and +false+ values.
data/sig/safe_memoize.rbs CHANGED
@@ -38,8 +38,10 @@ module SafeMemoize
38
38
  end
39
39
 
40
40
  module ClassMethods
41
- 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?) -> void
42
- def memoize_all: (?except: Array[Symbol | String], ?only: Array[Symbol | String], ?include_protected: bool, ?include_private: bool, ?ttl: Numeric?, ?max_size: Integer?, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool, ?key: (^(*untyped args, **untyped kwargs) -> untyped)?) -> void
41
+ 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) -> void
42
+ def safe_memoize_store: () -> Stores::Base?
43
+ def safe_memoize_store=: (Stores::Base?) -> Stores::Base?
44
+ def memoize_all: (?except: Array[Symbol | String], ?only: Array[Symbol | String], ?include_protected: bool, ?include_private: bool, ?ttl: Numeric?, ?max_size: Integer?, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool, ?key: (^(*untyped args, **untyped kwargs) -> untyped)?, ?fiber_local: bool) -> void
43
45
  def reset_shared_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
44
46
  def reset_all_shared_memos: () -> void
45
47
  def shared_memoized?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool
@@ -187,6 +189,37 @@ module SafeMemoize
187
189
  def lru_clear_all: () -> void
188
190
  end
189
191
 
192
+ module FiberLocalMethods
193
+ FIBER_STORE_KEY: Symbol
194
+
195
+ def fiber_local_memoized?: (Symbol | String method_name, *untyped args, **untyped kwargs) ?{ () -> untyped } -> bool
196
+ def reset_fiber_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
197
+ def reset_all_fiber_memos: () -> void
198
+
199
+ private
200
+
201
+ def fiber_root_store!: () -> Hash[untyped, untyped]
202
+ def fiber_memo_store!: () -> { cache: Hash[memo_key, memo_record], lru: Hash[Symbol, Hash[memo_key, true]] }
203
+ def fiber_memo_cache!: () -> Hash[memo_key, memo_record]
204
+ def fiber_memo_lru!: () -> Hash[Symbol, Hash[memo_key, true]]
205
+ def fiber_root_store_or_nil: () -> Hash[untyped, untyped]?
206
+ def fiber_memo_store_or_nil: () -> { cache: Hash[memo_key, memo_record], lru: Hash[Symbol, Hash[memo_key, true]] }?
207
+ def fiber_memo_cache_or_nil: () -> Hash[memo_key, memo_record]?
208
+ def fiber_memo_lru_or_nil: () -> Hash[Symbol, Hash[memo_key, true]]?
209
+ end
210
+
211
+ module RactorSharedMethods
212
+ def reset_ractor_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
213
+ def reset_all_ractor_memos: () -> void
214
+ def ractor_memoized?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool
215
+ def ractor_memo_count: (?(Symbol | String) method_name) -> Integer
216
+
217
+ private
218
+
219
+ def __ractor_cache_send__: (untyped supervisor, Symbol op, *untyped args) -> untyped
220
+ def __safe_memo_ractor_supervisor__: () -> untyped
221
+ end
222
+
190
223
  module InstanceMethods
191
224
  include PublicMethods
192
225
  include CacheStoreMethods
@@ -198,6 +231,7 @@ module SafeMemoize
198
231
  include CustomKeyMethods
199
232
  include PublicCustomKeyMethods
200
233
  include LruMethods
234
+ include FiberLocalMethods
201
235
  end
202
236
 
203
237
  module Stores
@@ -237,6 +271,17 @@ module SafeMemoize
237
271
  SPAN_NAME: String
238
272
  def self.trace: (untyped tracer, Symbol | String method_name, String? class_name) { () -> untyped } -> untyped
239
273
  end
274
+
275
+ class ConcurrentRuby < Stores::Base
276
+ def initialize: () -> void
277
+ def read: (untyped key) -> untyped
278
+ def write: (untyped key, untyped value, ?expires_in: Numeric?) -> void
279
+ def delete: (untyped key) -> void
280
+ def clear: () -> void
281
+ def keys: () -> Array[untyped]
282
+ private
283
+ def expired?: ({ expires_at: Float?, value: untyped, cached_at: Float }) -> bool
284
+ end
240
285
  end
241
286
 
242
287
  module Rails
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.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -52,6 +52,7 @@ files:
52
52
  - benchmarks/benchmark.rb
53
53
  - codecov.yml
54
54
  - lib/safe_memoize.rb
55
+ - lib/safe_memoize/adapters/concurrent_ruby.rb
55
56
  - lib/safe_memoize/adapters/opentelemetry.rb
56
57
  - lib/safe_memoize/adapters/statsd.rb
57
58
  - lib/safe_memoize/cache_metrics_methods.rb
@@ -60,6 +61,7 @@ files:
60
61
  - lib/safe_memoize/class_methods.rb
61
62
  - lib/safe_memoize/configuration.rb
62
63
  - lib/safe_memoize/custom_key_methods.rb
64
+ - lib/safe_memoize/fiber_local_methods.rb
63
65
  - lib/safe_memoize/hooks_methods.rb
64
66
  - lib/safe_memoize/inspection_methods.rb
65
67
  - lib/safe_memoize/instance_methods.rb
@@ -67,6 +69,7 @@ files:
67
69
  - lib/safe_memoize/public_custom_key_methods.rb
68
70
  - lib/safe_memoize/public_methods.rb
69
71
  - lib/safe_memoize/public_metrics_methods.rb
72
+ - lib/safe_memoize/ractor_shared_methods.rb
70
73
  - lib/safe_memoize/rails.rb
71
74
  - lib/safe_memoize/rails/middleware.rb
72
75
  - lib/safe_memoize/rails/request_scoped.rb