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 +4 -4
- data/CHANGELOG.md +17 -0
- data/ROADMAP.md +5 -21
- data/Rakefile +9 -1
- data/lib/safe_memoize/class_methods.rb +72 -1
- data/lib/safe_memoize/configuration.rb +7 -0
- data/lib/safe_memoize/stores/base.rb +85 -0
- data/lib/safe_memoize/stores/memory.rb +70 -0
- data/lib/safe_memoize/stores/rails_cache.rb +128 -0
- data/lib/safe_memoize/stores/redis.rb +111 -0
- data/lib/safe_memoize/version.rb +1 -1
- data/lib/safe_memoize.rb +2 -0
- data/sig/safe_memoize.rbs +28 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 12783636e7ebfd6b21453d9262eb81157d9c82de3a9a538fb8825fd173f072f6
|
|
4
|
+
data.tar.gz: fff5a502ff49712cf365b3fc67d337ad279f1b28e7ed604ee63b8dcf43e0f6bd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 |
|
|
30
|
-
| `store:` option on `memoize` | Accept any store adapter object; defaults to the existing in-process hash store |
|
|
31
|
-
| Redis adapter | Reference implementation (`SafeMemoize::Stores::Redis`) with TTL, LRU-like expiry, and serialization handled transparently |
|
|
32
|
-
| Rails.cache adapter | Thin wrapper around `ActiveSupport::Cache::Store` for projects already using a configured Rails cache |
|
|
33
|
-
| Global default store | Set via `SafeMemoize.configure` — applies a default store to every memoized method without per-call configuration |
|
|
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
|
-
|
|
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
|
data/lib/safe_memoize/version.rb
CHANGED
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.
|
|
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
|