safe_memoize 1.1.0 → 1.3.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.
data/README.md CHANGED
@@ -67,6 +67,16 @@ 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)
76
+ - [Cache namespacing — per-method `namespace:`, class-level `.safe_memoize_namespace=`, and global `Configuration#namespace` for multi-tenant and versioned deployments](#cache-namespacing)
77
+ - [Named shared caches via `shared_cache: "name"` — cross-class cache sharing backed by a globally-registered store](#named-shared-caches)
78
+ - [Automatic cache busting via `cache_bust:` — version-token-based invalidation; works with ActiveRecord `updated_at` and any comparable value](#automatic-cache-busting)
79
+ - [Plugin / extension architecture — `SafeMemoize::Extension` DSL for adding custom `memoize` options and global lifecycle handlers without monkey-patching](#plugin--extension-architecture)
70
80
 
71
81
  ## Installation
72
82
 
@@ -498,6 +508,48 @@ Hooks (`on_memo_hit`, `on_memo_miss`, `on_memo_expire`, `on_memo_evict`) fire on
498
508
 
499
509
  [↑ Back to features](#features)
500
510
 
511
+ ### Fiber-local memoization
512
+
513
+ 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.
514
+
515
+ 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.
516
+
517
+ ```ruby
518
+ class ApiClient
519
+ prepend SafeMemoize
520
+
521
+ def fetch(path)
522
+ http_get(path)
523
+ end
524
+
525
+ memoize :fetch, fiber_local: true
526
+ end
527
+
528
+ client = ApiClient.new
529
+
530
+ Fiber.new { client.fetch("/a") }.resume # computes in this fiber
531
+ Fiber.new { client.fetch("/a") }.resume # computes again — isolated cache
532
+ ```
533
+
534
+ `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`).
535
+
536
+ No `Mutex` is acquired because fibers within a single thread are cooperative — only one fiber executes at a time.
537
+
538
+ **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.
539
+
540
+ Instance-level inspection and reset for fiber-local entries use dedicated methods:
541
+
542
+ ```ruby
543
+ obj.fiber_local_memoized?(:fetch, "/a") # true / false for the current fiber
544
+ obj.reset_fiber_memo(:fetch) # clear all entries for :fetch in current fiber
545
+ obj.reset_fiber_memo(:fetch, "/a") # clear one specific entry
546
+ obj.reset_all_fiber_memos # clear all fiber-local entries for this instance
547
+ ```
548
+
549
+ 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.
550
+
551
+ [↑ Back to features](#features)
552
+
501
553
  ### Bulk memoization
502
554
 
503
555
  Use `memoize_all` to memoize every public method defined on the class in one call:
@@ -681,6 +733,282 @@ Metrics are per-instance and reset independently from the cache itself — clear
681
733
 
682
734
  [↑ Back to features](#features)
683
735
 
736
+ ### Plugin / extension architecture
737
+
738
+ `SafeMemoize::Extension` lets third-party gems add custom `memoize` options and global lifecycle handlers without monkey-patching SafeMemoize internals.
739
+
740
+ ```ruby
741
+ module MyExtension
742
+ extend SafeMemoize::Extension
743
+
744
+ # Declare a custom memoize option.
745
+ # The processor block runs at memoize definition time and returns
746
+ # a Hash of standard memoize options to inject.
747
+ handles_option :active_record_bust do |_value, _method_name, _options|
748
+ { cache_bust: -> { send(:updated_at) } }
749
+ end
750
+
751
+ # Register a global lifecycle handler (fires for every memoized method).
752
+ on_cache_event :miss do |klass, method_name, _cache_key, _record|
753
+ Rails.logger.debug "cache miss: #{klass}##{method_name}"
754
+ end
755
+ end
756
+
757
+ SafeMemoize.register_extension(:active_record_bust, MyExtension)
758
+ ```
759
+
760
+ Once registered, the custom option is accepted by `memoize`:
761
+
762
+ ```ruby
763
+ class OrderDecorator
764
+ prepend SafeMemoize
765
+
766
+ def initialize(order) = (@order = order)
767
+
768
+ def summary = expensive_compute(@order)
769
+ memoize :summary, active_record_bust: true
770
+ # ↑ MyExtension injects cache_bust: -> { updated_at } automatically
771
+ end
772
+ ```
773
+
774
+ #### `handles_option` processor return values
775
+
776
+ The processor block must return a Hash of **standard** `memoize` option keys to inject. Any standard option is supported:
777
+
778
+ ```ruby
779
+ handles_option :short_lived do |ttl, _, _| { ttl: ttl } end
780
+ handles_option :versioned do |ns, _, _| { namespace: ns } end
781
+ handles_option :via_redis do |store, _, _| { store: store } end
782
+ handles_option :bust_on do |fn, _, _| { cache_bust: fn } end
783
+ ```
784
+
785
+ #### `on_cache_event` handler signature
786
+
787
+ ```ruby
788
+ on_cache_event :on_hit, :on_miss do |klass, method_name, cache_key, record|
789
+ # klass — the class whose instance triggered the event
790
+ # method_name — bare Symbol (namespace stripped)
791
+ # cache_key — full cache key Array
792
+ # record — { value:, expires_at:, cached_at: } or nil
793
+ end
794
+ ```
795
+
796
+ Valid event types: `:on_hit`, `:on_miss`, `:on_store`, `:on_expire`, `:on_evict`.
797
+
798
+ #### Registry API
799
+
800
+ ```ruby
801
+ SafeMemoize.register_extension(:name, MyExtension)
802
+ SafeMemoize.unregister_extension(:name)
803
+ SafeMemoize.extensions # { name: MyExtension, … }
804
+ SafeMemoize.reset_extensions! # clear registry (test teardown)
805
+ SafeMemoize.extension_for_option(:active_record_bust) # → MyExtension
806
+ ```
807
+
808
+ #### Duck-type compatibility
809
+
810
+ An extension does not need to `extend SafeMemoize::Extension`. Any object responding to `handled_options`, `process_memoize_option`, and `dispatch_cache_event` is accepted.
811
+
812
+ #### Constraints
813
+
814
+ - Unknown `memoize` keywords raise `ArgumentError` unless a registered extension claims them — typos are still caught.
815
+ - `on_cache_event` handlers run on the main Ractor only; they are silently skipped from worker Ractors.
816
+
817
+ [↑ Back to features](#features)
818
+
819
+ ### Automatic cache busting
820
+
821
+ `cache_bust:` ties a method's cache lifetime to a version token derived from instance state. When the token changes, the old cache key no longer matches — the method is recomputed automatically, with no explicit `reset_memo` required.
822
+
823
+ ```ruby
824
+ class OrderDecorator
825
+ prepend SafeMemoize
826
+
827
+ def initialize(order)
828
+ @order = order
829
+ end
830
+
831
+ def summary = expensive_compute(@order)
832
+ memoize :summary, cache_bust: -> { @order.updated_at }
833
+ # Saving @order advances updated_at → next call is a cache miss → fresh result
834
+ end
835
+ ```
836
+
837
+ #### Token forms
838
+
839
+ ```ruby
840
+ # Proc/lambda — instance_exec gives full access to self, ivars, and methods
841
+ memoize :report, cache_bust: -> { @record.updated_at }
842
+
843
+ # Symbol — calls the named instance method
844
+ memoize :data, cache_bust: :cache_version
845
+
846
+ # Compound token — any comparable value works, including arrays
847
+ memoize :stats, cache_bust: -> { [@version, tenant_id] }
848
+ ```
849
+
850
+ #### How it works
851
+
852
+ The token is incorporated into the cache key alongside the normal arguments. When the token changes, the old key simply produces no match — there is no deletion. Stale entries accumulate silently until:
853
+ - They expire via `ttl:`, or
854
+ - They are evicted by the store adapter's own eviction policy, or
855
+ - You call `reset_memo(:method_name)` or `reset_all_memos` explicitly.
856
+
857
+ For unbounded caches, pair with `ttl:` or a `max_size:`-capable store to limit memory growth:
858
+
859
+ ```ruby
860
+ memoize :summary, cache_bust: -> { @order.updated_at }, ttl: 3600
861
+ ```
862
+
863
+ #### Introspection
864
+
865
+ All introspection methods work with the **current** token:
866
+
867
+ ```ruby
868
+ obj.memoized?(:summary) # true only if the current token's entry is live
869
+ obj.memo_count(:summary) # counts ALL live versions (current + stale)
870
+ obj.reset_memo(:summary) # clears ALL versions
871
+ ```
872
+
873
+ #### Constraints
874
+
875
+ - Incompatible with `key:` — both define the cache key shape; raises `ArgumentError` at `memoize` time.
876
+ - Composes with `namespace:`, `ttl:`, `if:`, `unless:`, and `shared_cache:`.
877
+
878
+ [↑ Back to features](#features)
879
+
880
+ ### Named shared caches
881
+
882
+ `shared_cache: "name"` routes all cache reads and writes through a globally-registered store, letting unrelated classes share the same cached data without any object-level coordination.
883
+
884
+ ```ruby
885
+ class OrderService
886
+ prepend SafeMemoize
887
+
888
+ def find(id) = Order.find(id)
889
+ memoize :find, shared_cache: "orders"
890
+ end
891
+
892
+ class OrderPresenter
893
+ prepend SafeMemoize
894
+
895
+ def find(id) = Order.find(id) # same method signature
896
+ memoize :find, shared_cache: "orders" # same backing store
897
+ end
898
+
899
+ # After OrderService.new.find(42) computes the value, OrderPresenter.new.find(42)
900
+ # returns the cached result — the method body is not called a second time.
901
+ ```
902
+
903
+ #### Registry API
904
+
905
+ ```ruby
906
+ SafeMemoize.shared_cache("orders") # get or auto-create a Memory store
907
+ SafeMemoize.register_shared_cache("orders", my_redis_store) # use a custom adapter
908
+ SafeMemoize.clear_shared_cache("orders") # evict all entries
909
+ SafeMemoize.drop_shared_cache("orders") # remove from registry
910
+ SafeMemoize.shared_caches # { "orders" => #<Memory>, … }
911
+ SafeMemoize.reset_shared_caches! # wipe registry (test teardown)
912
+ ```
913
+
914
+ #### Custom adapter
915
+
916
+ Register a Redis-backed (or any `Stores::Base`) store **before** any class that references the name is loaded — the store is captured at `memoize` definition time:
917
+
918
+ ```ruby
919
+ # config/initializers/safe_memoize.rb
920
+ require "safe_memoize/stores/redis"
921
+
922
+ SafeMemoize.register_shared_cache(
923
+ "orders",
924
+ SafeMemoize::Stores::Redis.new(Redis.new, namespace: "myapp:orders")
925
+ )
926
+ ```
927
+
928
+ #### Key scoping and namespace composition
929
+
930
+ By default two classes sharing the same cache name and method name share the same key:
931
+
932
+ ```ruby
933
+ # OrderService#find(42) and OrderPresenter#find(42) → same key [:find, [42], {}]
934
+ ```
935
+
936
+ Add `namespace:` when you want class-scoped entries within the same store:
937
+
938
+ ```ruby
939
+ memoize :find, shared_cache: "orders", namespace: "service" # [:"service:find", [42], {}]
940
+ memoize :find, shared_cache: "orders", namespace: "presenter" # [:"presenter:find", [42], {}]
941
+ ```
942
+
943
+ #### Constraints
944
+
945
+ - Incompatible with `shared:`, `store:`, `fiber_local:`, `ractor_safe:`, and `max_size:` (use the store adapter's own eviction policy).
946
+ - `register_shared_cache` must be called before the class that uses the name is defined.
947
+ - Test suites should call `SafeMemoize.reset_shared_caches!` in an `after` hook to prevent state leaking between examples.
948
+
949
+ [↑ Back to features](#features)
950
+
951
+ ### Cache namespacing
952
+
953
+ Namespacing adds a string prefix to every cache key, scoping entries to a logical partition. It is transparent to the rest of the API — introspection methods always accept and return bare method names regardless of the active namespace.
954
+
955
+ Namespacing is particularly useful for:
956
+
957
+ - **Versioned deployments** — change the namespace to instantly invalidate all in-flight cached values without flushing the whole store.
958
+ - **Multi-tenant applications** — scope keys per tenant so different tenants' data cannot collide, even when sharing the same in-process hash or external store.
959
+
960
+ #### Per-method namespace
961
+
962
+ Pass `namespace:` to a single `memoize` call:
963
+
964
+ ```ruby
965
+ class ApiClient
966
+ prepend SafeMemoize
967
+
968
+ def fetch(id) = http_get(id)
969
+ memoize :fetch, namespace: "v2" # keys: [:"v2:fetch", [id], {}]
970
+ end
971
+ ```
972
+
973
+ #### Class-level namespace
974
+
975
+ Set `.safe_memoize_namespace=` to apply a namespace to every `memoize` call on the class that doesn't specify its own:
976
+
977
+ ```ruby
978
+ class OrderService
979
+ prepend SafeMemoize
980
+ self.safe_memoize_namespace = "orders"
981
+
982
+ def find(id) = Order.find(id)
983
+ memoize :find # keys: [:"orders:find", ...]
984
+
985
+ def stats = compute_stats
986
+ memoize :stats, namespace: "v2" # per-method wins → [:"v2:stats", ...]
987
+ end
988
+ ```
989
+
990
+ #### Global namespace
991
+
992
+ Set via `SafeMemoize.configure` to apply a namespace to every memoized method in the process that has no per-method or class-level namespace:
993
+
994
+ ```ruby
995
+ SafeMemoize.configure do |c|
996
+ c.namespace = "v1.2.3" # bump this string on each deploy to bust all cached values
997
+ end
998
+ ```
999
+
1000
+ #### Resolution priority
1001
+
1002
+ `namespace:` option on `memoize` > `.safe_memoize_namespace` on the class > `SafeMemoize.configuration.namespace`
1003
+
1004
+ #### Constraints
1005
+
1006
+ - Namespace strings must be non-empty and must not contain `:`.
1007
+ - Namespacing works with all memoize paths (standard, `store:`, `fiber_local:`, `shared:`, `ractor_safe:`).
1008
+ - Adding or changing a namespace changes the cache keys, so existing entries become unreachable (they expire naturally or can be cleared by `reset_all_memos`).
1009
+
1010
+ [↑ Back to features](#features)
1011
+
684
1012
  ### Global configuration
685
1013
 
686
1014
  Use `SafeMemoize.configure` to set defaults that apply to all subsequently memoized methods. Per-call options always take precedence over global defaults.
@@ -698,7 +1026,7 @@ Both settings apply at definition time — methods already memoized before `conf
698
1026
  SafeMemoize.reset_configuration!
699
1027
  ```
700
1028
 
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)).
1029
+ The configure block also accepts `on_hook_error`, `on_deprecation`, `active_support_notifications`, `statsd_client`, `default_store`, and `namespace` (covered in [Hook error isolation](#hook-error-isolation), [Deprecation](#deprecation), [ActiveSupport::Notifications](#activesupportnotifications), [StatsD](#statsd), [Pluggable cache stores](#pluggable-cache-stores), and [Cache namespacing](#cache-namespacing)).
702
1030
 
703
1031
  [↑ Back to features](#features)
704
1032
 
@@ -864,6 +1192,155 @@ end
864
1192
 
865
1193
  [↑ Back to features](#features)
866
1194
 
1195
+ ### Pluggable cache stores
1196
+
1197
+ 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.
1198
+
1199
+ #### Built-in: `Stores::Memory`
1200
+
1201
+ `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:
1202
+
1203
+ ```ruby
1204
+ SHARED_STORE = SafeMemoize::Stores::Memory.new
1205
+
1206
+ class UserService
1207
+ prepend SafeMemoize
1208
+ def find(id) = User.find(id)
1209
+ memoize :find, store: SHARED_STORE, ttl: 60
1210
+ end
1211
+
1212
+ class PostService
1213
+ prepend SafeMemoize
1214
+ def author(post) = User.find(post.user_id)
1215
+ memoize :author, store: SHARED_STORE
1216
+ end
1217
+ ```
1218
+
1219
+ 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.
1220
+
1221
+ #### Redis adapter
1222
+
1223
+ Requires a Redis-compatible client (the `redis` gem or any drop-in replacement):
1224
+
1225
+ ```ruby
1226
+ require "safe_memoize/stores/redis"
1227
+ require "redis"
1228
+
1229
+ REDIS_STORE = SafeMemoize::Stores::Redis.new(::Redis.new)
1230
+
1231
+ class PricingService
1232
+ prepend SafeMemoize
1233
+ def quote(sku) = api_fetch(sku)
1234
+ memoize :quote, store: REDIS_STORE, ttl: 300
1235
+ end
1236
+ ```
1237
+
1238
+ 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:
1239
+
1240
+ ```ruby
1241
+ REDIS_STORE = SafeMemoize::Stores::Redis.new(::Redis.new, namespace: "myapp:memo")
1242
+ ```
1243
+
1244
+ #### Rails.cache adapter
1245
+
1246
+ Wraps any `ActiveSupport::Cache::Store`, including `Rails.cache`:
1247
+
1248
+ ```ruby
1249
+ require "safe_memoize/stores/rails_cache"
1250
+
1251
+ RAILS_STORE = SafeMemoize::Stores::RailsCache.new(Rails.cache)
1252
+
1253
+ class CatalogService
1254
+ prepend SafeMemoize
1255
+ def fetch(slug) = Catalog.find_by!(slug: slug)
1256
+ memoize :fetch, store: RAILS_STORE, ttl: 600
1257
+ end
1258
+ ```
1259
+
1260
+ 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.
1261
+
1262
+ #### Custom adapters
1263
+
1264
+ Subclass `SafeMemoize::Stores::Base` and implement the six-method contract:
1265
+
1266
+ ```ruby
1267
+ class MyStore < SafeMemoize::Stores::Base
1268
+ def read(key) = ... # return MISS if absent
1269
+ def write(key, value, expires_in: nil) = ...
1270
+ def delete(key) = ...
1271
+ def clear = ...
1272
+ def keys = ... # Array of stored keys
1273
+ end
1274
+ ```
1275
+
1276
+ 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`.
1277
+
1278
+ #### concurrent-ruby adapter
1279
+
1280
+ `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.
1281
+
1282
+ `concurrent-ruby` is a **soft dependency** — it is not required at runtime unless you instantiate the adapter. Add it to your own `Gemfile`:
1283
+
1284
+ ```ruby
1285
+ gem "concurrent-ruby"
1286
+ ```
1287
+
1288
+ Opt in per class:
1289
+
1290
+ ```ruby
1291
+ class HotService
1292
+ prepend SafeMemoize
1293
+ self.safe_memoize_store = SafeMemoize::Adapters::ConcurrentRuby.new
1294
+
1295
+ def expensive(id) = db.find(id)
1296
+ memoize :expensive
1297
+ end
1298
+ ```
1299
+
1300
+ Or set it globally:
1301
+
1302
+ ```ruby
1303
+ SafeMemoize.configure do |c|
1304
+ c.default_store = SafeMemoize::Adapters::ConcurrentRuby.new
1305
+ end
1306
+ ```
1307
+
1308
+ 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).
1309
+
1310
+ #### Class-level default store (`safe_memoize_store=`)
1311
+
1312
+ Set a default store for every `memoize` call on a single class without touching the global configuration:
1313
+
1314
+ ```ruby
1315
+ class ReportService
1316
+ prepend SafeMemoize
1317
+ self.safe_memoize_store = SafeMemoize::Adapters::ConcurrentRuby.new
1318
+
1319
+ def summary = compute_summary # routed through ConcurrentRuby
1320
+ memoize :summary
1321
+ end
1322
+ ```
1323
+
1324
+ 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`.
1325
+
1326
+ #### Global default store
1327
+
1328
+ Set a default store for all compatible `memoize` calls without specifying `store:` on each one:
1329
+
1330
+ ```ruby
1331
+ SafeMemoize.configure do |c|
1332
+ c.default_store = SafeMemoize::Stores::Redis.new(::Redis.new)
1333
+ end
1334
+ ```
1335
+
1336
+ 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!`.
1337
+
1338
+ #### Compatibility
1339
+
1340
+ 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).
1341
+
1342
+ [↑ Back to features](#features)
1343
+
867
1344
  ### Deprecation
868
1345
 
869
1346
  SafeMemoize ships a structured deprecation helper for gem authors who build on top of it:
@@ -895,17 +1372,72 @@ end
895
1372
 
896
1373
  [↑ Back to features](#features)
897
1374
 
1375
+ ## Ractor-safe shared cache
1376
+
1377
+ 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.
1378
+
1379
+ ```ruby
1380
+ class PriceService
1381
+ prepend SafeMemoize
1382
+
1383
+ def fetch_price(item_id)
1384
+ external_api.get("/prices/#{item_id}")
1385
+ end
1386
+
1387
+ memoize :fetch_price, shared: true, ractor_safe: true, ttl: 300
1388
+ end
1389
+
1390
+ # Main Ractor — multiple threads share one cache entry
1391
+ 20.times.map { Thread.new { PriceService.new.fetch_price(42) } }.map(&:value)
1392
+
1393
+ # Worker Ractors also read from and write to the same supervisor cache
1394
+ result = Ractor.new(PriceService) { |s| s.new.fetch_price(42) }.take
1395
+ ```
1396
+
1397
+ ### How it works
1398
+
1399
+ - 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.
1400
+ - 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.
1401
+ - Cached values are deep-frozen via `Ractor.make_shareable`. Values that cannot be made shareable (e.g. a `Mutex`) raise `ArgumentError`.
1402
+ - **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.
1403
+ - `ttl:` is supported. Expired entries are skipped by the supervisor's `:fetch` handler.
1404
+
1405
+ ### Constraints
1406
+
1407
+ `ractor_safe: true` is intentionally limited. The following options are incompatible and raise `ArgumentError` at `memoize` time:
1408
+
1409
+ | Option | Reason |
1410
+ |---|---|
1411
+ | `if:` / `unless:` | Conditional Procs are non-Ractor-shareable |
1412
+ | `max_size:` | LRU order tracking requires a non-shareable Ruby `Hash` |
1413
+ | `ttl_refresh:` | Requires re-examining the record on every hit |
1414
+ | `key:` | Custom key Procs are non-Ractor-shareable |
1415
+ | `store:` | External adapters are incompatible with the supervisor model |
1416
+
1417
+ ### Class-level API
1418
+
1419
+ ```ruby
1420
+ PriceService.ractor_memoized?(:fetch_price, 42) # → true / false
1421
+ PriceService.ractor_memo_count # → total live entries
1422
+ PriceService.ractor_memo_count(:fetch_price) # → entries for one method
1423
+ PriceService.reset_ractor_memo(:fetch_price, 42) # → clear one entry
1424
+ PriceService.reset_ractor_memo(:fetch_price) # → clear all entries for method
1425
+ PriceService.reset_all_ractor_memos # → clear entire shared cache
1426
+ ```
1427
+
1428
+ [↑ Back to features](#features)
1429
+
898
1430
  ## Ractor compatibility
899
1431
 
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:
1432
+ 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
1433
 
902
1434
  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
1435
 
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.
1436
+ 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
1437
 
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`.
1438
+ **Workaround for shared caches:** use `memoize :method, shared: true, ractor_safe: true` (see [Ractor-safe shared cache](#ractor-safe-shared-cache) above).
907
1439
 
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.
1440
+ **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
1441
 
910
1442
  ## Development
911
1443
 
@@ -970,6 +1502,17 @@ Anything **not** listed here — internal modules, private methods, `@__safe_mem
970
1502
  | `SafeMemoize.configuration` | module method | Returns the current `Configuration` |
971
1503
  | `SafeMemoize.reset_configuration!` | module method | Restores all configuration to defaults |
972
1504
  | `SafeMemoize.deprecate(subject, message:, horizon:)` | module method | Emits a structured deprecation warning |
1505
+ | `SafeMemoize.shared_cache(name)` | module method | Returns named store, auto-creating a `Memory` store if absent |
1506
+ | `SafeMemoize.register_shared_cache(name, store)` | module method | Registers a custom `Stores::Base` under a name |
1507
+ | `SafeMemoize.clear_shared_cache(name)` | module method | Evicts all entries from the named store |
1508
+ | `SafeMemoize.drop_shared_cache(name)` | module method | Removes the named store from the registry |
1509
+ | `SafeMemoize.shared_caches` | module method | Returns a snapshot of the registry |
1510
+ | `SafeMemoize.reset_shared_caches!` | module method | Clears the entire registry (test teardown) |
1511
+ | `SafeMemoize.register_extension(name, ext)` | module method | Registers a plugin extension |
1512
+ | `SafeMemoize.unregister_extension(name)` | module method | Removes an extension |
1513
+ | `SafeMemoize.extensions` | module method | Returns snapshot of extension registry |
1514
+ | `SafeMemoize.reset_extensions!` | module method | Clears all extensions (test teardown) |
1515
+ | `SafeMemoize.extension_for_option(name)` | module method | Returns the extension handling the named option |
973
1516
 
974
1517
  ### `memoize` DSL (class method, added by `prepend SafeMemoize`)
975
1518
 
@@ -982,6 +1525,13 @@ Anything **not** listed here — internal modules, private methods, `@__safe_mem
982
1525
  | `unless:` | `Symbol \| Proc \| nil` | `nil` | Store only when falsy |
983
1526
  | `shared:` | `Boolean` | `false` | Class-level shared cache |
984
1527
  | `key:` | `Proc \| nil` | `nil` | Class-level custom key generator |
1528
+ | `store:` | `Stores::Base \| nil` | `nil` | External cache store adapter; incompatible with `max_size:` and `shared:` |
1529
+ | `fiber_local:` | `Boolean` | `false` | Fiber-local cache; each fiber gets an isolated store; incompatible with `shared:` and `store:` |
1530
+ | `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:` |
1531
+ | `namespace:` | `String \| nil` | `nil` | Namespace prefix prepended to the cache key's first element; must not contain `:`; takes precedence over the class-level and global namespace |
1532
+ | `shared_cache:` | `String \| nil` | `nil` | Name of a globally-registered shared store; incompatible with `shared:`, `store:`, `fiber_local:`, `ractor_safe:`, and `max_size:` |
1533
+ | `cache_bust:` | `Proc \| Symbol \| nil` | `nil` | Version-token callable; invoked on the instance at each lookup; token is folded into the key; incompatible with `key:` |
1534
+ | *(extension options)* | any | — | Unknown kwargs are validated against registered extensions; raise `ArgumentError` if unclaimed |
985
1535
 
986
1536
  ### `memoize_all` options (class method)
987
1537
 
@@ -1055,6 +1605,14 @@ All `memoize` option keys above, plus:
1055
1605
  | `memoize_with_custom_key(method_name) { \|*args, **kwargs\| … }` | Instance-level key generator |
1056
1606
  | `clear_custom_keys(method_name = nil)` | Remove one or all key generators |
1057
1607
 
1608
+ **Fiber-local cache (when any method uses `fiber_local: true`)**
1609
+
1610
+ | Method | Returns |
1611
+ |---|---|
1612
+ | `fiber_local_memoized?(method_name, *args, **kwargs)` | `Boolean` — cached in the current fiber? |
1613
+ | `reset_fiber_memo(method_name, *args, **kwargs)` | `nil` — clear one or all entries in current fiber |
1614
+ | `reset_all_fiber_memos` | `nil` — clear all fiber-local entries for this instance |
1615
+
1058
1616
  ### Shared-cache class methods (added when any method uses `shared: true`)
1059
1617
 
1060
1618
  | Method | Returns |
@@ -1066,6 +1624,15 @@ All `memoize` option keys above, plus:
1066
1624
  | `shared_memo_age(method_name, *args, **kwargs)` | `Numeric \| nil` |
1067
1625
  | `shared_memo_stale?(method_name, *args, **kwargs)` | `Boolean` |
1068
1626
 
1627
+ **Ractor-safe shared cache (added when any method uses `ractor_safe: true`)**
1628
+
1629
+ | Method | Returns |
1630
+ |---|---|
1631
+ | `reset_ractor_memo(method_name, *args, **kwargs)` | `nil` — clear one or all entries |
1632
+ | `reset_all_ractor_memos` | `nil` — clear the entire Ractor-safe shared cache |
1633
+ | `ractor_memoized?(method_name, *args, **kwargs)` | `Boolean` — live entry exists? |
1634
+ | `ractor_memo_count(method_name = nil)` | `Integer` — live entry count |
1635
+
1069
1636
  ### `SafeMemoize::Configuration` attributes
1070
1637
 
1071
1638
  | Attribute | Type | Default |
@@ -1077,10 +1644,21 @@ All `memoize` option keys above, plus:
1077
1644
  | `active_support_notifications` | `Boolean` | `false` |
1078
1645
  | `statsd_client` | `Object \| nil` | `nil` |
1079
1646
  | `opentelemetry_tracer` | `Object \| nil` | `nil` |
1647
+ | `default_store` | `Stores::Base \| nil` | `nil` |
1648
+ | `namespace` | `String \| nil` | `nil` |
1649
+
1650
+ ### Store adapter classes (v1.1.0+)
1651
+
1652
+ | Class | Require | Notes |
1653
+ |---|---|---|
1654
+ | `SafeMemoize::Stores::Base` | auto | Abstract base — subclass to build custom adapters; exposes `MISS` sentinel |
1655
+ | `SafeMemoize::Stores::Memory` | auto | Built-in in-process store; reference implementation |
1656
+ | `SafeMemoize::Stores::Redis` | `"safe_memoize/stores/redis"` | Redis-backed adapter; Marshal serialization; `PX` TTL |
1657
+ | `SafeMemoize::Stores::RailsCache` | `"safe_memoize/stores/rails_cache"` | `ActiveSupport::Cache::Store` wrapper |
1080
1658
 
1081
1659
  ### Opt-in extensions (not guaranteed until their owning milestone ships)
1082
1660
 
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:
1661
+ 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
1662
 
1085
1663
  - `SafeMemoize::Rails` module (`track`, `reset_tracked!`)
1086
1664
  - `SafeMemoize::Rails::RequestScoped` concern