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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +2 -2
- data/CHANGELOG.md +57 -1
- data/README.md +584 -6
- data/ROADMAP.md +0 -30
- data/Rakefile +9 -5
- data/lib/safe_memoize/adapters/concurrent_ruby.rb +98 -0
- data/lib/safe_memoize/cache_metrics_methods.rb +4 -5
- data/lib/safe_memoize/class_methods.rb +330 -23
- data/lib/safe_memoize/configuration.rb +8 -0
- data/lib/safe_memoize/custom_key_methods.rb +11 -2
- data/lib/safe_memoize/extension.rb +88 -0
- data/lib/safe_memoize/fiber_local_methods.rb +109 -0
- data/lib/safe_memoize/hooks_methods.rb +8 -1
- data/lib/safe_memoize/inspection_methods.rb +34 -5
- data/lib/safe_memoize/instance_methods.rb +1 -0
- data/lib/safe_memoize/public_methods.rb +3 -2
- data/lib/safe_memoize/public_metrics_methods.rb +4 -3
- data/lib/safe_memoize/ractor_shared_methods.rb +146 -0
- data/lib/safe_memoize/release_tooling.rb +25 -0
- data/lib/safe_memoize/version.rb +1 -1
- data/lib/safe_memoize.rb +141 -0
- data/sig/safe_memoize.rbs +77 -2
- metadata +5 -1
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 `
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|