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