safe_memoize 1.0.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: 6beffd3f5a1de6f8582c9a164502f353b8ac6c55caec6326edb4c52b0bf913ec
4
- data.tar.gz: 44ebaf0f89254c692b9d33d8962577c9644df6d910818905e46395ba5e28cf89
3
+ metadata.gz: 24faf0555ba5e11e37092aea14e1db090d7f5bb13dd40edc33ea30d8adcba552
4
+ data.tar.gz: '09beaa5cf9e25284462bdac9636929642213a9e8d657900d30913832da230959'
5
5
  SHA512:
6
- metadata.gz: 0f22cd22816499ec3400a7b98cf51c3a3b77a43a0e26451ff97d6f01877d02df8772b7b3be0209e86b5c6c00d435bc96e7db8fd3a28619169e1f0e97b8ab0263
7
- data.tar.gz: af315893620e46fe419a2a61ff4092f7d27f71bbfe98185ba0714162148c7d19b2d31ed9db3909d500f3e12f474f51d66ec027d81cd2d7e9948e4bc388d3a5db
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,7 +8,48 @@ from v1.0.0 onwards. Prior 0.x releases may include breaking changes between min
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
- ## [1.0.0] - 2026-05-22
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
+
35
+ ## [1.1.0] - 2026-05-22
36
+
37
+ ### Added
38
+
39
+ - `SafeMemoize::Stores::Base` — abstract adapter base class defining the cache store contract: `read(key)`, `write(key, value, expires_in: nil)`, `delete(key)`, `clear`, `keys`, and `exist?(key)`; a frozen `MISS` sentinel on `Base` distinguishes cache misses from cached `nil` or `false` values; `exist?` has a default implementation that delegates to `read`
40
+ - `SafeMemoize::Stores::Memory` — built-in in-process store that wraps a plain `Hash` behind a `Mutex`; supports per-entry TTL via `expires_in:` with lazy expiry on read; serves as both the default store and the reference implementation for custom adapters
41
+ - `Configuration#default_store` — set via `SafeMemoize.configure { |c| c.default_store = MyStore.new }` to route every `memoize` call that has no explicit `store:` through the given adapter; methods using `max_size:` or `shared:` are incompatible and fall back silently to the per-instance hash; an invalid value raises `ArgumentError` at `memoize` time; cleared by `reset_configuration!`
42
+ - `SafeMemoize::Stores::RailsCache` — opt-in adapter (`require "safe_memoize/stores/rails_cache"`) wrapping any `ActiveSupport::Cache::Store` (including `Rails.cache`); values are wrapped in a sentinel envelope so cached `nil`/`false` are distinguished from a cache miss; TTL forwarded as `expires_in:` for native store expiry; `clear` uses `delete_matched` scoped to the namespace; `keys` returns `[]` (AS::Cache has no enumeration API)
43
+ - `SafeMemoize::Stores::Redis` — opt-in adapter (`require "safe_memoize/stores/redis"`) backed by any Redis-compatible client responding to `#get`, `#set`, `#del`, and `#scan_each`; values and keys are serialized with Marshal + `pack("m0")`; TTL is forwarded as `PX` (milliseconds, rounded up) for sub-second precision; `clear` uses `SCAN` to avoid blocking; all entries are namespaced (default: `"safe_memoize"`) so multiple stores or applications can share one Redis instance
44
+ - `store:` option on `memoize` — accepts any `Stores::Base` subclass instance; routes all reads and writes through the adapter's `read`/`write` interface; the store is shared across all instances of the class; `ttl:` is forwarded as `expires_in:` to `write`, `ttl_refresh:` re-writes on every hit, and `if:`/`unless:` conditional storage is enforced at the SafeMemoize layer; raises `ArgumentError` if combined with `max_size:` (LRU belongs in the adapter) or `shared:`
45
+
46
+ ### Changed
47
+
48
+ - Test suite achieves 100% line coverage — `spec_helper` now requires opt-in store adapters (`Stores::Redis`, `Stores::RailsCache`) after `SimpleCov.start` so Coverage tracks them; `Rakefile` runs `spec/stores/` before other specs to prevent Ruby 3.4 Coverage counter disruption from Ractor/concurrency tests; `version.rb` excluded from coverage reporting
49
+ - `store:` type guard in `ClassMethods#memoize` collapsed to an inline guard clause so Ruby's Coverage module counts the raise correctly
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`
51
+
52
+ ## [1.0.0] - 2026-05-22
12
53
 
13
54
  ### Added
14
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,48 +4,6 @@ This document tracks the planned evolution of SafeMemoize through v1.0.0 and bey
4
4
 
5
5
  ---
6
6
 
7
- ## v1.0.0 — Stable API
8
-
9
- *Goal: declare a stable, semver-governed public API that downstream code can depend on with confidence.*
10
-
11
- | Feature | Description | Status |
12
- |---|---|---|
13
- | Semantic versioning guarantee | Document which constants, methods, and option keys are public API; breaking changes require a major bump henceforth | Shipped |
14
- | Complete RBS + Sorbet signatures | Cover all public methods including overloads for optional keyword arguments; publish `.rbi` stubs as a companion package if demand warrants | Shipped |
15
- | Full API reference | Generated documentation hosted on RubyDoc or a dedicated docs site; all public methods documented with parameter types, return values, and usage examples | Shipped |
16
- | Ractor compatibility audit | Investigate and either support Ractor-compatible operation (Mutex replacement, shared-cache storage) or document the limitation clearly | Shipped |
17
- | Ruby version policy | Formalise the supported Ruby version window and cadence for dropping EOL versions | Shipped |
18
- | Deprecation sweep | Resolve or formally deprecate any unstable internal APIs before the stable release | Shipped |
19
- | Upgrade guide | Document all breaking changes from 0.x and provide a migration path for users of deprecated behaviour | Shipped |
20
-
21
- ---
22
-
23
- ## v1.1.0 — Pluggable Cache Stores
24
-
25
- *Goal: allow the in-process hash cache to be swapped for an external store, enabling cross-process and distributed memoization.*
26
-
27
- | Feature | Description | Status |
28
- |---|---|---|
29
- | Cache store adapter interface | Define a minimal read/write/delete/clear/keys contract that external backends must implement | Planned |
30
- | `store:` option on `memoize` | Accept any store adapter object; defaults to the existing in-process hash store | Planned |
31
- | Redis adapter | Reference implementation (`SafeMemoize::Stores::Redis`) with TTL, LRU-like expiry, and serialization handled transparently | Planned |
32
- | Rails.cache adapter | Thin wrapper around `ActiveSupport::Cache::Store` for projects already using a configured Rails cache | Planned |
33
- | Global default store | Set via `SafeMemoize.configure` — applies a default store to every memoized method without per-call configuration | Planned |
34
-
35
- ---
36
-
37
- ## v1.2.0 — Async & Fiber-Safe Memoization
38
-
39
- *Goal: first-class support for Fiber-based concurrency frameworks (Async, Falcon, Rails async controllers).*
40
-
41
- | Feature | Description | Status |
42
- |---|---|---|
43
- | 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 |
44
- | Ractor-compatible shared cache | Revisit `shared: true` using `Ractor::TVar` or shareable frozen objects so class-level caches work across Ractors | Planned |
45
- | 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 |
46
-
47
- ---
48
-
49
7
  ## v2.0.0 — Next Generation (Long Horizon)
50
8
 
51
9
  *Goal: incorporate real-world usage feedback, clean up accumulated API surface, and open a path for advanced extension.*
data/Rakefile CHANGED
@@ -3,7 +3,19 @@
3
3
  require "bundler/gem_tasks"
4
4
  require "rspec/core/rake_task"
5
5
 
6
- RSpec::Core::RakeTask.new(:spec)
6
+ RSpec::Core::RakeTask.new(:spec) do |t|
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.
13
+ store_specs = Dir["spec/stores/**/*_spec.rb"].sort
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(" ")
17
+ t.pattern = "non_existent_placeholder" # overridden by rspec_opts file args
18
+ end
7
19
 
8
20
  require "standard/rake"
9
21
 
@@ -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