safe_memoize 1.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 24faf0555ba5e11e37092aea14e1db090d7f5bb13dd40edc33ea30d8adcba552
4
- data.tar.gz: '09beaa5cf9e25284462bdac9636929642213a9e8d657900d30913832da230959'
3
+ metadata.gz: 3e47bd422b1516a4f775e49ad3f58bf916df60c094aae7dfddf652fa78e44d73
4
+ data.tar.gz: 2adc318d5cda8858d1ee49a2dd32080acea1e8163807e424366d9e8e42a30943
5
5
  SHA512:
6
- metadata.gz: 8ed21a58fc95a122794bcb91a1cbccae2bad598b38f736d76eb6f95551245dba070e047eaaea56b26fca6aace181e138acd37a0a027dbb7c0a6a43ae728f2cf0
7
- data.tar.gz: f51afd15884dd10c771cd3f3ffcb7d481b0fd3012ecc74148fc9ff82e056abc1a2bb044d5344d635abb5380d200f0ec62a4db528d8fc79697358f775cc4aba3e
6
+ metadata.gz: de347fc9b0e6f9ad1385f0f5f3a1cc03595f9cf8b969f354cd87bd5173e318ec9af98cf31d2253617c369a8cd7ea9d6c08fbc489d22d30775b912f6867bab70f
7
+ data.tar.gz: '08153a2284f53e6110d4f3fae0a84ae72d2739bc0146e70cbabd5a6cce30b544122204623185ad96c1706c1de518feb583a227fd8b74018b896775f884ac7053'
data/CHANGELOG.md CHANGED
@@ -8,6 +8,38 @@ from v1.0.0 onwards. Prior 0.x releases may include breaking changes between min
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.3.0] - 2026-05-28
12
+
13
+ ### Added
14
+
15
+ - `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.
16
+ - `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"}`).
17
+ - `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.
18
+ - Duck-type compatible — any object responding to `handled_options`, `process_memoize_option`, and `dispatch_cache_event` works without `extend SafeMemoize::Extension`.
19
+ - `SafeMemoize.register_extension(name, extension)` — registers an extension under a symbolic name.
20
+ - `SafeMemoize.unregister_extension(name)` — removes an extension.
21
+ - `SafeMemoize.extensions` — returns a snapshot of the registry.
22
+ - `SafeMemoize.reset_extensions!` — clears the registry (test teardown).
23
+ - `SafeMemoize.extension_for_option(option_name)` — returns the registered extension that handles the named option, or `nil`.
24
+ - `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.
25
+
26
+ - `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:`.
27
+
28
+ - `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:`.
29
+ - `SafeMemoize.shared_cache(name)` — returns the `Stores::Base` instance for the given name, creating a new `Stores::Memory` if none is registered.
30
+ - `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.
31
+ - `SafeMemoize.clear_shared_cache(name)` — calls `clear` on the named store, evicting all entries. No-op for unregistered names.
32
+ - `SafeMemoize.drop_shared_cache(name)` — removes the named store from the registry; subsequent `shared_cache(name)` calls will auto-create a new `Memory` store.
33
+ - `SafeMemoize.shared_caches` — returns a dup of the current registry as a `Hash{String => Stores::Base}`.
34
+ - `SafeMemoize.reset_shared_caches!` — clears the entire registry; useful in test-suite `after` hooks to prevent state leaking between examples.
35
+
36
+ - `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.
37
+ - `.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`.
38
+ - `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!`.
39
+ - Resolution priority: per-method `namespace:` > class `.safe_memoize_namespace` > global `Configuration#namespace`.
40
+ - 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.
41
+ - 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.
42
+
11
43
  ## [1.2.0] - 2026-05-27
12
44
 
13
45
  ### Added
data/README.md CHANGED
@@ -73,6 +73,10 @@ 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)
76
80
 
77
81
  ## Installation
78
82
 
@@ -729,6 +733,282 @@ Metrics are per-instance and reset independently from the cache itself — clear
729
733
 
730
734
  [↑ Back to features](#features)
731
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
+
732
1012
  ### Global configuration
733
1013
 
734
1014
  Use `SafeMemoize.configure` to set defaults that apply to all subsequently memoized methods. Per-call options always take precedence over global defaults.
@@ -746,7 +1026,7 @@ Both settings apply at definition time — methods already memoized before `conf
746
1026
  SafeMemoize.reset_configuration!
747
1027
  ```
748
1028
 
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)).
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)).
750
1030
 
751
1031
  [↑ Back to features](#features)
752
1032
 
@@ -1222,6 +1502,17 @@ Anything **not** listed here — internal modules, private methods, `@__safe_mem
1222
1502
  | `SafeMemoize.configuration` | module method | Returns the current `Configuration` |
1223
1503
  | `SafeMemoize.reset_configuration!` | module method | Restores all configuration to defaults |
1224
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 |
1225
1516
 
1226
1517
  ### `memoize` DSL (class method, added by `prepend SafeMemoize`)
1227
1518
 
@@ -1237,6 +1528,10 @@ Anything **not** listed here — internal modules, private methods, `@__safe_mem
1237
1528
  | `store:` | `Stores::Base \| nil` | `nil` | External cache store adapter; incompatible with `max_size:` and `shared:` |
1238
1529
  | `fiber_local:` | `Boolean` | `false` | Fiber-local cache; each fiber gets an isolated store; incompatible with `shared:` and `store:` |
1239
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 |
1240
1535
 
1241
1536
  ### `memoize_all` options (class method)
1242
1537
 
@@ -1350,6 +1645,7 @@ All `memoize` option keys above, plus:
1350
1645
  | `statsd_client` | `Object \| nil` | `nil` |
1351
1646
  | `opentelemetry_tracer` | `Object \| nil` | `nil` |
1352
1647
  | `default_store` | `Stores::Base \| nil` | `nil` |
1648
+ | `namespace` | `String \| nil` | `nil` |
1353
1649
 
1354
1650
  ### Store adapter classes (v1.1.0+)
1355
1651
 
data/ROADMAP.md CHANGED
@@ -10,11 +10,7 @@ This document tracks the planned evolution of SafeMemoize through v1.0.0 and bey
10
10
 
11
11
  | Feature | Description | Status |
12
12
  |---|---|---|
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
13
  | 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
14
 
19
15
  ---
20
16
 
@@ -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
@@ -38,6 +38,27 @@ module SafeMemoize
38
38
  # from worker Ractors. Requires +shared: true+. Cached values are deep-frozen via
39
39
  # +Ractor.make_shareable+. Incompatible with +if:+, +unless:+, +max_size:+,
40
40
  # +ttl_refresh:+, +key:+, and +store:+.
41
+ # @param namespace [String, nil] prefix prepended to every cache key for this method,
42
+ # scoping it to a logical partition. Takes precedence over both the class-level
43
+ # {#safe_memoize_namespace} and the global {SafeMemoize::Configuration#namespace}.
44
+ # Useful for versioning a single method independently of its peers. Must not contain
45
+ # the character +:+.
46
+ # @param cache_bust [Proc, Symbol, nil] callable invoked on the instance (via
47
+ # +instance_exec+) on every cache lookup to obtain a version token. The token is
48
+ # folded into the cache key alongside the normal arguments, so when the token
49
+ # changes (e.g. an ActiveRecord +updated_at+ timestamp advances after a +save+)
50
+ # the old key no longer matches any entry — the method is recomputed and the result
51
+ # stored under the new key. Accepts any callable (+Proc+, +lambda+, +Method+) that
52
+ # takes no arguments, or a +Symbol+ naming an instance method. Cannot be combined
53
+ # with +key:+.
54
+ # @param shared_cache [String, nil] name of a globally-registered shared cache store
55
+ # (see {SafeMemoize.shared_cache} and {SafeMemoize.register_shared_cache}). All
56
+ # instances of any class that memoizes a method with the same +shared_cache:+ name
57
+ # read and write the same backing store, enabling cross-class cache sharing.
58
+ # The store is resolved at +memoize+ definition time; call
59
+ # {SafeMemoize.register_shared_cache} before the class is loaded to supply a custom
60
+ # adapter. Incompatible with +shared:+, +store:+, +fiber_local:+, +ractor_safe:+,
61
+ # and +max_size:+. Composes naturally with +namespace:+, +ttl:+, +if:+, and +key:+.
41
62
  # @return [void]
42
63
  # @raise [ArgumentError] if the method does not exist, or option values are invalid
43
64
  #
@@ -55,13 +76,32 @@ module SafeMemoize
55
76
  # @example With a custom store
56
77
  # STORE = SafeMemoize::Stores::Memory.new
57
78
  # memoize :fetch, store: STORE, ttl: 300
58
- def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, unless: nil, shared: false, key: nil, store: nil, fiber_local: false, ractor_safe: false)
79
+ def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, unless: nil, shared: false, key: nil, store: nil, fiber_local: false, ractor_safe: false, namespace: nil, shared_cache: nil, cache_bust: nil, **extension_options)
59
80
  method_name = method_name.to_sym
60
81
 
61
82
  unless method_defined?(method_name) || private_method_defined?(method_name) || protected_method_defined?(method_name)
62
83
  raise ArgumentError, "cannot memoize :#{method_name} — no instance method with that name is defined on #{self}"
63
84
  end
64
85
 
86
+ unless extension_options.empty?
87
+ extension_options.each_key do |opt|
88
+ raise ArgumentError, "unknown memoize option :#{opt} — no registered extension handles it" unless SafeMemoize.extension_for_option(opt)
89
+ end
90
+
91
+ injected = {}
92
+ extension_options.each do |opt, val|
93
+ result = SafeMemoize.extension_for_option(opt).process_memoize_option(opt, val, method_name, extension_options)
94
+ injected.merge!(result)
95
+ end
96
+
97
+ ttl = injected[:ttl] if injected.key?(:ttl)
98
+ max_size = injected[:max_size] if injected.key?(:max_size)
99
+ namespace = injected[:namespace] if injected.key?(:namespace)
100
+ store = injected[:store] if injected.key?(:store)
101
+ shared_cache = injected[:shared_cache] if injected.key?(:shared_cache)
102
+ cache_bust = injected[:cache_bust] if injected.key?(:cache_bust)
103
+ end
104
+
65
105
  visibility = memoized_method_visibility(method_name)
66
106
 
67
107
  config = SafeMemoize.configuration
@@ -102,6 +142,13 @@ module SafeMemoize
102
142
  raise ArgumentError, ":unless must be callable" if cond_unless && !cond_unless.respond_to?(:call)
103
143
  raise ArgumentError, ":key must be callable" if key && !key.respond_to?(:call)
104
144
 
145
+ if cache_bust
146
+ unless cache_bust.respond_to?(:call) || cache_bust.is_a?(Symbol)
147
+ raise ArgumentError, "cache_bust: must be a callable or Symbol (got #{cache_bust.class})"
148
+ end
149
+ raise ArgumentError, "cache_bust: and key: cannot be combined" if key
150
+ end
151
+
105
152
  if store
106
153
  raise ArgumentError, "store: must be a SafeMemoize::Stores::Base instance (got #{store.class})" unless store.is_a?(SafeMemoize::Stores::Base)
107
154
  raise ArgumentError, "max_size: is not supported with store: — use the store adapter's own eviction" if max_size
@@ -122,6 +169,24 @@ module SafeMemoize
122
169
  raise ArgumentError, "ractor_safe: is incompatible with store:" if store
123
170
  end
124
171
 
172
+ if namespace
173
+ raise ArgumentError, "namespace: must be a String (got #{namespace.class})" unless namespace.is_a?(String)
174
+ raise ArgumentError, "namespace: must not be empty" if namespace.empty?
175
+ raise ArgumentError, "namespace: must not contain ':'" if namespace.include?(":")
176
+ __safe_memo_method_namespaces__[method_name] = namespace
177
+ end
178
+
179
+ if shared_cache
180
+ raise ArgumentError, "shared_cache: must be a String (got #{shared_cache.class})" unless shared_cache.is_a?(String)
181
+ raise ArgumentError, "shared_cache: must not be empty" if shared_cache.empty?
182
+ raise ArgumentError, "shared_cache: and shared: cannot be combined" if shared
183
+ raise ArgumentError, "shared_cache: and store: cannot be combined" if store
184
+ raise ArgumentError, "shared_cache: and fiber_local: cannot be combined" if fiber_local
185
+ raise ArgumentError, "shared_cache: and ractor_safe: cannot be combined" if ractor_safe
186
+ raise ArgumentError, "max_size: is not supported with shared_cache: — use the store adapter's own eviction" if max_size
187
+ store = SafeMemoize.shared_cache(shared_cache)
188
+ end
189
+
125
190
  # Resolve effective store: per-method store: wins; then class-level
126
191
  # safe_memoize_store; then global default_store. max_size: and shared:
127
192
  # are incompatible with external stores — fall back silently.
@@ -143,6 +208,7 @@ module SafeMemoize
143
208
  end
144
209
 
145
210
  __safe_memo_class_key_generators__[method_name] = key if key
211
+ __safe_memo_class_cache_bust_generators__[method_name] = cache_bust if cache_bust
146
212
 
147
213
  # Normalize to a single "should cache?" predicate
148
214
  condition = if cond_if
@@ -163,7 +229,7 @@ module SafeMemoize
163
229
 
164
230
  unless cached.equal?(miss)
165
231
  effective_store.write(cache_key, cached, expires_in: ttl) if ttl_refresh
166
- record_cache_hit(method_name, args, kwargs)
232
+ record_cache_hit(cache_key)
167
233
  call_memo_hooks(:on_hit, cache_key, {value: cached, expires_at: nil, cached_at: nil})
168
234
  return cached
169
235
  end
@@ -180,7 +246,7 @@ module SafeMemoize
180
246
  call_memo_hooks(:on_store, cache_key, {value: value, expires_at: nil, cached_at: now})
181
247
  end
182
248
 
183
- record_cache_miss(method_name, args, kwargs, elapsed_time)
249
+ record_cache_miss(cache_key, elapsed_time)
184
250
  call_memo_hooks(:on_miss, cache_key, {value: value, expires_at: nil, cached_at: now})
185
251
 
186
252
  value
@@ -210,7 +276,7 @@ module SafeMemoize
210
276
  lru[cache_key] = true
211
277
  end
212
278
  record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
213
- record_cache_hit(method_name, args, kwargs)
279
+ record_cache_hit(cache_key)
214
280
  call_memo_hooks(:on_hit, cache_key, record)
215
281
  memo_record_value(record)
216
282
  else
@@ -243,7 +309,7 @@ module SafeMemoize
243
309
  call_memo_hooks(:on_store, cache_key, new_record)
244
310
  end
245
311
 
246
- record_cache_miss(method_name, args, kwargs, elapsed_time)
312
+ record_cache_miss(cache_key, elapsed_time)
247
313
  call_memo_hooks(:on_miss, cache_key, new_record)
248
314
 
249
315
  value
@@ -288,7 +354,7 @@ module SafeMemoize
288
354
  lru[cache_key] = true
289
355
  end
290
356
  record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
291
- record_cache_hit(method_name, args, kwargs)
357
+ record_cache_hit(cache_key)
292
358
  call_memo_hooks(:on_hit, cache_key, record)
293
359
  record[:value]
294
360
  else
@@ -319,7 +385,7 @@ module SafeMemoize
319
385
  call_memo_hooks(:on_store, cache_key, new_record)
320
386
  end
321
387
 
322
- record_cache_miss(method_name, args, kwargs, elapsed_time)
388
+ record_cache_miss(cache_key, elapsed_time)
323
389
  call_memo_hooks(:on_miss, cache_key, new_record)
324
390
 
325
391
  value
@@ -349,7 +415,7 @@ module SafeMemoize
349
415
  if record
350
416
  lru_touch(method_name, cache_key) if max_size
351
417
  record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
352
- record_cache_hit(method_name, args, kwargs)
418
+ record_cache_hit(cache_key)
353
419
  call_memo_hooks(:on_hit, cache_key, record)
354
420
  memo_record_value(record)
355
421
  else
@@ -365,7 +431,7 @@ module SafeMemoize
365
431
  lru_touch(method_name, cache_key) if max_size
366
432
  call_memo_hooks(:on_store, cache_key, new_record)
367
433
  end
368
- record_cache_miss(method_name, args, kwargs, elapsed_time)
434
+ record_cache_miss(cache_key, elapsed_time)
369
435
  call_memo_hooks(:on_miss, cache_key, new_record)
370
436
 
371
437
  value
@@ -374,7 +440,7 @@ module SafeMemoize
374
440
  else
375
441
  # Fast path: check without lock
376
442
  if (record = memo_cache_record(cache_key))
377
- record_cache_hit(method_name, args, kwargs)
443
+ record_cache_hit(cache_key)
378
444
  call_memo_hooks(:on_hit, cache_key, record)
379
445
  return memo_record_value(record)
380
446
  end
@@ -387,7 +453,7 @@ module SafeMemoize
387
453
  elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
388
454
 
389
455
  with_memo_lock do
390
- record_cache_miss(method_name, args, kwargs, elapsed_time)
456
+ record_cache_miss(cache_key, elapsed_time)
391
457
  new_record = memo_cache_record(cache_key)
392
458
  call_memo_hooks(:on_store, cache_key, new_record)
393
459
  call_memo_hooks(:on_miss, cache_key, new_record)
@@ -429,6 +495,31 @@ module SafeMemoize
429
495
  @__safe_memoize_store__ = store
430
496
  end
431
497
 
498
+ # Returns the class-level namespace prefix, or +nil+ if not set.
499
+ #
500
+ # When set, this prefix is prepended to every cache key produced by +memoize+
501
+ # calls on this class that do not specify their own +namespace:+ option.
502
+ # The global {SafeMemoize::Configuration#namespace} is the final fallback.
503
+ #
504
+ # @return [String, nil]
505
+ def safe_memoize_namespace
506
+ @__safe_memoize_namespace__
507
+ end
508
+
509
+ # Sets the class-level namespace prefix.
510
+ #
511
+ # @param ns [String, nil] a non-empty string without +:+, or +nil+ to clear
512
+ # @return [String, nil]
513
+ # @raise [ArgumentError] if +ns+ is not a valid namespace string
514
+ def safe_memoize_namespace=(ns)
515
+ if ns
516
+ raise ArgumentError, "safe_memoize_namespace= must be a String (got #{ns.class})" unless ns.is_a?(String)
517
+ raise ArgumentError, "safe_memoize_namespace= must not be empty" if ns.empty?
518
+ raise ArgumentError, "safe_memoize_namespace= must not contain ':'" if ns.include?(":")
519
+ end
520
+ @__safe_memoize_namespace__ = ns
521
+ end
522
+
432
523
  # Memoizes every eligible public instance method defined directly on the class.
433
524
  #
434
525
  # Accepts all options that {#memoize} accepts, plus +:except:+ and +:only:+.
@@ -470,14 +561,15 @@ module SafeMemoize
470
561
  # @return [void]
471
562
  def reset_shared_memo(method_name, *args, **kwargs)
472
563
  method_name = method_name.to_sym
473
- specific_key = (args.empty? && kwargs.empty?) ? nil : [method_name, args, kwargs]
564
+ effective = __safe_memo_effective_key_name__(method_name)
565
+ specific_key = (args.empty? && kwargs.empty?) ? nil : [effective, args, kwargs]
474
566
 
475
567
  __safe_memo_shared_mutex__.synchronize do
476
568
  if specific_key
477
569
  __safe_memo_shared_cache__.delete(specific_key)
478
570
  __safe_memo_shared_lru_order__[method_name]&.delete(specific_key)
479
571
  else
480
- __safe_memo_shared_cache__.delete_if { |key, _| key[0] == method_name }
572
+ __safe_memo_shared_cache__.delete_if { |key, _| key[0] == effective }
481
573
  __safe_memo_shared_lru_order__.delete(method_name)
482
574
  end
483
575
  end
@@ -500,7 +592,8 @@ module SafeMemoize
500
592
  # @return [Boolean]
501
593
  def shared_memoized?(method_name, *args, **kwargs)
502
594
  method_name = method_name.to_sym
503
- cache_key = [method_name, args, kwargs]
595
+ effective = __safe_memo_effective_key_name__(method_name)
596
+ cache_key = [effective, args, kwargs]
504
597
 
505
598
  __safe_memo_shared_mutex__.synchronize do
506
599
  cache = @__safe_memo_shared_cache__
@@ -523,7 +616,12 @@ module SafeMemoize
523
616
  cache = @__safe_memo_shared_cache__ || {}
524
617
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
525
618
  live = cache.reject { |_, r| r[:expires_at] && r[:expires_at] <= now }
526
- method_name ? live.count { |key, _| key[0] == method_name.to_sym } : live.count
619
+ if method_name
620
+ effective = __safe_memo_effective_key_name__(method_name.to_sym)
621
+ live.count { |key, _| key[0] == effective }
622
+ else
623
+ live.count
624
+ end
527
625
  end
528
626
  end
529
627
 
@@ -536,7 +634,8 @@ module SafeMemoize
536
634
  # @return [Float, nil]
537
635
  def shared_memo_age(method_name, *args, **kwargs)
538
636
  method_name = method_name.to_sym
539
- cache_key = [method_name, args, kwargs]
637
+ effective = __safe_memo_effective_key_name__(method_name)
638
+ cache_key = [effective, args, kwargs]
540
639
 
541
640
  __safe_memo_shared_mutex__.synchronize do
542
641
  cache = @__safe_memo_shared_cache__
@@ -563,7 +662,8 @@ module SafeMemoize
563
662
  # @return [Boolean]
564
663
  def shared_memo_stale?(method_name, *args, **kwargs)
565
664
  method_name = method_name.to_sym
566
- cache_key = [method_name, args, kwargs]
665
+ effective = __safe_memo_effective_key_name__(method_name)
666
+ cache_key = [effective, args, kwargs]
567
667
 
568
668
  __safe_memo_shared_mutex__.synchronize do
569
669
  cache = @__safe_memo_shared_cache__
@@ -597,6 +697,25 @@ module SafeMemoize
597
697
  @__safe_memo_class_key_generators__ ||= {}
598
698
  end
599
699
 
700
+ def __safe_memo_method_namespaces__
701
+ @__safe_memo_method_namespaces__ ||= {}
702
+ end
703
+
704
+ def __safe_memo_class_cache_bust_generators__
705
+ @__safe_memo_class_cache_bust_generators__ ||= {}
706
+ end
707
+
708
+ # Resolves the effective first-element key sym for a given bare method name,
709
+ # applying the active namespace. Used by class-level cache operations where
710
+ # instance methods (compute_cache_key) are unavailable.
711
+ def __safe_memo_effective_key_name__(method_name)
712
+ ns_map = @__safe_memo_method_namespaces__
713
+ ns = (ns_map && ns_map[method_name]) ||
714
+ @__safe_memoize_namespace__ ||
715
+ SafeMemoize.configuration.namespace
716
+ ns ? :"#{ns}:#{method_name}" : method_name
717
+ end
718
+
600
719
  def memoized_method_visibility(method_name)
601
720
  return :private if private_method_defined?(method_name)
602
721
  return :protected if protected_method_defined?(method_name)
@@ -627,7 +746,7 @@ module SafeMemoize
627
746
  response = Ractor.receive_if { |m| m.is_a?(Array) && m[0] == tag }[1]
628
747
 
629
748
  if response[:hit]
630
- record_cache_hit(method_name, args, kwargs)
749
+ record_cache_hit(cache_key)
631
750
  call_memo_hooks(:on_hit, cache_key, response[:record])
632
751
  return response[:record][:value]
633
752
  end
@@ -653,7 +772,7 @@ module SafeMemoize
653
772
  stored = Ractor.receive_if { |m| m.is_a?(Array) && m[0] == tag }[1]
654
773
  stored_record = stored[:stored]
655
774
 
656
- record_cache_miss(method_name, args, kwargs, elapsed_time)
775
+ record_cache_miss(cache_key, elapsed_time)
657
776
  call_memo_hooks(:on_store, cache_key, stored_record)
658
777
  call_memo_hooks(:on_miss, cache_key, stored_record)
659
778
 
@@ -48,6 +48,13 @@ module SafeMemoize
48
48
  # store and will silently continue using the per-instance hash even when this is set.
49
49
  attr_accessor :default_store
50
50
 
51
+ # @return [String, nil] Global namespace prefix applied to every cache key produced by
52
+ # {ClassMethods#memoize}. Useful for versioned deployments (change the namespace to
53
+ # bust all in-flight cached values) and multi-tenant setups (scope keys to a tenant
54
+ # identifier). A class-level {ClassMethods#safe_memoize_namespace} or a per-method
55
+ # +namespace:+ option takes precedence over this value. +nil+ means no prefix.
56
+ attr_accessor :namespace
57
+
51
58
  # @api private
52
59
  def initialize
53
60
  @default_ttl = nil
@@ -58,6 +65,7 @@ module SafeMemoize
58
65
  @statsd_client = nil
59
66
  @opentelemetry_tracer = nil
60
67
  @default_store = nil
68
+ @namespace = nil
61
69
  end
62
70
  end
63
71
  end
@@ -19,14 +19,23 @@ module SafeMemoize
19
19
  def compute_cache_key(method_name, args, kwargs)
20
20
  method_name = method_name.to_sym
21
21
 
22
+ ns = __safe_memo_resolve_namespace__(method_name)
23
+ effective_name = ns ? :"#{ns}:#{method_name}" : method_name
24
+
22
25
  # Instance-level key generator takes priority over class-level
23
26
  key_block = custom_key_store[method_name] ||
24
27
  self.class.send(:__safe_memo_class_key_generators__)[method_name]
25
28
 
26
29
  if key_block
27
- [method_name, key_block.call(*args, **kwargs)]
30
+ [effective_name, key_block.call(*args, **kwargs)]
28
31
  else
29
- safe_memo_cache_key(method_name, args, kwargs)
32
+ bust_block = self.class.send(:__safe_memo_class_cache_bust_generators__)[method_name]
33
+ if bust_block
34
+ token = bust_block.is_a?(Symbol) ? send(bust_block) : instance_exec(&bust_block)
35
+ [effective_name, [deep_freeze_copy(args), deep_freeze_copy(kwargs), token]]
36
+ else
37
+ safe_memo_cache_key(effective_name, args, kwargs)
38
+ end
30
39
  end
31
40
  end
32
41
 
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeMemoize
4
+ # Mixin for defining SafeMemoize extensions.
5
+ #
6
+ # Extend this module in any Ruby module or class that you want to register
7
+ # as a SafeMemoize extension. It provides a DSL for declaring custom
8
+ # +memoize+ options and global cache lifecycle event handlers.
9
+ #
10
+ # @example Defining an extension
11
+ # module MyExtension
12
+ # extend SafeMemoize::Extension
13
+ #
14
+ # handles_option :active_record_bust do |value, method_name, _options|
15
+ # { cache_bust: -> { send(:updated_at) } }
16
+ # end
17
+ #
18
+ # on_cache_event :miss do |klass, method_name, _cache_key, _record|
19
+ # Rails.logger.debug "cache miss: #{klass}##{method_name}"
20
+ # end
21
+ # end
22
+ #
23
+ # SafeMemoize.register_extension(:active_record_bust, MyExtension)
24
+ module Extension
25
+ # Declares a custom +memoize+ option handled by this extension.
26
+ #
27
+ # The block is called at +memoize+ definition time whenever +option_name+
28
+ # appears in the +memoize+ keyword arguments. It receives the option value,
29
+ # the method name being memoized, and the full hash of other extension options
30
+ # passed to that +memoize+ call. It must return a +Hash+ of standard
31
+ # {ClassMethods#memoize} options to inject (e.g. +{ cache_bust: ... }+), or
32
+ # +nil+/empty hash for no injection.
33
+ #
34
+ # @param option_name [Symbol]
35
+ # @yieldparam value [Object] the option value supplied by the caller
36
+ # @yieldparam method_name [Symbol] the method being memoized
37
+ # @yieldparam all_options [Hash] other extension options in the same +memoize+ call
38
+ # @yieldreturn [Hash, nil] standard memoize options to inject
39
+ # @return [void]
40
+ def handles_option(option_name, &processor)
41
+ @__handled_options__ ||= {}
42
+ @__handled_options__[option_name.to_sym] = processor
43
+ end
44
+
45
+ # Registers a global cache lifecycle event handler.
46
+ #
47
+ # The block fires after every matching cache event across *all* memoized
48
+ # methods on all classes. Multiple event types can be listed in a single
49
+ # call. Valid types are +:on_hit+, +:on_miss+, +:on_store+, +:on_expire+,
50
+ # and +:on_evict+.
51
+ #
52
+ # Handlers execute on the main Ractor only; they are silently skipped from
53
+ # worker Ractors.
54
+ #
55
+ # @param event_types [Array<Symbol>] one or more of +:on_hit+, +:on_miss+,
56
+ # +:on_store+, +:on_expire+, +:on_evict+
57
+ # @yieldparam klass [Class] the class whose instance triggered the event
58
+ # @yieldparam method_name [Symbol] bare method name (namespace stripped)
59
+ # @yieldparam cache_key [Array] the full cache key
60
+ # @yieldparam record [Hash, nil] the cache record (+value+, +expires_at+, +cached_at+)
61
+ # @return [void]
62
+ def on_cache_event(*event_types, &handler)
63
+ @__event_handlers__ ||= {}
64
+ event_types.each { |type| (@__event_handlers__[type.to_sym] ||= []) << handler }
65
+ end
66
+
67
+ # @api private
68
+ def handled_options
69
+ @__handled_options__&.keys || []
70
+ end
71
+
72
+ # @api private
73
+ def process_memoize_option(option_name, value, method_name, all_options)
74
+ processor = @__handled_options__&.[](option_name.to_sym)
75
+ result = processor&.call(value, method_name, all_options)
76
+ result.is_a?(Hash) ? result : {}
77
+ end
78
+
79
+ # @api private
80
+ def dispatch_cache_event(event_type, klass, method_name, cache_key, record)
81
+ return unless @__event_handlers__
82
+
83
+ (@__event_handlers__[event_type] || []).each do |handler|
84
+ handler.call(klass, method_name, cache_key, record)
85
+ end
86
+ end
87
+ end
88
+ end
@@ -42,7 +42,8 @@ module SafeMemoize
42
42
  return unless cache
43
43
 
44
44
  if args.empty? && kwargs.empty?
45
- cache.delete_if { |key, _| key[0] == method_name }
45
+ effective = resolve_memo_key_name(method_name)
46
+ cache.delete_if { |key, _| key[0] == effective }
46
47
  fiber_memo_lru_or_nil&.delete(method_name)
47
48
  else
48
49
  cache_key = compute_cache_key(method_name, args, kwargs)
@@ -49,6 +49,8 @@ module SafeMemoize
49
49
  if (client = SafeMemoize.configuration.statsd_client)
50
50
  Adapters::StatsD.dispatch(client, hook_type, cache_key, self.class.name)
51
51
  end
52
+
53
+ SafeMemoize.dispatch_extension_events(hook_type, self.class, safe_memo_bare_method_name(cache_key[0]), cache_key, record)
52
54
  end
53
55
 
54
56
  def safe_memo_notify(hook_type, cache_key)
@@ -13,7 +13,8 @@ module SafeMemoize
13
13
 
14
14
  def memo_matcher_for(method_name, args, kwargs)
15
15
  if args.empty? && kwargs.empty?
16
- ->(key) { key[0] == method_name }
16
+ effective = resolve_memo_key_name(method_name)
17
+ ->(key) { key[0] == effective }
17
18
  else
18
19
  cache_key = compute_cache_key(method_name, args, kwargs)
19
20
  ->(key) { key == cache_key }
@@ -28,7 +29,8 @@ module SafeMemoize
28
29
  entries = cache.to_a
29
30
  return entries unless method_name
30
31
 
31
- entries.select { |(cache_key, _)| cache_key[0] == method_name }
32
+ effective = resolve_memo_key_name(method_name)
33
+ entries.select { |(cache_key, _)| cache_key[0] == effective }
32
34
  end
33
35
 
34
36
  def safe_memo_count_for(method_name)
@@ -57,14 +59,14 @@ module SafeMemoize
57
59
  # Custom keys are [method, custom_key] (2 elements); default keys are
58
60
  # [method, args, kwargs] (3 elements). Detect and surface accordingly.
59
61
  if cache_key.length == 2
60
- method_name, custom_key = cache_key
62
+ effective_name, custom_key = cache_key
61
63
  payload = {custom_key: custom_key}
62
64
  else
63
- method_name, args, kwargs = cache_key
65
+ effective_name, args, kwargs = cache_key
64
66
  payload = {args: args, kwargs: kwargs}
65
67
  end
66
68
 
67
- payload[:method] = method_name if include_method
69
+ payload[:method] = safe_memo_bare_method_name(effective_name) if include_method
68
70
  payload[:value] = memo_record_value(value) if include_value
69
71
  payload
70
72
  end
@@ -73,6 +75,33 @@ module SafeMemoize
73
75
  [method_name.to_sym, deep_freeze_copy(args), deep_freeze_copy(kwargs)]
74
76
  end
75
77
 
78
+ # Returns the active namespace string for a bare method name, or nil.
79
+ # Priority: per-method namespace: option > class safe_memoize_namespace > global.
80
+ #
81
+ # Uses instance_variable_get throughout so this is safe to call from worker
82
+ # Ractors (reads only; lazy-init ivars must not be triggered in that context).
83
+ def __safe_memo_resolve_namespace__(method_name)
84
+ ns_map = self.class.instance_variable_get(:@__safe_memo_method_namespaces__)
85
+ (ns_map && ns_map[method_name]) ||
86
+ self.class.instance_variable_get(:@__safe_memoize_namespace__) ||
87
+ SafeMemoize.instance_variable_get(:@configuration)&.namespace
88
+ end
89
+
90
+ # Returns the effective cache key sym for a bare method name, applying any
91
+ # active namespace (per-method > class-level > global).
92
+ def resolve_memo_key_name(method_name)
93
+ ns = __safe_memo_resolve_namespace__(method_name)
94
+ ns ? :"#{ns}:#{method_name}" : method_name
95
+ end
96
+
97
+ # Strips the namespace prefix from an effective key sym, returning the bare
98
+ # method name. When no namespace is present the sym is returned unchanged.
99
+ def safe_memo_bare_method_name(key_sym)
100
+ s = key_sym.to_s
101
+ colon_idx = s.index(":")
102
+ colon_idx ? s[(colon_idx + 1)..].to_sym : key_sym
103
+ end
104
+
76
105
  def deep_freeze_copy(obj)
77
106
  case obj
78
107
  when Array
@@ -214,7 +214,8 @@ module SafeMemoize
214
214
 
215
215
  with_memo_lock do
216
216
  cache = memo_cache_or_nil || {}
217
- entries = method_name ? cache.select { |key, _| key[0] == method_name } : cache.dup
217
+ effective = method_name && resolve_memo_key_name(method_name)
218
+ entries = effective ? cache.select { |key, _| key[0] == effective } : cache.dup
218
219
  entries.select! { |_, record| memo_record_live?(record) }
219
220
  entries.transform_values { |record| memo_record_value(record) }
220
221
  end
@@ -401,7 +402,7 @@ module SafeMemoize
401
402
 
402
403
  age = (now - record[:cached_at]).round(6) if record[:cached_at]
403
404
 
404
- metrics_key = safe_memo_cache_key(method_name, args, kwargs)
405
+ metrics_key = compute_cache_key(method_name, args, kwargs)
405
406
  entry_metrics = memo_metrics_store[metrics_key] || {hits: 0, misses: 0}
406
407
 
407
408
  custom_key = (cache_key.length == 2) ? cache_key[1] : nil
@@ -26,7 +26,8 @@ module SafeMemoize
26
26
  method_name = method_name.to_sym
27
27
 
28
28
  with_memo_lock do
29
- metrics = memo_metrics_store.select { |key, _| key[0] == method_name }
29
+ effective = resolve_memo_key_name(method_name)
30
+ metrics = memo_metrics_store.select { |key, _| key[0] == effective }
30
31
  return empty_stats.merge(method: method_name) if metrics.empty?
31
32
 
32
33
  aggregate_metrics(metrics, include_method: false).merge(method: method_name)
@@ -73,13 +74,13 @@ module SafeMemoize
73
74
  avg_time = total_misses.zero? ? 0.0 : (total_time / total_misses).round(6)
74
75
 
75
76
  entries = metrics.map do |cache_key, stats|
76
- method_name, args, _kwargs = cache_key
77
+ effective_name, args, _kwargs = cache_key
77
78
  entry_calls = stats[:hits] + stats[:misses]
78
79
  entry_hit_rate = entry_calls.zero? ? 0.0 : (stats[:hits].to_f / entry_calls * 100).round(2)
79
80
 
80
81
  entry = {args: args, hits: stats[:hits], misses: stats[:misses],
81
82
  hit_rate: entry_hit_rate, computation_time: stats[:total_time].round(6)}
82
- entry[:method] = method_name if include_method
83
+ entry[:method] = safe_memo_bare_method_name(effective_name) if include_method
83
84
  entry
84
85
  end
85
86
 
@@ -2,5 +2,5 @@
2
2
 
3
3
  module SafeMemoize
4
4
  # The current gem version string.
5
- VERSION = "1.2.0"
5
+ VERSION = "1.3.0"
6
6
  end
data/lib/safe_memoize.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "safe_memoize/version"
4
4
  require_relative "safe_memoize/configuration"
5
+ require_relative "safe_memoize/extension"
5
6
  require_relative "safe_memoize/stores/base"
6
7
  require_relative "safe_memoize/stores/memory"
7
8
  require_relative "safe_memoize/adapters/statsd"
@@ -54,6 +55,16 @@ module SafeMemoize
54
55
  # Rescue this to catch any error raised by the library itself.
55
56
  class Error < StandardError; end
56
57
 
58
+ # @api private
59
+ SHARED_CACHE_REGISTRY = {}
60
+ # @api private
61
+ SHARED_CACHE_MUTEX = Mutex.new
62
+
63
+ # @api private
64
+ EXTENSION_REGISTRY = {}
65
+ # @api private
66
+ EXTENSION_MUTEX = Mutex.new
67
+
57
68
  include InstanceMethods
58
69
 
59
70
  # @api private
@@ -102,4 +113,131 @@ module SafeMemoize
102
113
  handler = configuration.on_deprecation
103
114
  handler ? handler.call(text) : warn(text)
104
115
  end
116
+
117
+ # Returns the named shared cache store, creating a new in-process
118
+ # {Stores::Memory} instance if one has not been registered under +name+.
119
+ #
120
+ # Use {register_shared_cache} to supply a custom adapter (e.g. Redis) before
121
+ # any class that references the same name is loaded.
122
+ #
123
+ # @param name [String] the logical cache name
124
+ # @return [Stores::Base]
125
+ def self.shared_cache(name)
126
+ SHARED_CACHE_MUTEX.synchronize do
127
+ SHARED_CACHE_REGISTRY[name] ||= Stores::Memory.new
128
+ end
129
+ end
130
+
131
+ # Registers a custom store under +name+, replacing any existing entry.
132
+ #
133
+ # Must be called *before* any class that references +name+ via +shared_cache:+
134
+ # is loaded, because the store is captured at +memoize+ definition time.
135
+ #
136
+ # @param name [String] the logical cache name
137
+ # @param store [Stores::Base] any {Stores::Base} subclass instance
138
+ # @return [Stores::Base] the registered store
139
+ # @raise [ArgumentError] if +store+ is not a {Stores::Base} instance
140
+ def self.register_shared_cache(name, store)
141
+ unless store.is_a?(Stores::Base)
142
+ raise ArgumentError, "store must be a SafeMemoize::Stores::Base instance (got #{store.class})"
143
+ end
144
+ SHARED_CACHE_MUTEX.synchronize { SHARED_CACHE_REGISTRY[name] = store }
145
+ end
146
+
147
+ # Clears all entries in the named shared cache without removing it from the
148
+ # registry. A no-op when no cache is registered under +name+.
149
+ #
150
+ # @param name [String]
151
+ # @return [void]
152
+ def self.clear_shared_cache(name)
153
+ SHARED_CACHE_MUTEX.synchronize { SHARED_CACHE_REGISTRY[name]&.clear }
154
+ end
155
+
156
+ # Removes the named shared cache from the registry entirely.
157
+ # Subsequent +shared_cache(name)+ calls will create a fresh store.
158
+ #
159
+ # @param name [String]
160
+ # @return [Stores::Base, nil] the removed store, or +nil+ if not present
161
+ def self.drop_shared_cache(name)
162
+ SHARED_CACHE_MUTEX.synchronize { SHARED_CACHE_REGISTRY.delete(name) }
163
+ end
164
+
165
+ # Returns a snapshot of the current registry as a plain +Hash+.
166
+ #
167
+ # @return [Hash{String => Stores::Base}]
168
+ def self.shared_caches
169
+ SHARED_CACHE_MUTEX.synchronize { SHARED_CACHE_REGISTRY.dup }
170
+ end
171
+
172
+ # Removes all named shared caches from the registry.
173
+ #
174
+ # Useful in test suite +after+ hooks to prevent state leaking between examples.
175
+ #
176
+ # @return [void]
177
+ def self.reset_shared_caches!
178
+ SHARED_CACHE_MUTEX.synchronize { SHARED_CACHE_REGISTRY.clear }
179
+ end
180
+
181
+ # Registers an extension under +name+.
182
+ #
183
+ # An extension is any Ruby object that optionally responds to the
184
+ # {Extension} interface: {Extension#handled_options}, {Extension#process_memoize_option},
185
+ # and {Extension#dispatch_cache_event}. Use {Extension} as a mixin to get a
186
+ # convenient DSL for defining these.
187
+ #
188
+ # @param name [Symbol, String] a unique identifier for this extension
189
+ # @param extension [Object] the extension object or module
190
+ # @return [Object] the registered extension
191
+ def self.register_extension(name, extension)
192
+ EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY[name.to_sym] = extension }
193
+ end
194
+
195
+ # Removes an extension from the registry.
196
+ #
197
+ # @param name [Symbol, String]
198
+ # @return [Object, nil] the removed extension, or +nil+ if not present
199
+ def self.unregister_extension(name)
200
+ EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY.delete(name.to_sym) }
201
+ end
202
+
203
+ # Returns a snapshot of all registered extensions as a +Hash+.
204
+ #
205
+ # @return [Hash{Symbol => Object}]
206
+ def self.extensions
207
+ EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY.dup }
208
+ end
209
+
210
+ # Removes all registered extensions.
211
+ #
212
+ # Useful in test suite +after+ hooks to prevent state leaking between examples.
213
+ #
214
+ # @return [void]
215
+ def self.reset_extensions!
216
+ EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY.clear }
217
+ end
218
+
219
+ # Returns the first registered extension that declares it handles +option_name+,
220
+ # or +nil+ if none does.
221
+ #
222
+ # @param option_name [Symbol]
223
+ # @return [Object, nil]
224
+ def self.extension_for_option(option_name)
225
+ sym = option_name.to_sym
226
+ EXTENSION_MUTEX.synchronize do
227
+ EXTENSION_REGISTRY.values.find do |ext|
228
+ ext.respond_to?(:handled_options) && ext.handled_options.include?(sym)
229
+ end
230
+ end
231
+ end
232
+
233
+ # Dispatches a cache lifecycle event to all registered extensions that
234
+ # respond to +dispatch_cache_event+.
235
+ #
236
+ # @api private
237
+ def self.dispatch_extension_events(event_type, klass, method_name, cache_key, record)
238
+ exts = EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY.values.dup }
239
+ exts.each do |ext|
240
+ ext.dispatch_cache_event(event_type, klass, method_name, cache_key, record) if ext.respond_to?(:dispatch_cache_event)
241
+ end
242
+ end
105
243
  end
data/sig/safe_memoize.rbs CHANGED
@@ -18,11 +18,36 @@ module SafeMemoize
18
18
  @__safe_memo_shared_mutex__: Mutex?
19
19
  @__safe_memo_shared_lru_order__: Hash[Symbol, Hash[memo_key, true]]?
20
20
 
21
+ SHARED_CACHE_REGISTRY: Hash[String, Stores::Base]
22
+ SHARED_CACHE_MUTEX: Mutex
23
+ EXTENSION_REGISTRY: Hash[Symbol, untyped]
24
+ EXTENSION_MUTEX: Mutex
25
+
21
26
  def self.prepended: (Class base) -> void
22
27
  def self.configure: () { (Configuration) -> void } -> void
23
28
  def self.configuration: () -> Configuration
24
29
  def self.reset_configuration!: () -> Configuration
25
30
  def self.deprecate: (String subject, message: String, horizon: String) -> void
31
+ def self.shared_cache: (String name) -> Stores::Base
32
+ def self.register_shared_cache: (String name, Stores::Base store) -> Stores::Base
33
+ def self.clear_shared_cache: (String name) -> void
34
+ def self.drop_shared_cache: (String name) -> Stores::Base?
35
+ def self.shared_caches: () -> Hash[String, Stores::Base]
36
+ def self.reset_shared_caches!: () -> void
37
+ def self.register_extension: (Symbol | String name, untyped extension) -> untyped
38
+ def self.unregister_extension: (Symbol | String name) -> untyped?
39
+ def self.extensions: () -> Hash[Symbol, untyped]
40
+ def self.reset_extensions!: () -> void
41
+ def self.extension_for_option: (Symbol option_name) -> untyped?
42
+ def self.dispatch_extension_events: (Symbol event_type, Class klass, Symbol method_name, untyped cache_key, untyped record) -> void
43
+
44
+ module Extension
45
+ def handles_option: (Symbol option_name) { (untyped value, Symbol method_name, Hash[Symbol, untyped] all_options) -> Hash[Symbol, untyped]? } -> void
46
+ def on_cache_event: (*Symbol event_types) { (Class klass, Symbol method_name, untyped cache_key, untyped record) -> void } -> void
47
+ def handled_options: () -> Array[Symbol]
48
+ def process_memoize_option: (Symbol option_name, untyped value, Symbol method_name, Hash[Symbol, untyped] all_options) -> Hash[Symbol, untyped]
49
+ def dispatch_cache_event: (Symbol event_type, Class klass, Symbol method_name, untyped cache_key, untyped record) -> void
50
+ end
26
51
 
27
52
  class Configuration
28
53
  attr_accessor default_ttl: Numeric?
@@ -33,15 +58,18 @@ module SafeMemoize
33
58
  attr_accessor statsd_client: untyped
34
59
  attr_accessor opentelemetry_tracer: untyped
35
60
  attr_accessor default_store: Stores::Base?
61
+ attr_accessor namespace: String?
36
62
 
37
63
  def initialize: () -> void
38
64
  end
39
65
 
40
66
  module ClassMethods
41
- def memoize: (Symbol | String method_name, ?ttl: Numeric?, ?max_size: Integer?, ?ttl_refresh: bool, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool, ?key: (^(*untyped args, **untyped kwargs) -> untyped)?, ?store: Stores::Base?, ?fiber_local: bool, ?ractor_safe: bool) -> void
67
+ def memoize: (Symbol | String method_name, ?ttl: Numeric?, ?max_size: Integer?, ?ttl_refresh: bool, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool, ?key: (^(*untyped args, **untyped kwargs) -> untyped)?, ?store: Stores::Base?, ?fiber_local: bool, ?ractor_safe: bool, ?namespace: String?, ?shared_cache: String?, ?cache_bust: (^() -> untyped) | Symbol | nil, **untyped extension_options) -> void
42
68
  def safe_memoize_store: () -> Stores::Base?
43
69
  def safe_memoize_store=: (Stores::Base?) -> Stores::Base?
44
- def memoize_all: (?except: Array[Symbol | String], ?only: Array[Symbol | String], ?include_protected: bool, ?include_private: bool, ?ttl: Numeric?, ?max_size: Integer?, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool, ?key: (^(*untyped args, **untyped kwargs) -> untyped)?, ?fiber_local: bool) -> void
70
+ def safe_memoize_namespace: () -> String?
71
+ def safe_memoize_namespace=: (String?) -> String?
72
+ def memoize_all: (?except: Array[Symbol | String], ?only: Array[Symbol | String], ?include_protected: bool, ?include_private: bool, ?ttl: Numeric?, ?max_size: Integer?, ?if: (^(untyped result) -> boolish)?, ?unless: (^(untyped result) -> boolish)?, ?shared: bool, ?key: (^(*untyped args, **untyped kwargs) -> untyped)?, ?fiber_local: bool, ?namespace: String?, ?shared_cache: String?) -> void
45
73
  def reset_shared_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
46
74
  def reset_all_shared_memos: () -> void
47
75
  def shared_memoized?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool
@@ -55,6 +83,8 @@ module SafeMemoize
55
83
  def __safe_memo_shared_mutex__: () -> Mutex
56
84
  def __safe_memo_shared_lru_order__: () -> Hash[Symbol, Hash[memo_key, true]]
57
85
  def __safe_memo_class_key_generators__: () -> Hash[Symbol, Proc]
86
+ def __safe_memo_method_namespaces__: () -> Hash[Symbol, String]
87
+ def __safe_memo_class_cache_bust_generators__: () -> Hash[Symbol, Proc | Symbol]
58
88
  def memoized_method_visibility: (Symbol method_name) -> Symbol
59
89
  end
60
90
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: safe_memoize
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 1.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -61,6 +61,7 @@ files:
61
61
  - lib/safe_memoize/class_methods.rb
62
62
  - lib/safe_memoize/configuration.rb
63
63
  - lib/safe_memoize/custom_key_methods.rb
64
+ - lib/safe_memoize/extension.rb
64
65
  - lib/safe_memoize/fiber_local_methods.rb
65
66
  - lib/safe_memoize/hooks_methods.rb
66
67
  - lib/safe_memoize/inspection_methods.rb