safe_memoize 1.2.0 → 1.4.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: 24faf0555ba5e11e37092aea14e1db090d7f5bb13dd40edc33ea30d8adcba552
4
- data.tar.gz: '09beaa5cf9e25284462bdac9636929642213a9e8d657900d30913832da230959'
3
+ metadata.gz: e34f9a8aaa025eca2f817b633409686788e99442f10de2fede62f30443f9a0fc
4
+ data.tar.gz: 21560a08d7060ee77da109da4a91024899674da14ec41e051ff5cb08a5d913f2
5
5
  SHA512:
6
- metadata.gz: 8ed21a58fc95a122794bcb91a1cbccae2bad598b38f736d76eb6f95551245dba070e047eaaea56b26fca6aace181e138acd37a0a027dbb7c0a6a43ae728f2cf0
7
- data.tar.gz: f51afd15884dd10c771cd3f3ffcb7d481b0fd3012ecc74148fc9ff82e056abc1a2bb044d5344d635abb5380d200f0ec62a4db528d8fc79697358f775cc4aba3e
6
+ metadata.gz: '0408a2a2322c7861ec0eab88a62e02a15c59c552301e8fe3b7712691d03f385c81a2efbcf8ef06da57b50ae304edc18a7794001c43093b9fc49c180449a68199'
7
+ data.tar.gz: 421ad6f07416395bef2723fd00a20d7e20c4becd2fec0da9f774ca476d370b1bb459b8d895c2cb7535da9898e27afcfdfcf3d089923ff8aac426dee0c89c5aa8
@@ -0,0 +1,15 @@
1
+ # These are supported funding model platforms
2
+
3
+ github: [eclectic-coding] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
4
+ patreon: # Replace with a single Patreon username
5
+ open_collective: # Replace with a single Open Collective username
6
+ ko_fi: # Replace with a single Ko-fi username
7
+ tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8
+ community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9
+ liberapay: # Replace with a single Liberapay username
10
+ issuehunt: # Replace with a single IssueHunt username
11
+ lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
12
+ polar: # Replace with a single Polar username
13
+ buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
14
+ thanks_dev: # Replace with a single thanks.dev username
15
+ custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
data/CHANGELOG.md CHANGED
@@ -8,6 +8,45 @@ from v1.0.0 onwards. Prior 0.x releases may include breaking changes between min
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.4.0] - 2026-06-02
12
+
13
+ ### Added
14
+
15
+ - `safe_memoize_options(**opts)` class-level macro — sets default options for every subsequent `memoize` call on the class. Per-call options take precedence; class defaults take precedence over global `SafeMemoize.configure` defaults. Accepts all `memoize` options except mode-switch options (`shared:`, `fiber_local:`, `ractor_safe:`, `shared_cache:`), which must be specified per call. Call with no arguments to clear class-level defaults.
16
+ - `copy_on_read: true` option on `memoize` — returns a `dup` (or `deep_dup` when available, e.g. ActiveRecord objects) of the cached value on every read, preventing callers from mutating shared cached state. `nil` and frozen values are returned as-is. Works across all cache paths (per-instance, LRU, shared, fiber-local, and external store). Incompatible with `ractor_safe:` (ractor-safe values are always frozen; use that guarantee instead). Can be set as a class default via `safe_memoize_options copy_on_read: true`.
17
+
18
+ ## [1.3.0] - 2026-05-28
19
+
20
+ ### Added
21
+
22
+ - `SafeMemoize::Extension` — mixin for building SafeMemoize extensions. Extend it in any module to get a DSL for declaring custom `memoize` options and global lifecycle event handlers without monkey-patching SafeMemoize internals.
23
+ - `handles_option(name, &processor)` — declares a custom keyword argument that `memoize` will accept; the processor block is called at definition time with `(value, method_name, all_extension_options)` and must return a `Hash` of standard memoize options to inject (e.g. `{cache_bust: ...}`, `{ttl: 60}`, `{namespace: "v2"}`).
24
+ - `on_cache_event(*event_types, &handler)` — registers a global lifecycle handler that fires after every matching event (`:on_hit`, `:on_miss`, `:on_store`, `:on_expire`, `:on_evict`) across all memoized methods on all classes; handler receives `(klass, method_name, cache_key, record)`; runs on the main Ractor only.
25
+ - Duck-type compatible — any object responding to `handled_options`, `process_memoize_option`, and `dispatch_cache_event` works without `extend SafeMemoize::Extension`.
26
+ - `SafeMemoize.register_extension(name, extension)` — registers an extension under a symbolic name.
27
+ - `SafeMemoize.unregister_extension(name)` — removes an extension.
28
+ - `SafeMemoize.extensions` — returns a snapshot of the registry.
29
+ - `SafeMemoize.reset_extensions!` — clears the registry (test teardown).
30
+ - `SafeMemoize.extension_for_option(option_name)` — returns the registered extension that handles the named option, or `nil`.
31
+ - `memoize` now accepts `**extension_options` for any unknown keyword argument; each key is validated against registered extensions at call time and raises `ArgumentError` if no extension claims it, preserving the existing strict-options behaviour for typos.
32
+
33
+ - `cache_bust: callable` option on `memoize` — automatic cache invalidation driven by a version token. A callable (Proc, lambda, or Symbol naming an instance method) is invoked on the instance at every cache lookup; the returned token is folded into the cache key alongside the normal arguments. When the token changes (e.g. an ActiveRecord `updated_at` advances after a `save`), the old key no longer matches any entry — the method body is recomputed and stored under the new key without any explicit `reset_memo` call. Accepts a zero-argument callable invoked via `instance_exec` (giving access to `self`, instance variables, and methods) or a `Symbol` naming an instance method. Returns any comparable value as the token: a `Time`, `Integer`, `String`, `Array`, etc. Old token entries accumulate as stale; pair with `ttl:` or a store adapter's eviction to bound memory. Incompatible with `key:`. Composes with `namespace:`, `ttl:`, `if:`, `unless:`, and `shared_cache:`.
34
+
35
+ - `shared_cache: "name"` option on `memoize` — routes all reads and writes through a globally-registered named `Stores::Base` instance, enabling cross-class cache sharing. Any number of unrelated classes can share the same backing store by referencing the same name. The store is resolved at `memoize` definition time via `SafeMemoize.shared_cache("name")`, which auto-creates a `Stores::Memory` instance on first access; supply a custom adapter (Redis, RailsCache, etc.) by calling `SafeMemoize.register_shared_cache("name", store)` before any class that references the name is loaded. Incompatible with `shared:`, `store:`, `fiber_local:`, `ractor_safe:`, and `max_size:`; composes naturally with `namespace:`, `ttl:`, `if:`, `unless:`, and `key:`.
36
+ - `SafeMemoize.shared_cache(name)` — returns the `Stores::Base` instance for the given name, creating a new `Stores::Memory` if none is registered.
37
+ - `SafeMemoize.register_shared_cache(name, store)` — registers a custom `Stores::Base` instance under a name; must be called before any class that uses that name via `shared_cache:` is loaded.
38
+ - `SafeMemoize.clear_shared_cache(name)` — calls `clear` on the named store, evicting all entries. No-op for unregistered names.
39
+ - `SafeMemoize.drop_shared_cache(name)` — removes the named store from the registry; subsequent `shared_cache(name)` calls will auto-create a new `Memory` store.
40
+ - `SafeMemoize.shared_caches` — returns a dup of the current registry as a `Hash{String => Stores::Base}`.
41
+ - `SafeMemoize.reset_shared_caches!` — clears the entire registry; useful in test-suite `after` hooks to prevent state leaking between examples.
42
+
43
+ - `namespace:` option on `memoize` — a String prefix scoped to a single method; prepended to the cache key's first element so that entries with different namespaces never collide, even when sharing the same store or the same per-instance hash. Must be a non-empty string without `:`. Useful for versioning one method independently of its peers.
44
+ - `.safe_memoize_namespace` / `.safe_memoize_namespace=` — class-level namespace attribute; applies to every `memoize` call on the class that does not specify its own `namespace:` option. Takes precedence over the global `SafeMemoize::Configuration#namespace`.
45
+ - `SafeMemoize::Configuration#namespace` — global namespace prefix applied to every `memoize` call site that has no per-method or class-level namespace set. Set via `SafeMemoize.configure { |c| c.namespace = "v1" }`. Useful for versioned deployments and multi-tenant setups. Cleared by `reset_configuration!`.
46
+ - Resolution priority: per-method `namespace:` > class `.safe_memoize_namespace` > global `Configuration#namespace`.
47
+ - All introspection methods (`memoized?`, `memo_count`, `memo_keys`, `memo_values`, `reset_memo`, `reset_all_memos`, `dump_memo`, `cache_stats_for`, `cache_metrics_reset`, shared-cache equivalents, etc.) accept the bare method name regardless of which namespace tier is active; the `:method` field in projections always returns the bare method name.
48
+ - Ractor-safe: namespace resolution uses `instance_variable_get` (read-only) so worker Ractors can call `compute_cache_key` without triggering unshareable class-level ivar initialization.
49
+
11
50
  ## [1.2.0] - 2026-05-27
12
51
 
13
52
  ### Added
data/README.md CHANGED
@@ -73,6 +73,12 @@ SafeMemoize uses Ruby's `prepend` mechanism. When you call `memoize :method_name
73
73
  - [Class-level `.safe_memoize_store=` — set a per-class default store without touching global config](#class-level-default-store-safe_memoize_store)
74
74
  - [Fiber-local memoization via `fiber_local: true` — isolated per-fiber cache, no mutex, works with Async/Falcon](#fiber-local-memoization)
75
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)
80
+ - [Per-class default options via `safe_memoize_options` — set TTL, max size, copy-on-read, and other defaults for every `memoize` call on the class without repeating them](#per-class-default-options-safe_memoize_options)
81
+ - [Copy-on-read via `copy_on_read: true` — returns a `dup`/`deep_dup` on every cache read to protect shared cached state from caller mutation](#copy-on-read)
76
82
 
77
83
  ## Installation
78
84
 
@@ -729,6 +735,282 @@ Metrics are per-instance and reset independently from the cache itself — clear
729
735
 
730
736
  [↑ Back to features](#features)
731
737
 
738
+ ### Plugin / extension architecture
739
+
740
+ `SafeMemoize::Extension` lets third-party gems add custom `memoize` options and global lifecycle handlers without monkey-patching SafeMemoize internals.
741
+
742
+ ```ruby
743
+ module MyExtension
744
+ extend SafeMemoize::Extension
745
+
746
+ # Declare a custom memoize option.
747
+ # The processor block runs at memoize definition time and returns
748
+ # a Hash of standard memoize options to inject.
749
+ handles_option :active_record_bust do |_value, _method_name, _options|
750
+ { cache_bust: -> { send(:updated_at) } }
751
+ end
752
+
753
+ # Register a global lifecycle handler (fires for every memoized method).
754
+ on_cache_event :miss do |klass, method_name, _cache_key, _record|
755
+ Rails.logger.debug "cache miss: #{klass}##{method_name}"
756
+ end
757
+ end
758
+
759
+ SafeMemoize.register_extension(:active_record_bust, MyExtension)
760
+ ```
761
+
762
+ Once registered, the custom option is accepted by `memoize`:
763
+
764
+ ```ruby
765
+ class OrderDecorator
766
+ prepend SafeMemoize
767
+
768
+ def initialize(order) = (@order = order)
769
+
770
+ def summary = expensive_compute(@order)
771
+ memoize :summary, active_record_bust: true
772
+ # ↑ MyExtension injects cache_bust: -> { updated_at } automatically
773
+ end
774
+ ```
775
+
776
+ #### `handles_option` processor return values
777
+
778
+ The processor block must return a Hash of **standard** `memoize` option keys to inject. Any standard option is supported:
779
+
780
+ ```ruby
781
+ handles_option :short_lived do |ttl, _, _| { ttl: ttl } end
782
+ handles_option :versioned do |ns, _, _| { namespace: ns } end
783
+ handles_option :via_redis do |store, _, _| { store: store } end
784
+ handles_option :bust_on do |fn, _, _| { cache_bust: fn } end
785
+ ```
786
+
787
+ #### `on_cache_event` handler signature
788
+
789
+ ```ruby
790
+ on_cache_event :on_hit, :on_miss do |klass, method_name, cache_key, record|
791
+ # klass — the class whose instance triggered the event
792
+ # method_name — bare Symbol (namespace stripped)
793
+ # cache_key — full cache key Array
794
+ # record — { value:, expires_at:, cached_at: } or nil
795
+ end
796
+ ```
797
+
798
+ Valid event types: `:on_hit`, `:on_miss`, `:on_store`, `:on_expire`, `:on_evict`.
799
+
800
+ #### Registry API
801
+
802
+ ```ruby
803
+ SafeMemoize.register_extension(:name, MyExtension)
804
+ SafeMemoize.unregister_extension(:name)
805
+ SafeMemoize.extensions # { name: MyExtension, … }
806
+ SafeMemoize.reset_extensions! # clear registry (test teardown)
807
+ SafeMemoize.extension_for_option(:active_record_bust) # → MyExtension
808
+ ```
809
+
810
+ #### Duck-type compatibility
811
+
812
+ An extension does not need to `extend SafeMemoize::Extension`. Any object responding to `handled_options`, `process_memoize_option`, and `dispatch_cache_event` is accepted.
813
+
814
+ #### Constraints
815
+
816
+ - Unknown `memoize` keywords raise `ArgumentError` unless a registered extension claims them — typos are still caught.
817
+ - `on_cache_event` handlers run on the main Ractor only; they are silently skipped from worker Ractors.
818
+
819
+ [↑ Back to features](#features)
820
+
821
+ ### Automatic cache busting
822
+
823
+ `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.
824
+
825
+ ```ruby
826
+ class OrderDecorator
827
+ prepend SafeMemoize
828
+
829
+ def initialize(order)
830
+ @order = order
831
+ end
832
+
833
+ def summary = expensive_compute(@order)
834
+ memoize :summary, cache_bust: -> { @order.updated_at }
835
+ # Saving @order advances updated_at → next call is a cache miss → fresh result
836
+ end
837
+ ```
838
+
839
+ #### Token forms
840
+
841
+ ```ruby
842
+ # Proc/lambda — instance_exec gives full access to self, ivars, and methods
843
+ memoize :report, cache_bust: -> { @record.updated_at }
844
+
845
+ # Symbol — calls the named instance method
846
+ memoize :data, cache_bust: :cache_version
847
+
848
+ # Compound token — any comparable value works, including arrays
849
+ memoize :stats, cache_bust: -> { [@version, tenant_id] }
850
+ ```
851
+
852
+ #### How it works
853
+
854
+ 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:
855
+ - They expire via `ttl:`, or
856
+ - They are evicted by the store adapter's own eviction policy, or
857
+ - You call `reset_memo(:method_name)` or `reset_all_memos` explicitly.
858
+
859
+ For unbounded caches, pair with `ttl:` or a `max_size:`-capable store to limit memory growth:
860
+
861
+ ```ruby
862
+ memoize :summary, cache_bust: -> { @order.updated_at }, ttl: 3600
863
+ ```
864
+
865
+ #### Introspection
866
+
867
+ All introspection methods work with the **current** token:
868
+
869
+ ```ruby
870
+ obj.memoized?(:summary) # true only if the current token's entry is live
871
+ obj.memo_count(:summary) # counts ALL live versions (current + stale)
872
+ obj.reset_memo(:summary) # clears ALL versions
873
+ ```
874
+
875
+ #### Constraints
876
+
877
+ - Incompatible with `key:` — both define the cache key shape; raises `ArgumentError` at `memoize` time.
878
+ - Composes with `namespace:`, `ttl:`, `if:`, `unless:`, and `shared_cache:`.
879
+
880
+ [↑ Back to features](#features)
881
+
882
+ ### Named shared caches
883
+
884
+ `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.
885
+
886
+ ```ruby
887
+ class OrderService
888
+ prepend SafeMemoize
889
+
890
+ def find(id) = Order.find(id)
891
+ memoize :find, shared_cache: "orders"
892
+ end
893
+
894
+ class OrderPresenter
895
+ prepend SafeMemoize
896
+
897
+ def find(id) = Order.find(id) # same method signature
898
+ memoize :find, shared_cache: "orders" # same backing store
899
+ end
900
+
901
+ # After OrderService.new.find(42) computes the value, OrderPresenter.new.find(42)
902
+ # returns the cached result — the method body is not called a second time.
903
+ ```
904
+
905
+ #### Registry API
906
+
907
+ ```ruby
908
+ SafeMemoize.shared_cache("orders") # get or auto-create a Memory store
909
+ SafeMemoize.register_shared_cache("orders", my_redis_store) # use a custom adapter
910
+ SafeMemoize.clear_shared_cache("orders") # evict all entries
911
+ SafeMemoize.drop_shared_cache("orders") # remove from registry
912
+ SafeMemoize.shared_caches # { "orders" => #<Memory>, … }
913
+ SafeMemoize.reset_shared_caches! # wipe registry (test teardown)
914
+ ```
915
+
916
+ #### Custom adapter
917
+
918
+ 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:
919
+
920
+ ```ruby
921
+ # config/initializers/safe_memoize.rb
922
+ require "safe_memoize/stores/redis"
923
+
924
+ SafeMemoize.register_shared_cache(
925
+ "orders",
926
+ SafeMemoize::Stores::Redis.new(Redis.new, namespace: "myapp:orders")
927
+ )
928
+ ```
929
+
930
+ #### Key scoping and namespace composition
931
+
932
+ By default two classes sharing the same cache name and method name share the same key:
933
+
934
+ ```ruby
935
+ # OrderService#find(42) and OrderPresenter#find(42) → same key [:find, [42], {}]
936
+ ```
937
+
938
+ Add `namespace:` when you want class-scoped entries within the same store:
939
+
940
+ ```ruby
941
+ memoize :find, shared_cache: "orders", namespace: "service" # [:"service:find", [42], {}]
942
+ memoize :find, shared_cache: "orders", namespace: "presenter" # [:"presenter:find", [42], {}]
943
+ ```
944
+
945
+ #### Constraints
946
+
947
+ - Incompatible with `shared:`, `store:`, `fiber_local:`, `ractor_safe:`, and `max_size:` (use the store adapter's own eviction policy).
948
+ - `register_shared_cache` must be called before the class that uses the name is defined.
949
+ - Test suites should call `SafeMemoize.reset_shared_caches!` in an `after` hook to prevent state leaking between examples.
950
+
951
+ [↑ Back to features](#features)
952
+
953
+ ### Cache namespacing
954
+
955
+ 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.
956
+
957
+ Namespacing is particularly useful for:
958
+
959
+ - **Versioned deployments** — change the namespace to instantly invalidate all in-flight cached values without flushing the whole store.
960
+ - **Multi-tenant applications** — scope keys per tenant so different tenants' data cannot collide, even when sharing the same in-process hash or external store.
961
+
962
+ #### Per-method namespace
963
+
964
+ Pass `namespace:` to a single `memoize` call:
965
+
966
+ ```ruby
967
+ class ApiClient
968
+ prepend SafeMemoize
969
+
970
+ def fetch(id) = http_get(id)
971
+ memoize :fetch, namespace: "v2" # keys: [:"v2:fetch", [id], {}]
972
+ end
973
+ ```
974
+
975
+ #### Class-level namespace
976
+
977
+ Set `.safe_memoize_namespace=` to apply a namespace to every `memoize` call on the class that doesn't specify its own:
978
+
979
+ ```ruby
980
+ class OrderService
981
+ prepend SafeMemoize
982
+ self.safe_memoize_namespace = "orders"
983
+
984
+ def find(id) = Order.find(id)
985
+ memoize :find # keys: [:"orders:find", ...]
986
+
987
+ def stats = compute_stats
988
+ memoize :stats, namespace: "v2" # per-method wins → [:"v2:stats", ...]
989
+ end
990
+ ```
991
+
992
+ #### Global namespace
993
+
994
+ Set via `SafeMemoize.configure` to apply a namespace to every memoized method in the process that has no per-method or class-level namespace:
995
+
996
+ ```ruby
997
+ SafeMemoize.configure do |c|
998
+ c.namespace = "v1.2.3" # bump this string on each deploy to bust all cached values
999
+ end
1000
+ ```
1001
+
1002
+ #### Resolution priority
1003
+
1004
+ `namespace:` option on `memoize` > `.safe_memoize_namespace` on the class > `SafeMemoize.configuration.namespace`
1005
+
1006
+ #### Constraints
1007
+
1008
+ - Namespace strings must be non-empty and must not contain `:`.
1009
+ - Namespacing works with all memoize paths (standard, `store:`, `fiber_local:`, `shared:`, `ractor_safe:`).
1010
+ - 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`).
1011
+
1012
+ [↑ Back to features](#features)
1013
+
732
1014
  ### Global configuration
733
1015
 
734
1016
  Use `SafeMemoize.configure` to set defaults that apply to all subsequently memoized methods. Per-call options always take precedence over global defaults.
@@ -746,7 +1028,7 @@ Both settings apply at definition time — methods already memoized before `conf
746
1028
  SafeMemoize.reset_configuration!
747
1029
  ```
748
1030
 
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)).
1031
+ 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)).
750
1032
 
751
1033
  [↑ Back to features](#features)
752
1034
 
@@ -1092,6 +1374,83 @@ end
1092
1374
 
1093
1375
  [↑ Back to features](#features)
1094
1376
 
1377
+ ## Per-class default options (`safe_memoize_options`)
1378
+
1379
+ `safe_memoize_options` sets option defaults for every `memoize` call on the class, eliminating repetition when many methods share the same TTL, LRU cap, or other option. Per-call options still take precedence; class defaults take precedence over global `SafeMemoize.configure` defaults.
1380
+
1381
+ ```ruby
1382
+ class ApiClient
1383
+ prepend SafeMemoize
1384
+ safe_memoize_options ttl: 60, max_size: 200, copy_on_read: true
1385
+
1386
+ def fetch(id) = http.get(id)
1387
+ memoize :fetch # uses ttl: 60, max_size: 200, copy_on_read: true
1388
+
1389
+ def list = http.get("/all")
1390
+ memoize :list, ttl: 300 # uses max_size: 200, copy_on_read: true; ttl: 300 overrides
1391
+ end
1392
+ ```
1393
+
1394
+ Accepted options are the same as `memoize` minus the mode-switch options (`shared:`, `fiber_local:`, `ractor_safe:`, `shared_cache:`), which must be specified per call because they change the entire execution path:
1395
+
1396
+ ```ruby
1397
+ safe_memoize_options(
1398
+ ttl: 60,
1399
+ max_size: 100,
1400
+ ttl_refresh: true,
1401
+ copy_on_read: true,
1402
+ namespace: "v2",
1403
+ if: ->(v) { v.present? },
1404
+ cache_bust: :updated_at
1405
+ )
1406
+ ```
1407
+
1408
+ Call with no arguments to clear all class-level defaults:
1409
+
1410
+ ```ruby
1411
+ MyClass.safe_memoize_options # clears — subsequent memoize calls use global config or per-call options only
1412
+ ```
1413
+
1414
+ [↑ Back to features](#features)
1415
+
1416
+ ## Copy-on-read
1417
+
1418
+ Pass `copy_on_read: true` to `memoize` to return a `dup` (or `deep_dup` when available, e.g. ActiveRecord objects) of the stored value on every cache read. This prevents callers from mutating the shared cached object:
1419
+
1420
+ ```ruby
1421
+ class ConfigService
1422
+ prepend SafeMemoize
1423
+
1424
+ def settings = {host: "localhost", port: 8080}
1425
+ memoize :settings, copy_on_read: true
1426
+ end
1427
+
1428
+ svc = ConfigService.new
1429
+ result = svc.settings
1430
+ result[:host] = "mutated" # only affects the caller's copy
1431
+
1432
+ svc.settings[:host] # => "localhost" — cache is unaffected
1433
+ ```
1434
+
1435
+ `nil` and frozen values are returned as-is (no dup attempted). `copy_on_read:` works across all cache paths: per-instance hash, LRU (`max_size:`), class-level shared (`shared: true`), fiber-local (`fiber_local: true`), and external stores. It is incompatible with `ractor_safe: true` (ractor-safe values are always frozen; rely on that guarantee instead).
1436
+
1437
+ Set it as a class-wide default with `safe_memoize_options`:
1438
+
1439
+ ```ruby
1440
+ class ReportService
1441
+ prepend SafeMemoize
1442
+ safe_memoize_options copy_on_read: true
1443
+
1444
+ def summary = build_summary
1445
+ memoize :summary
1446
+
1447
+ def details = build_details
1448
+ memoize :details
1449
+ end
1450
+ ```
1451
+
1452
+ [↑ Back to features](#features)
1453
+
1095
1454
  ## Ractor-safe shared cache
1096
1455
 
1097
1456
  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.
@@ -1222,6 +1581,17 @@ Anything **not** listed here — internal modules, private methods, `@__safe_mem
1222
1581
  | `SafeMemoize.configuration` | module method | Returns the current `Configuration` |
1223
1582
  | `SafeMemoize.reset_configuration!` | module method | Restores all configuration to defaults |
1224
1583
  | `SafeMemoize.deprecate(subject, message:, horizon:)` | module method | Emits a structured deprecation warning |
1584
+ | `SafeMemoize.shared_cache(name)` | module method | Returns named store, auto-creating a `Memory` store if absent |
1585
+ | `SafeMemoize.register_shared_cache(name, store)` | module method | Registers a custom `Stores::Base` under a name |
1586
+ | `SafeMemoize.clear_shared_cache(name)` | module method | Evicts all entries from the named store |
1587
+ | `SafeMemoize.drop_shared_cache(name)` | module method | Removes the named store from the registry |
1588
+ | `SafeMemoize.shared_caches` | module method | Returns a snapshot of the registry |
1589
+ | `SafeMemoize.reset_shared_caches!` | module method | Clears the entire registry (test teardown) |
1590
+ | `SafeMemoize.register_extension(name, ext)` | module method | Registers a plugin extension |
1591
+ | `SafeMemoize.unregister_extension(name)` | module method | Removes an extension |
1592
+ | `SafeMemoize.extensions` | module method | Returns snapshot of extension registry |
1593
+ | `SafeMemoize.reset_extensions!` | module method | Clears all extensions (test teardown) |
1594
+ | `SafeMemoize.extension_for_option(name)` | module method | Returns the extension handling the named option |
1225
1595
 
1226
1596
  ### `memoize` DSL (class method, added by `prepend SafeMemoize`)
1227
1597
 
@@ -1237,6 +1607,11 @@ Anything **not** listed here — internal modules, private methods, `@__safe_mem
1237
1607
  | `store:` | `Stores::Base \| nil` | `nil` | External cache store adapter; incompatible with `max_size:` and `shared:` |
1238
1608
  | `fiber_local:` | `Boolean` | `false` | Fiber-local cache; each fiber gets an isolated store; incompatible with `shared:` and `store:` |
1239
1609
  | `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:` |
1610
+ | `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 |
1611
+ | `shared_cache:` | `String \| nil` | `nil` | Name of a globally-registered shared store; incompatible with `shared:`, `store:`, `fiber_local:`, `ractor_safe:`, and `max_size:` |
1612
+ | `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:` |
1613
+ | `copy_on_read:` | `Boolean` | `false` | Return a `dup`/`deep_dup` of the cached value on every read; protects shared state from caller mutation; nil and frozen values pass through; incompatible with `ractor_safe:` |
1614
+ | *(extension options)* | any | — | Unknown kwargs are validated against registered extensions; raise `ArgumentError` if unclaimed |
1240
1615
 
1241
1616
  ### `memoize_all` options (class method)
1242
1617
 
@@ -1249,6 +1624,12 @@ All `memoize` option keys above, plus:
1249
1624
  | `include_protected:` | `Boolean` | `false` |
1250
1625
  | `include_private:` | `Boolean` | `false` |
1251
1626
 
1627
+ ### `safe_memoize_options` (class method)
1628
+
1629
+ | Option key | Type | Default | Notes |
1630
+ |---|---|---|---|
1631
+ | any `memoize` key except mode-switches | — | — | Accepts `ttl:`, `max_size:`, `ttl_refresh:`, `if:`, `unless:`, `key:`, `cache_bust:`, `copy_on_read:`, `namespace:`, `store:`; raises `ArgumentError` for `shared:`, `fiber_local:`, `ractor_safe:`, `shared_cache:` |
1632
+
1252
1633
  ### Instance methods (public)
1253
1634
 
1254
1635
  **Inspection**
@@ -1350,6 +1731,7 @@ All `memoize` option keys above, plus:
1350
1731
  | `statsd_client` | `Object \| nil` | `nil` |
1351
1732
  | `opentelemetry_tracer` | `Object \| nil` | `nil` |
1352
1733
  | `default_store` | `Stores::Base \| nil` | `nil` |
1734
+ | `namespace` | `String \| nil` | `nil` |
1353
1735
 
1354
1736
  ### Store adapter classes (v1.1.0+)
1355
1737
 
data/ROADMAP.md CHANGED
@@ -4,17 +4,44 @@ This document tracks the planned evolution of SafeMemoize through v1.0.0 and bey
4
4
 
5
5
  ---
6
6
 
7
+ ## v1.5.0 — Cache Invalidation
8
+
9
+ *Goal: group-level cache invalidation so related methods can be busted in one operation.*
10
+
11
+ | Feature | Description | Status |
12
+ |---|---|---|
13
+ | Memoization groups | `memoize :find, group: :database` then `reset_memo_group(:database)` to invalidate all methods tagged with the same group at once; groups can span multiple methods on the same class | Planned |
14
+
15
+ ---
16
+
17
+ ## v1.6.0 — Resilience
18
+
19
+ *Goal: make external-store memoization resilient to infrastructure failures.*
20
+
21
+ | Feature | Description | Status |
22
+ |---|---|---|
23
+ | Circuit breaker for external stores | When a `store:` adapter raises on `read` or `write`, automatically fall back to the per-instance in-process hash rather than propagating the exception; configurable error threshold and recovery probe interval | Planned |
24
+
25
+ ---
26
+
27
+ ## v1.7.0 — Advanced Store Features
28
+
29
+ *Goal: multi-process performance patterns for high-traffic deployments.*
30
+
31
+ | Feature | Description | Status |
32
+ |---|---|---|
33
+ | Multi-level (L1/L2) caching | `store: [memory_store, redis_store]` — check in-process first, fall back to the remote store on miss, and promote to L1 on read; each level can have independent TTL and eviction settings | Planned |
34
+ | Stampede protection | Probabilistic early expiry (XFetch algorithm) for external stores; recomputes slightly before a TTL expires to prevent multiple processes hitting a cold miss simultaneously | Planned |
35
+
36
+ ---
37
+
7
38
  ## v2.0.0 — Next Generation (Long Horizon)
8
39
 
9
40
  *Goal: incorporate real-world usage feedback, clean up accumulated API surface, and open a path for advanced extension.*
10
41
 
11
42
  | Feature | Description | Status |
12
43
  |---|---|---|
13
- | Plugin / extension architecture | A formal `SafeMemoize::Extension` API so third-party gems can add new options, hooks, or store adapters without monkey-patching | Planned |
14
44
  | DSL refinements | Evaluate alternative syntax proposals (`memoize_method`, block form, annotation approach) based on community feedback; introduce the preferred form with a migration path from the current API | Planned |
15
- | Cross-instance cache sharing | Beyond the class-level `shared: true`, support explicitly named shared caches that span unrelated classes | Planned |
16
- | Cache namespacing | Allow a namespace prefix on all keys for multi-tenant or versioned deployments (especially useful with external stores) | Planned |
17
- | Automatic cache busting | Optional integration with ActiveRecord's `updated_at` timestamp so object mutations automatically invalidate their own cached entries | Planned |
18
45
 
19
46
  ---
20
47
 
@@ -9,15 +9,13 @@ module SafeMemoize
9
9
  @__safe_memo_metrics__ ||= {}
10
10
  end
11
11
 
12
- def record_cache_hit(method_name, args, kwargs)
13
- cache_key = safe_memo_cache_key(method_name, args, kwargs)
12
+ def record_cache_hit(cache_key)
14
13
  metrics = memo_metrics_store
15
14
  metrics[cache_key] ||= {hits: 0, misses: 0, total_time: 0.0}
16
15
  metrics[cache_key][:hits] += 1
17
16
  end
18
17
 
19
- def record_cache_miss(method_name, args, kwargs, computation_time)
20
- cache_key = safe_memo_cache_key(method_name, args, kwargs)
18
+ def record_cache_miss(cache_key, computation_time)
21
19
  metrics = memo_metrics_store
22
20
  metrics[cache_key] ||= {hits: 0, misses: 0, total_time: 0.0}
23
21
  metrics[cache_key][:misses] += 1
@@ -31,7 +29,8 @@ module SafeMemoize
31
29
  def _reset_cache_metrics_for(method_name)
32
30
  return unless defined?(@__safe_memo_metrics__) && @__safe_memo_metrics__
33
31
 
34
- @__safe_memo_metrics__.delete_if { |key, _| key[0] == method_name }
32
+ effective = resolve_memo_key_name(method_name)
33
+ @__safe_memo_metrics__.delete_if { |key, _| key[0] == effective }
35
34
  end
36
35
  end
37
36
  end