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.
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
- # 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.
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
- other_specs = Dir["spec/**/*_spec.rb"].sort - store_specs
12
- t.rspec_opts = (store_specs + other_specs).join(" ")
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(method_name, args, kwargs)
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(method_name, args, kwargs, computation_time)
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
- @__safe_memo_metrics__.delete_if { |key, _| key[0] == method_name }
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