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 +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +297 -1
- data/ROADMAP.md +0 -4
- data/lib/safe_memoize/cache_metrics_methods.rb +4 -5
- data/lib/safe_memoize/class_methods.rb +138 -19
- data/lib/safe_memoize/configuration.rb +8 -0
- data/lib/safe_memoize/custom_key_methods.rb +11 -2
- data/lib/safe_memoize/extension.rb +88 -0
- data/lib/safe_memoize/fiber_local_methods.rb +2 -1
- data/lib/safe_memoize/hooks_methods.rb +2 -0
- data/lib/safe_memoize/inspection_methods.rb +34 -5
- data/lib/safe_memoize/public_methods.rb +3 -2
- data/lib/safe_memoize/public_metrics_methods.rb +4 -3
- data/lib/safe_memoize/version.rb +1 -1
- data/lib/safe_memoize.rb +138 -0
- data/sig/safe_memoize.rbs +32 -2
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3e47bd422b1516a4f775e49ad3f58bf916df60c094aae7dfddf652fa78e44d73
|
|
4
|
+
data.tar.gz: 2adc318d5cda8858d1ee49a2dd32080acea1e8163807e424366d9e8e42a30943
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 `
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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] ==
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
[
|
|
30
|
+
[effective_name, key_block.call(*args, **kwargs)]
|
|
28
31
|
else
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
+
effective_name, custom_key = cache_key
|
|
61
63
|
payload = {custom_key: custom_key}
|
|
62
64
|
else
|
|
63
|
-
|
|
65
|
+
effective_name, args, kwargs = cache_key
|
|
64
66
|
payload = {args: args, kwargs: kwargs}
|
|
65
67
|
end
|
|
66
68
|
|
|
67
|
-
payload[: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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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] =
|
|
83
|
+
entry[:method] = safe_memo_bare_method_name(effective_name) if include_method
|
|
83
84
|
entry
|
|
84
85
|
end
|
|
85
86
|
|
data/lib/safe_memoize/version.rb
CHANGED
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
|
|
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.
|
|
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
|