safe_memoize 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6beffd3f5a1de6f8582c9a164502f353b8ac6c55caec6326edb4c52b0bf913ec
4
- data.tar.gz: 44ebaf0f89254c692b9d33d8962577c9644df6d910818905e46395ba5e28cf89
3
+ metadata.gz: 12783636e7ebfd6b21453d9262eb81157d9c82de3a9a538fb8825fd173f072f6
4
+ data.tar.gz: fff5a502ff49712cf365b3fc67d337ad279f1b28e7ed604ee63b8dcf43e0f6bd
5
5
  SHA512:
6
- metadata.gz: 0f22cd22816499ec3400a7b98cf51c3a3b77a43a0e26451ff97d6f01877d02df8772b7b3be0209e86b5c6c00d435bc96e7db8fd3a28619169e1f0e97b8ab0263
7
- data.tar.gz: af315893620e46fe419a2a61ff4092f7d27f71bbfe98185ba0714162148c7d19b2d31ed9db3909d500f3e12f474f51d66ec027d81cd2d7e9948e4bc388d3a5db
6
+ metadata.gz: 0eedea81071d36fb8891c8767b43b07e5873aaca7d0f14bafdc339acf9c0c040b1e9b050ff72ec840d0edeccf33ef422082423576b5ae58e371dec13f50e4ed0
7
+ data.tar.gz: 506cadf95e962b1dccf167be9566663ee44d9d4de8ee7eed75bad4189596674b0dc6e485b9063a37f222b1136698b28f031f56d2a035f04e6d3ffa13fcc34b50
data/CHANGELOG.md CHANGED
@@ -8,6 +8,23 @@ from v1.0.0 onwards. Prior 0.x releases may include breaking changes between min
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.1.0] - 2026-05-22
12
+
13
+ ### Added
14
+
15
+ - `SafeMemoize::Stores::Base` — abstract adapter base class defining the cache store contract: `read(key)`, `write(key, value, expires_in: nil)`, `delete(key)`, `clear`, `keys`, and `exist?(key)`; a frozen `MISS` sentinel on `Base` distinguishes cache misses from cached `nil` or `false` values; `exist?` has a default implementation that delegates to `read`
16
+ - `SafeMemoize::Stores::Memory` — built-in in-process store that wraps a plain `Hash` behind a `Mutex`; supports per-entry TTL via `expires_in:` with lazy expiry on read; serves as both the default store and the reference implementation for custom adapters
17
+ - `Configuration#default_store` — set via `SafeMemoize.configure { |c| c.default_store = MyStore.new }` to route every `memoize` call that has no explicit `store:` through the given adapter; methods using `max_size:` or `shared:` are incompatible and fall back silently to the per-instance hash; an invalid value raises `ArgumentError` at `memoize` time; cleared by `reset_configuration!`
18
+ - `SafeMemoize::Stores::RailsCache` — opt-in adapter (`require "safe_memoize/stores/rails_cache"`) wrapping any `ActiveSupport::Cache::Store` (including `Rails.cache`); values are wrapped in a sentinel envelope so cached `nil`/`false` are distinguished from a cache miss; TTL forwarded as `expires_in:` for native store expiry; `clear` uses `delete_matched` scoped to the namespace; `keys` returns `[]` (AS::Cache has no enumeration API)
19
+ - `SafeMemoize::Stores::Redis` — opt-in adapter (`require "safe_memoize/stores/redis"`) backed by any Redis-compatible client responding to `#get`, `#set`, `#del`, and `#scan_each`; values and keys are serialized with Marshal + `pack("m0")`; TTL is forwarded as `PX` (milliseconds, rounded up) for sub-second precision; `clear` uses `SCAN` to avoid blocking; all entries are namespaced (default: `"safe_memoize"`) so multiple stores or applications can share one Redis instance
20
+ - `store:` option on `memoize` — accepts any `Stores::Base` subclass instance; routes all reads and writes through the adapter's `read`/`write` interface; the store is shared across all instances of the class; `ttl:` is forwarded as `expires_in:` to `write`, `ttl_refresh:` re-writes on every hit, and `if:`/`unless:` conditional storage is enforced at the SafeMemoize layer; raises `ArgumentError` if combined with `max_size:` (LRU belongs in the adapter) or `shared:`
21
+
22
+ ### Changed
23
+
24
+ - Test suite achieves 100% line coverage — `spec_helper` now requires opt-in store adapters (`Stores::Redis`, `Stores::RailsCache`) after `SimpleCov.start` so Coverage tracks them; `Rakefile` runs `spec/stores/` before other specs to prevent Ruby 3.4 Coverage counter disruption from Ractor/concurrency tests; `version.rb` excluded from coverage reporting
25
+ - `store:` type guard in `ClassMethods#memoize` collapsed to an inline guard clause so Ruby's Coverage module counts the raise correctly
26
+ - Hook-error isolation tests (`concurrency_spec`, `hooks_spec`) now configure `on_hook_error = ->(*) {}` to silence expected stderr warnings rather than leaking them into test output; StatsD error-resilience test asserts on the emitted warning with `expect { }.to output(...).to_stderr`
27
+
11
28
  ## [1.0.0] - 2026-05-22
12
29
 
13
30
  ### Added
data/ROADMAP.md CHANGED
@@ -4,33 +4,17 @@ This document tracks the planned evolution of SafeMemoize through v1.0.0 and bey
4
4
 
5
5
  ---
6
6
 
7
- ## v1.0.0 — Stable API
8
-
9
- *Goal: declare a stable, semver-governed public API that downstream code can depend on with confidence.*
10
-
11
- | Feature | Description | Status |
12
- |---|---|---|
13
- | Semantic versioning guarantee | Document which constants, methods, and option keys are public API; breaking changes require a major bump henceforth | Shipped |
14
- | Complete RBS + Sorbet signatures | Cover all public methods including overloads for optional keyword arguments; publish `.rbi` stubs as a companion package if demand warrants | Shipped |
15
- | Full API reference | Generated documentation hosted on RubyDoc or a dedicated docs site; all public methods documented with parameter types, return values, and usage examples | Shipped |
16
- | Ractor compatibility audit | Investigate and either support Ractor-compatible operation (Mutex replacement, shared-cache storage) or document the limitation clearly | Shipped |
17
- | Ruby version policy | Formalise the supported Ruby version window and cadence for dropping EOL versions | Shipped |
18
- | Deprecation sweep | Resolve or formally deprecate any unstable internal APIs before the stable release | Shipped |
19
- | Upgrade guide | Document all breaking changes from 0.x and provide a migration path for users of deprecated behaviour | Shipped |
20
-
21
- ---
22
-
23
7
  ## v1.1.0 — Pluggable Cache Stores
24
8
 
25
9
  *Goal: allow the in-process hash cache to be swapped for an external store, enabling cross-process and distributed memoization.*
26
10
 
27
11
  | Feature | Description | Status |
28
12
  |---|---|---|
29
- | Cache store adapter interface | Define a minimal read/write/delete/clear/keys contract that external backends must implement | Planned |
30
- | `store:` option on `memoize` | Accept any store adapter object; defaults to the existing in-process hash store | Planned |
31
- | Redis adapter | Reference implementation (`SafeMemoize::Stores::Redis`) with TTL, LRU-like expiry, and serialization handled transparently | Planned |
32
- | Rails.cache adapter | Thin wrapper around `ActiveSupport::Cache::Store` for projects already using a configured Rails cache | Planned |
33
- | Global default store | Set via `SafeMemoize.configure` — applies a default store to every memoized method without per-call configuration | Planned |
13
+ | Cache store adapter interface | Define a minimal read/write/delete/clear/keys contract that external backends must implement | Shipped |
14
+ | `store:` option on `memoize` | Accept any store adapter object; defaults to the existing in-process hash store | Shipped |
15
+ | Redis adapter | Reference implementation (`SafeMemoize::Stores::Redis`) with TTL, LRU-like expiry, and serialization handled transparently | Shipped |
16
+ | Rails.cache adapter | Thin wrapper around `ActiveSupport::Cache::Store` for projects already using a configured Rails cache | Shipped |
17
+ | Global default store | Set via `SafeMemoize.configure` — applies a default store to every memoized method without per-call configuration | Shipped |
34
18
 
35
19
  ---
36
20
 
data/Rakefile CHANGED
@@ -3,7 +3,15 @@
3
3
  require "bundler/gem_tasks"
4
4
  require "rspec/core/rake_task"
5
5
 
6
- RSpec::Core::RakeTask.new(:spec)
6
+ RSpec::Core::RakeTask.new(:spec) do |t|
7
+ # Run store specs first: Ruby Coverage counters for opt-in adapters
8
+ # (redis, rails_cache) must be exercised before Ractor/concurrency tests
9
+ # run, which can disrupt coverage tracking in certain Ruby 3.4 builds.
10
+ store_specs = Dir["spec/stores/**/*_spec.rb"].sort
11
+ other_specs = Dir["spec/**/*_spec.rb"].sort - store_specs
12
+ t.rspec_opts = (store_specs + other_specs).join(" ")
13
+ t.pattern = "non_existent_placeholder" # overridden by rspec_opts file args
14
+ end
7
15
 
8
16
  require "standard/rake"
9
17
 
@@ -25,6 +25,10 @@ module SafeMemoize
25
25
  # @param key [Proc, nil] class-level custom cache key generator. Receives the same
26
26
  # arguments as the method and should return a single comparable value. Instance-level
27
27
  # keys set via {PublicCustomKeyMethods#memoize_with_custom_key} take priority.
28
+ # @param store [Stores::Base, nil] custom cache store adapter. Must be a
29
+ # {Stores::Base} subclass instance. The store is shared across all instances of the
30
+ # class. When +nil+, the default per-instance in-process hash is used.
31
+ # Cannot be combined with +max_size:+ or +shared:+.
28
32
  # @return [void]
29
33
  # @raise [ArgumentError] if the method does not exist, or option values are invalid
30
34
  #
@@ -38,7 +42,11 @@ module SafeMemoize
38
42
  #
39
43
  # @example Conditional — only cache successful responses
40
44
  # memoize :fetch, if: ->(v) { v[:status] == 200 }
41
- def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, unless: nil, shared: false, key: nil)
45
+ #
46
+ # @example With a custom store
47
+ # STORE = SafeMemoize::Stores::Memory.new
48
+ # memoize :fetch, store: STORE, ttl: 300
49
+ def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, unless: nil, shared: false, key: nil, store: nil)
42
50
  method_name = method_name.to_sym
43
51
 
44
52
  unless method_defined?(method_name) || private_method_defined?(method_name) || protected_method_defined?(method_name)
@@ -85,6 +93,26 @@ module SafeMemoize
85
93
  raise ArgumentError, ":unless must be callable" if cond_unless && !cond_unless.respond_to?(:call)
86
94
  raise ArgumentError, ":key must be callable" if key && !key.respond_to?(:call)
87
95
 
96
+ if store
97
+ raise ArgumentError, "store: must be a SafeMemoize::Stores::Base instance (got #{store.class})" unless store.is_a?(SafeMemoize::Stores::Base)
98
+ raise ArgumentError, "max_size: is not supported with store: — use the store adapter's own eviction" if max_size
99
+ raise ArgumentError, "shared: and store: cannot be combined" if shared
100
+ end
101
+
102
+ # Resolve effective store: explicit store: wins; global default applies when
103
+ # compatible (max_size: and shared: are incompatible — fall back silently).
104
+ effective_store = store
105
+ if effective_store.nil? && !max_size && !shared
106
+ global_default = SafeMemoize.configuration.default_store
107
+ if global_default
108
+ unless global_default.is_a?(SafeMemoize::Stores::Base)
109
+ raise ArgumentError,
110
+ "SafeMemoize.configuration.default_store must be a Stores::Base instance (got #{global_default.class})"
111
+ end
112
+ effective_store = global_default
113
+ end
114
+ end
115
+
88
116
  __safe_memo_class_key_generators__[method_name] = key if key
89
117
 
90
118
  # Normalize to a single "should cache?" predicate
@@ -94,6 +122,49 @@ module SafeMemoize
94
122
  ->(result) { !cond_unless.call(result) }
95
123
  end
96
124
 
125
+ if effective_store
126
+ miss = SafeMemoize::Stores::Base::MISS
127
+
128
+ mod = Module.new do
129
+ define_method(method_name) do |*args, **kwargs, &block|
130
+ return super(*args, **kwargs, &block) if block
131
+
132
+ cache_key = compute_cache_key(method_name, args, kwargs)
133
+ cached = effective_store.read(cache_key)
134
+
135
+ unless cached.equal?(miss)
136
+ effective_store.write(cache_key, cached, expires_in: ttl) if ttl_refresh
137
+ record_cache_hit(method_name, args, kwargs)
138
+ call_memo_hooks(:on_hit, cache_key, {value: cached, expires_at: nil, cached_at: nil})
139
+ return cached
140
+ end
141
+
142
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
143
+ value = Adapters::OpenTelemetry.trace(
144
+ SafeMemoize.configuration.opentelemetry_tracer, method_name, self.class.name
145
+ ) { super(*args, **kwargs) }
146
+ elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
147
+
148
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
149
+ if !condition || condition.call(value)
150
+ effective_store.write(cache_key, value, expires_in: ttl)
151
+ call_memo_hooks(:on_store, cache_key, {value: value, expires_at: nil, cached_at: now})
152
+ end
153
+
154
+ record_cache_miss(method_name, args, kwargs, elapsed_time)
155
+ call_memo_hooks(:on_miss, cache_key, {value: value, expires_at: nil, cached_at: now})
156
+
157
+ value
158
+ end
159
+
160
+ send(visibility, method_name)
161
+ end
162
+
163
+ prepend mod
164
+
165
+ return
166
+ end
167
+
97
168
  if shared
98
169
  klass = self
99
170
  shared_mutex = klass.send(:__safe_memo_shared_mutex__)
@@ -42,6 +42,12 @@ module SafeMemoize
42
42
  # When set, {Adapters::OpenTelemetry} wraps each cache-miss computation in a span.
43
43
  attr_accessor :opentelemetry_tracer
44
44
 
45
+ # @return [Stores::Base, nil] Default cache store applied to every {ClassMethods#memoize}
46
+ # call that does not specify its own +store:+. +nil+ uses the built-in per-instance
47
+ # hash cache. Methods using +max_size:+ or +shared:+ are incompatible with an external
48
+ # store and will silently continue using the per-instance hash even when this is set.
49
+ attr_accessor :default_store
50
+
45
51
  # @api private
46
52
  def initialize
47
53
  @default_ttl = nil
@@ -51,6 +57,7 @@ module SafeMemoize
51
57
  @active_support_notifications = false
52
58
  @statsd_client = nil
53
59
  @opentelemetry_tracer = nil
60
+ @default_store = nil
54
61
  end
55
62
  end
56
63
  end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeMemoize
4
+ module Stores
5
+ # Abstract base class for SafeMemoize cache store adapters.
6
+ #
7
+ # Subclass this and implement all abstract methods to plug in a custom backend
8
+ # (Redis, Memcached, Rails.cache, etc.). The {Stores::Memory} class is the
9
+ # built-in reference implementation.
10
+ #
11
+ # @abstract
12
+ #
13
+ # @example Minimal inline implementation
14
+ # class MyStore < SafeMemoize::Stores::Base
15
+ # def initialize = (@h = {})
16
+ # def read(key) = @h.fetch(key, MISS)
17
+ # def write(key, value, expires_in: nil) = (@h[key] = value)
18
+ # def delete(key) = @h.delete(key)
19
+ # def clear = @h.clear
20
+ # def keys = @h.keys
21
+ # end
22
+ class Base
23
+ # Sentinel returned by {#read} to signal a cache miss.
24
+ #
25
+ # Distinct from +nil+ and +false+, which are valid cached values.
26
+ MISS = Object.new.freeze
27
+
28
+ # Read a value from the store.
29
+ #
30
+ # @param key [Object] cache key
31
+ # @return [Object] the stored value, or {MISS} if absent or expired
32
+ # @abstract
33
+ def read(key)
34
+ raise NotImplementedError, "#{self.class}#read must be implemented"
35
+ end
36
+
37
+ # Write a value to the store.
38
+ #
39
+ # @param key [Object] cache key
40
+ # @param value [Object] value to cache (may be +nil+ or +false+)
41
+ # @param expires_in [Numeric, nil] seconds until expiry; +nil+ means no expiry
42
+ # @return [void]
43
+ # @abstract
44
+ def write(key, value, expires_in: nil)
45
+ raise NotImplementedError, "#{self.class}#write must be implemented"
46
+ end
47
+
48
+ # Delete a single entry.
49
+ #
50
+ # @param key [Object] cache key
51
+ # @return [void]
52
+ # @abstract
53
+ def delete(key)
54
+ raise NotImplementedError, "#{self.class}#delete must be implemented"
55
+ end
56
+
57
+ # Remove all entries from the store.
58
+ #
59
+ # @return [void]
60
+ # @abstract
61
+ def clear
62
+ raise NotImplementedError, "#{self.class}#clear must be implemented"
63
+ end
64
+
65
+ # Return all live (non-expired) keys.
66
+ #
67
+ # @return [Array<Object>]
68
+ # @abstract
69
+ def keys
70
+ raise NotImplementedError, "#{self.class}#keys must be implemented"
71
+ end
72
+
73
+ # Check whether a live entry exists for the given key.
74
+ #
75
+ # The default delegates to {#read}; subclasses may override for stores
76
+ # with a native existence check.
77
+ #
78
+ # @param key [Object]
79
+ # @return [Boolean]
80
+ def exist?(key)
81
+ read(key) != MISS
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SafeMemoize
4
+ module Stores
5
+ # Default in-process cache store backed by a plain +Hash+.
6
+ #
7
+ # Thread-safe via an internal +Mutex+. Supports per-entry TTL with lazy
8
+ # expiry: stale entries are not proactively removed but are treated as
9
+ # misses on read and excluded from {#keys}.
10
+ class Memory < Base
11
+ def initialize
12
+ @data = {}
13
+ @mutex = Mutex.new
14
+ end
15
+
16
+ # @param key [Object]
17
+ # @return [Object] stored value, or {MISS} if absent or expired
18
+ def read(key)
19
+ @mutex.synchronize do
20
+ entry = @data[key]
21
+ return MISS unless entry
22
+ return MISS if expired?(entry)
23
+
24
+ entry[:value]
25
+ end
26
+ end
27
+
28
+ # @param key [Object]
29
+ # @param value [Object]
30
+ # @param expires_in [Numeric, nil] seconds until expiry
31
+ # @return [void]
32
+ def write(key, value, expires_in: nil)
33
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
34
+ expires_at = expires_in ? now + expires_in.to_f : nil
35
+
36
+ @mutex.synchronize do
37
+ @data[key] = {value: value, expires_at: expires_at, cached_at: now}
38
+ end
39
+ end
40
+
41
+ # @param key [Object]
42
+ # @return [void]
43
+ def delete(key)
44
+ @mutex.synchronize { @data.delete(key) }
45
+ end
46
+
47
+ # Removes all entries.
48
+ # @return [void]
49
+ def clear
50
+ @mutex.synchronize { @data.clear }
51
+ end
52
+
53
+ # Returns all live (non-expired) keys.
54
+ # @return [Array<Object>]
55
+ def keys
56
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
57
+ @mutex.synchronize do
58
+ @data.filter_map { |k, entry| k unless entry[:expires_at] && entry[:expires_at] <= now }
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def expired?(entry)
65
+ expires_at = entry[:expires_at]
66
+ expires_at && expires_at <= Process.clock_gettime(Process::CLOCK_MONOTONIC)
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "safe_memoize"
4
+
5
+ module SafeMemoize
6
+ module Stores
7
+ # Cache store adapter backed by any +ActiveSupport::Cache::Store+.
8
+ #
9
+ # Not auto-required. Add to your Rails initializer:
10
+ # require "safe_memoize/stores/rails_cache"
11
+ #
12
+ # Compatible with any +ActiveSupport::Cache::Store+ implementation
13
+ # (+MemoryStore+, +FileStore+, +MemCacheStore+, +RedisCacheStore+, etc.)
14
+ # and with +Rails.cache+ directly.
15
+ #
16
+ # Because +ActiveSupport::Cache+ returns +nil+ for both a cache miss and
17
+ # a cached +nil+ value, this adapter wraps every value in a two-element
18
+ # sentinel envelope before writing. The envelope is transparent to callers.
19
+ #
20
+ # TTL is forwarded as +expires_in:+ to the cache, so the underlying store
21
+ # manages expiry natively — there is no lazy-expiry overhead on read.
22
+ #
23
+ # {#clear} uses +delete_matched+ scoped to the adapter's namespace, so it
24
+ # never clears entries belonging to other parts of the application. The
25
+ # backend must respond to +delete_matched+ (all standard Rails cache stores
26
+ # do); a +NotImplementedError+ is raised if it does not.
27
+ #
28
+ # {#keys} returns an empty array — +ActiveSupport::Cache::Store+ does not
29
+ # expose a standard key enumeration API. Override the method if your
30
+ # backend supports it.
31
+ #
32
+ # @example Basic setup
33
+ # # config/initializers/safe_memoize.rb
34
+ # require "safe_memoize/stores/rails_cache"
35
+ #
36
+ # MEMO_STORE = SafeMemoize::Stores::RailsCache.new(Rails.cache)
37
+ #
38
+ # class MyService
39
+ # prepend SafeMemoize
40
+ # def fetch(id) = http_get(id)
41
+ # memoize :fetch, store: MEMO_STORE, ttl: 300
42
+ # end
43
+ #
44
+ # @example Dedicated cache store (recommended for production)
45
+ # MEMO_STORE = SafeMemoize::Stores::RailsCache.new(
46
+ # ActiveSupport::Cache::RedisCacheStore.new(url: ENV["REDIS_URL"]),
47
+ # namespace: "myapp:memo"
48
+ # )
49
+ class RailsCache < Base
50
+ # Tag prepended to every stored value so cached +nil+/+false+ are
51
+ # distinguishable from a cache miss.
52
+ VALUE_TAG = "safe_memoize:v1"
53
+
54
+ # @param cache [ActiveSupport::Cache::Store] the cache store to use;
55
+ # typically +Rails.cache+ or a dedicated store instance
56
+ # @param namespace [String] key prefix used to scope all entries;
57
+ # defaults to +"safe_memoize"+
58
+ def initialize(cache, namespace: "safe_memoize")
59
+ @cache = cache
60
+ @namespace = namespace
61
+ end
62
+
63
+ # @param key [Object] cache key (serialized with Marshal + Base64)
64
+ # @return [Object] the stored value, or {MISS} if absent or unrecognised
65
+ def read(key)
66
+ raw = @cache.read(cache_key(key))
67
+ return MISS unless raw.is_a?(Array) && raw.length == 2 && raw[0] == VALUE_TAG
68
+
69
+ raw[1]
70
+ end
71
+
72
+ # @param key [Object] cache key
73
+ # @param value [Object] value to store (may be +nil+ or +false+)
74
+ # @param expires_in [Numeric, nil] TTL in seconds forwarded to the cache
75
+ # as +expires_in:+; +nil+ means no expiry
76
+ # @return [void]
77
+ def write(key, value, expires_in: nil)
78
+ opts = expires_in ? {expires_in: expires_in} : {}
79
+ @cache.write(cache_key(key), [VALUE_TAG, value], **opts)
80
+ end
81
+
82
+ # @param key [Object]
83
+ # @return [void]
84
+ def delete(key)
85
+ @cache.delete(cache_key(key))
86
+ end
87
+
88
+ # Removes all entries written by this adapter (scoped to the namespace).
89
+ #
90
+ # Delegates to +delete_matched+ on the underlying store; raises
91
+ # +NotImplementedError+ if the store does not support it.
92
+ #
93
+ # @return [void]
94
+ # @raise [NotImplementedError] if the backing store does not respond to
95
+ # +delete_matched+
96
+ def clear
97
+ unless @cache.respond_to?(:delete_matched)
98
+ raise NotImplementedError,
99
+ "#{@cache.class} does not support delete_matched — " \
100
+ "implement clear manually or use a store that supports it (e.g. MemoryStore, RedisCacheStore)"
101
+ end
102
+ @cache.delete_matched(/\A#{Regexp.escape(@namespace)}:/)
103
+ end
104
+
105
+ # Returns an empty array.
106
+ #
107
+ # +ActiveSupport::Cache::Store+ does not expose a key enumeration API.
108
+ # Override this method if your backend supports key listing.
109
+ #
110
+ # @return [Array]
111
+ def keys
112
+ []
113
+ end
114
+
115
+ # @param key [Object]
116
+ # @return [Boolean]
117
+ def exist?(key)
118
+ @cache.exist?(cache_key(key))
119
+ end
120
+
121
+ private
122
+
123
+ def cache_key(key)
124
+ "#{@namespace}:#{[Marshal.dump(key)].pack("m0")}"
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "safe_memoize"
4
+
5
+ module SafeMemoize
6
+ module Stores
7
+ # Cache store adapter backed by Redis.
8
+ #
9
+ # Not auto-required. Add to your application:
10
+ # require "safe_memoize/stores/redis"
11
+ #
12
+ # Requires a Redis-compatible client that responds to +#get+, +#set+,
13
+ # +#del+, and +#scan_each+. Compatible with the +redis+ gem (v4+) and
14
+ # any drop-in replacement.
15
+ #
16
+ # Values and keys are serialized with +Marshal+ (Base64-encoded via
17
+ # +Array#pack("m0")+) so that any Ruby object, including +nil+ and
18
+ # +false+, can be stored and retrieved faithfully. TTL is forwarded to
19
+ # Redis as the +PX+ option (milliseconds, rounded up to the nearest
20
+ # millisecond; minimum 1 ms) to preserve sub-second precision.
21
+ #
22
+ # @example Basic setup
23
+ # require "redis"
24
+ # require "safe_memoize/stores/redis"
25
+ #
26
+ # REDIS_STORE = SafeMemoize::Stores::Redis.new(::Redis.new)
27
+ #
28
+ # class MyService
29
+ # prepend SafeMemoize
30
+ # def fetch(id) = http_get(id)
31
+ # memoize :fetch, store: REDIS_STORE, ttl: 300
32
+ # end
33
+ #
34
+ # @example With a custom namespace
35
+ # STORE = SafeMemoize::Stores::Redis.new(::Redis.new, namespace: "myapp:memo")
36
+ class Redis < Base
37
+ # @param client [Object] a Redis-compatible client responding to
38
+ # +#get+, +#set+, +#del+, and +#scan_each+
39
+ # @param namespace [String] key prefix used to scope all entries in Redis;
40
+ # defaults to +"safe_memoize"+
41
+ def initialize(client, namespace: "safe_memoize")
42
+ @client = client
43
+ @namespace = namespace
44
+ end
45
+
46
+ # @param key [Object] cache key (serialized with Marshal + Base64)
47
+ # @return [Object] the stored value, or {MISS} if absent
48
+ def read(key)
49
+ raw = @client.get(redis_key(key))
50
+ return MISS if raw.nil?
51
+
52
+ Marshal.load(raw) # rubocop:disable Security/MarshalLoad
53
+ rescue TypeError, ArgumentError
54
+ MISS
55
+ end
56
+
57
+ # @param key [Object] cache key
58
+ # @param value [Object] value to store (may be +nil+ or +false+)
59
+ # @param expires_in [Numeric, nil] TTL in seconds forwarded to Redis as +PX+
60
+ # (milliseconds, rounded up; minimum 1 ms); +nil+ means no expiry
61
+ # @return [void]
62
+ def write(key, value, expires_in: nil)
63
+ raw = Marshal.dump(value)
64
+ if expires_in
65
+ @client.set(redis_key(key), raw, px: [(expires_in * 1000).ceil, 1].max)
66
+ else
67
+ @client.set(redis_key(key), raw)
68
+ end
69
+ end
70
+
71
+ # @param key [Object]
72
+ # @return [void]
73
+ def delete(key)
74
+ @client.del(redis_key(key))
75
+ end
76
+
77
+ # Removes all entries written by this adapter (scoped to the namespace).
78
+ # Uses +SCAN+ internally to avoid blocking Redis.
79
+ # @return [void]
80
+ def clear
81
+ to_delete = []
82
+ @client.scan_each(match: "#{@namespace}:*") { |k| to_delete << k }
83
+ @client.del(*to_delete) unless to_delete.empty?
84
+ end
85
+
86
+ # Returns all live keys in the namespace, deserialized back to their
87
+ # original Ruby form. Entries that cannot be deserialized are silently
88
+ # skipped. Because Redis handles TTL natively, every key returned by
89
+ # +SCAN+ is live.
90
+ #
91
+ # @return [Array<Object>]
92
+ def keys
93
+ prefix = "#{@namespace}:"
94
+ result = []
95
+ @client.scan_each(match: "#{@namespace}:*") do |rk|
96
+ encoded = rk.delete_prefix(prefix)
97
+ result << Marshal.load(encoded.unpack1("m0")) # rubocop:disable Security/MarshalLoad
98
+ rescue ArgumentError, TypeError
99
+ # skip keys that cannot be deserialized (e.g. written by another serializer)
100
+ end
101
+ result
102
+ end
103
+
104
+ private
105
+
106
+ def redis_key(key)
107
+ "#{@namespace}:#{[Marshal.dump(key)].pack("m0")}"
108
+ end
109
+ end
110
+ end
111
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module SafeMemoize
4
4
  # The current gem version string.
5
- VERSION = "1.0.0"
5
+ VERSION = "1.1.0"
6
6
  end
data/lib/safe_memoize.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require_relative "safe_memoize/version"
4
4
  require_relative "safe_memoize/configuration"
5
+ require_relative "safe_memoize/stores/base"
6
+ require_relative "safe_memoize/stores/memory"
5
7
  require_relative "safe_memoize/adapters/statsd"
6
8
  require_relative "safe_memoize/adapters/opentelemetry"
7
9
  require_relative "safe_memoize/class_methods"
data/sig/safe_memoize.rbs CHANGED
@@ -32,12 +32,13 @@ module SafeMemoize
32
32
  attr_accessor active_support_notifications: bool
33
33
  attr_accessor statsd_client: untyped
34
34
  attr_accessor opentelemetry_tracer: untyped
35
+ attr_accessor default_store: Stores::Base?
35
36
 
36
37
  def initialize: () -> void
37
38
  end
38
39
 
39
40
  module ClassMethods
40
- 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)?) -> void
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?) -> void
41
42
  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)?) -> void
42
43
  def reset_shared_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
43
44
  def reset_all_shared_memos: () -> void
@@ -199,6 +200,32 @@ module SafeMemoize
199
200
  include LruMethods
200
201
  end
201
202
 
203
+ module Stores
204
+ class Base
205
+ MISS: Object
206
+
207
+ def read: (untyped key) -> untyped
208
+ def write: (untyped key, untyped value, ?expires_in: Numeric?) -> void
209
+ def delete: (untyped key) -> void
210
+ def clear: () -> void
211
+ def keys: () -> Array[untyped]
212
+ def exist?: (untyped key) -> bool
213
+ end
214
+
215
+ class Memory < Base
216
+ def initialize: () -> void
217
+ def read: (untyped key) -> untyped
218
+ def write: (untyped key, untyped value, ?expires_in: Numeric?) -> void
219
+ def delete: (untyped key) -> void
220
+ def clear: () -> void
221
+ def keys: () -> Array[untyped]
222
+
223
+ private
224
+
225
+ def expired?: ({ expires_at: Float?, value: untyped, cached_at: Float }) -> bool
226
+ end
227
+ end
228
+
202
229
  module Adapters
203
230
  module StatsD
204
231
  METRIC_NAMES: Hash[Symbol, String]
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.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith
@@ -71,6 +71,10 @@ files:
71
71
  - lib/safe_memoize/rails/middleware.rb
72
72
  - lib/safe_memoize/rails/request_scoped.rb
73
73
  - lib/safe_memoize/release_tooling.rb
74
+ - lib/safe_memoize/stores/base.rb
75
+ - lib/safe_memoize/stores/memory.rb
76
+ - lib/safe_memoize/stores/rails_cache.rb
77
+ - lib/safe_memoize/stores/redis.rb
74
78
  - lib/safe_memoize/version.rb
75
79
  - rbi/safe_memoize.rbi
76
80
  - sig/safe_memoize.rbs