safe_memoize 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +2 -2
- data/CHANGELOG.md +57 -1
- data/README.md +584 -6
- data/ROADMAP.md +0 -30
- data/Rakefile +9 -5
- data/lib/safe_memoize/adapters/concurrent_ruby.rb +98 -0
- data/lib/safe_memoize/cache_metrics_methods.rb +4 -5
- data/lib/safe_memoize/class_methods.rb +330 -23
- 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 +109 -0
- data/lib/safe_memoize/hooks_methods.rb +8 -1
- data/lib/safe_memoize/inspection_methods.rb +34 -5
- data/lib/safe_memoize/instance_methods.rb +1 -0
- data/lib/safe_memoize/public_methods.rb +3 -2
- data/lib/safe_memoize/public_metrics_methods.rb +4 -3
- data/lib/safe_memoize/ractor_shared_methods.rb +146 -0
- data/lib/safe_memoize/release_tooling.rb +25 -0
- data/lib/safe_memoize/version.rb +1 -1
- data/lib/safe_memoize.rb +141 -0
- data/sig/safe_memoize.rbs +77 -2
- metadata +5 -1
data/ROADMAP.md
CHANGED
|
@@ -4,43 +4,13 @@ This document tracks the planned evolution of SafeMemoize through v1.0.0 and bey
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
## v1.1.0 — Pluggable Cache Stores
|
|
8
|
-
|
|
9
|
-
*Goal: allow the in-process hash cache to be swapped for an external store, enabling cross-process and distributed memoization.*
|
|
10
|
-
|
|
11
|
-
| Feature | Description | Status |
|
|
12
|
-
|---|---|---|
|
|
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 |
|
|
18
|
-
|
|
19
|
-
---
|
|
20
|
-
|
|
21
|
-
## v1.2.0 — Async & Fiber-Safe Memoization
|
|
22
|
-
|
|
23
|
-
*Goal: first-class support for Fiber-based concurrency frameworks (Async, Falcon, Rails async controllers).*
|
|
24
|
-
|
|
25
|
-
| Feature | Description | Status |
|
|
26
|
-
|---|---|---|
|
|
27
|
-
| Fiber-local memoization mode | `memoize :method, fiber_local: true` stores results in `Fiber[:safe_memoize_cache]` rather than instance variables, giving each fiber its own isolated cache automatically reset when the fiber terminates | Planned |
|
|
28
|
-
| Ractor-compatible shared cache | Revisit `shared: true` using `Ractor::TVar` or shareable frozen objects so class-level caches work across Ractors | Planned |
|
|
29
|
-
| concurrent-ruby integration | Optional adapter using `Concurrent::Map` and `Concurrent::ReentrantReadWriteLock` as a drop-in replacement for `Mutex` where higher read-concurrency is desirable | Planned |
|
|
30
|
-
|
|
31
|
-
---
|
|
32
|
-
|
|
33
7
|
## v2.0.0 — Next Generation (Long Horizon)
|
|
34
8
|
|
|
35
9
|
*Goal: incorporate real-world usage feedback, clean up accumulated API surface, and open a path for advanced extension.*
|
|
36
10
|
|
|
37
11
|
| Feature | Description | Status |
|
|
38
12
|
|---|---|---|
|
|
39
|
-
| Plugin / extension architecture | A formal `SafeMemoize::Extension` API so third-party gems can add new options, hooks, or store adapters without monkey-patching | Planned |
|
|
40
13
|
| DSL refinements | Evaluate alternative syntax proposals (`memoize_method`, block form, annotation approach) based on community feedback; introduce the preferred form with a migration path from the current API | Planned |
|
|
41
|
-
| Cross-instance cache sharing | Beyond the class-level `shared: true`, support explicitly named shared caches that span unrelated classes | Planned |
|
|
42
|
-
| Cache namespacing | Allow a namespace prefix on all keys for multi-tenant or versioned deployments (especially useful with external stores) | Planned |
|
|
43
|
-
| Automatic cache busting | Optional integration with ActiveRecord's `updated_at` timestamp so object mutations automatically invalidate their own cached entries | Planned |
|
|
44
14
|
|
|
45
15
|
---
|
|
46
16
|
|
data/Rakefile
CHANGED
|
@@ -4,12 +4,16 @@ require "bundler/gem_tasks"
|
|
|
4
4
|
require "rspec/core/rake_task"
|
|
5
5
|
|
|
6
6
|
RSpec::Core::RakeTask.new(:spec) do |t|
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
7
|
+
# Ordering ensures accurate coverage tracking in Ruby 3.4:
|
|
8
|
+
# 1. Store specs first: opt-in adapter lines must be hit before Ractor tests
|
|
9
|
+
# can disrupt Ruby's Coverage counters.
|
|
10
|
+
# 2. Ractor specs last: Ractor-based tests spin up background Ractors whose
|
|
11
|
+
# internal threads can cause later coverage samples to be missed if they
|
|
12
|
+
# interleave with SimpleCov's collection phase.
|
|
10
13
|
store_specs = Dir["spec/stores/**/*_spec.rb"].sort
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
ractor_specs = Dir["spec/ractor*_spec.rb"].sort
|
|
15
|
+
other_specs = Dir["spec/**/*_spec.rb"].sort - store_specs - ractor_specs
|
|
16
|
+
t.rspec_opts = (store_specs + other_specs + ractor_specs).join(" ")
|
|
13
17
|
t.pattern = "non_existent_placeholder" # overridden by rspec_opts file args
|
|
14
18
|
end
|
|
15
19
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
module Adapters
|
|
5
|
+
# Optional store adapter backed by +concurrent-ruby+.
|
|
6
|
+
#
|
|
7
|
+
# Replaces the default +Mutex+-guarded +Hash+ with +Concurrent::Map+ and
|
|
8
|
+
# +Concurrent::ReentrantReadWriteLock+. Multiple readers proceed in parallel;
|
|
9
|
+
# writers still get exclusive access. For hot paths with many concurrent readers
|
|
10
|
+
# this can meaningfully reduce lock contention compared to {Stores::Memory}.
|
|
11
|
+
#
|
|
12
|
+
# Opt in per class:
|
|
13
|
+
#
|
|
14
|
+
# class MyService
|
|
15
|
+
# prepend SafeMemoize
|
|
16
|
+
# self.safe_memoize_store = SafeMemoize::Adapters::ConcurrentRuby.new
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# Or globally via {SafeMemoize.configure}:
|
|
20
|
+
#
|
|
21
|
+
# SafeMemoize.configure do |c|
|
|
22
|
+
# c.default_store = SafeMemoize::Adapters::ConcurrentRuby.new
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# Requires the +concurrent-ruby+ gem, which is *not* a runtime dependency of
|
|
26
|
+
# +safe_memoize+. Add it to your own Gemfile or gemspec:
|
|
27
|
+
#
|
|
28
|
+
# gem "concurrent-ruby"
|
|
29
|
+
#
|
|
30
|
+
# A {LoadError} with an actionable message is raised at instantiation time if
|
|
31
|
+
# the gem is not available.
|
|
32
|
+
class ConcurrentRuby < Stores::Base
|
|
33
|
+
def initialize
|
|
34
|
+
require "concurrent/map"
|
|
35
|
+
require "concurrent/atomic/reentrant_read_write_lock"
|
|
36
|
+
@data = Concurrent::Map.new
|
|
37
|
+
@lock = Concurrent::ReentrantReadWriteLock.new
|
|
38
|
+
rescue LoadError
|
|
39
|
+
raise LoadError,
|
|
40
|
+
"SafeMemoize::Adapters::ConcurrentRuby requires the concurrent-ruby gem. " \
|
|
41
|
+
"Add `gem 'concurrent-ruby'` to your Gemfile."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @param key [Object]
|
|
45
|
+
# @return [Object] the stored value, or {MISS} if absent or expired
|
|
46
|
+
def read(key)
|
|
47
|
+
@lock.with_read_lock do
|
|
48
|
+
entry = @data[key]
|
|
49
|
+
return MISS unless entry
|
|
50
|
+
return MISS if expired?(entry)
|
|
51
|
+
|
|
52
|
+
entry[:value]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# @param key [Object]
|
|
57
|
+
# @param value [Object]
|
|
58
|
+
# @param expires_in [Numeric, nil] seconds until expiry; +nil+ means no expiry
|
|
59
|
+
# @return [void]
|
|
60
|
+
def write(key, value, expires_in: nil)
|
|
61
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
62
|
+
expires_at = expires_in ? now + expires_in.to_f : nil
|
|
63
|
+
@lock.with_write_lock do
|
|
64
|
+
@data[key] = {value: value, expires_at: expires_at, cached_at: now}
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @param key [Object]
|
|
69
|
+
# @return [void]
|
|
70
|
+
def delete(key)
|
|
71
|
+
@lock.with_write_lock { @data.delete(key) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [void]
|
|
75
|
+
def clear
|
|
76
|
+
@lock.with_write_lock { @data.clear }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Returns all live (non-expired) keys.
|
|
80
|
+
# @return [Array<Object>]
|
|
81
|
+
def keys
|
|
82
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
83
|
+
@lock.with_read_lock do
|
|
84
|
+
result = []
|
|
85
|
+
@data.each_pair { |k, entry| result << k unless entry[:expires_at] && entry[:expires_at] <= now }
|
|
86
|
+
result
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def expired?(entry)
|
|
93
|
+
expires_at = entry[:expires_at]
|
|
94
|
+
expires_at && expires_at <= Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -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
|