safe_memoize 1.1.0 → 1.2.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 +25 -1
- data/README.md +288 -6
- data/ROADMAP.md +0 -26
- data/Rakefile +9 -5
- data/lib/safe_memoize/adapters/concurrent_ruby.rb +98 -0
- data/lib/safe_memoize/class_methods.rb +197 -9
- data/lib/safe_memoize/fiber_local_methods.rb +108 -0
- data/lib/safe_memoize/hooks_methods.rb +6 -1
- data/lib/safe_memoize/instance_methods.rb +1 -0
- 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 +3 -0
- data/sig/safe_memoize.rbs +47 -2
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 24faf0555ba5e11e37092aea14e1db090d7f5bb13dd40edc33ea30d8adcba552
|
|
4
|
+
data.tar.gz: '09beaa5cf9e25284462bdac9636929642213a9e8d657900d30913832da230959'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8ed21a58fc95a122794bcb91a1cbccae2bad598b38f736d76eb6f95551245dba070e047eaaea56b26fca6aace181e138acd37a0a027dbb7c0a6a43ae728f2cf0
|
|
7
|
+
data.tar.gz: f51afd15884dd10c771cd3f3ffcb7d481b0fd3012ecc74148fc9ff82e056abc1a2bb044d5344d635abb5380d200f0ec62a4db528d8fc79697358f775cc4aba3e
|
data/.github/workflows/ci.yml
CHANGED
|
@@ -53,12 +53,12 @@ jobs:
|
|
|
53
53
|
bundler-cache: true
|
|
54
54
|
|
|
55
55
|
- name: Run test suite
|
|
56
|
-
run: bundle exec
|
|
56
|
+
run: bundle exec rake spec
|
|
57
57
|
|
|
58
58
|
- name: Upload coverage to Codecov
|
|
59
59
|
uses: codecov/codecov-action@v5
|
|
60
60
|
if: matrix.ruby == '3.4'
|
|
61
61
|
with:
|
|
62
62
|
token: ${{ secrets.CODECOV_TOKEN }}
|
|
63
|
-
files: coverage
|
|
63
|
+
files: coverage/coverage.json
|
|
64
64
|
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,30 @@ from v1.0.0 onwards. Prior 0.x releases may include breaking changes between min
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [1.2.0] - 2026-05-27
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- `SafeMemoize::Adapters::ConcurrentRuby` — optional store adapter backed by `concurrent-ruby`; uses `Concurrent::Map` as the backing hash and `Concurrent::ReentrantReadWriteLock` to allow multiple readers to proceed in parallel while writers still get exclusive access; reduces lock contention on hot read paths compared to the default `Mutex`-backed `Stores::Memory`; `concurrent-ruby` is a soft dependency — a `LoadError` with an actionable message is raised at instantiation if the gem is not available
|
|
16
|
+
- `.safe_memoize_store=` / `.safe_memoize_store` — class-level attribute for setting a default store on an individual class without touching the global `SafeMemoize.configure` default; takes precedence over `SafeMemoize.configuration.default_store` but is overridden by a per-method `store:` argument; accepts any `SafeMemoize::Stores::Base` instance or `nil`; raises `ArgumentError` for invalid values
|
|
17
|
+
|
|
18
|
+
- `ractor_safe: true` option on `memoize` — replaces 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; requires `shared: true`; cached values are deep-frozen via `Ractor.make_shareable`; the memoize wrapper Proc is frozen with `Ractor.make_shareable` before being passed to `define_method` so that classes using `ractor_safe: true` can be passed directly into worker Ractors; incompatible with `if:`, `unless:`, `max_size:`, `ttl_refresh:`, `key:`, and `store:` (raises `ArgumentError`); `ttl:` is supported
|
|
19
|
+
- `.reset_ractor_memo(method_name, *args, **kwargs)` — class method to clear one or all entries from the Ractor-safe shared cache for a given method
|
|
20
|
+
- `.reset_all_ractor_memos` — class method to clear the entire Ractor-safe shared cache for this class
|
|
21
|
+
- `.ractor_memoized?(method_name, *args, **kwargs)` — returns `true` if a live entry exists in the Ractor-safe shared cache for the given call signature
|
|
22
|
+
- `.ractor_memo_count(method_name = nil)` — returns the number of live entries in the Ractor-safe shared cache; scoped to one method when a name is given
|
|
23
|
+
- `fiber_local: true` option on `memoize` — stores results in `Fiber[:__safe_memoize__]` rather than instance variables, giving each fiber its own isolated cache that is automatically discarded when the fiber terminates; no `Mutex` is acquired because fibers are cooperative; a per-fiber ownership sentinel ensures inherited storage from parent fibers is replaced with a fresh isolated store on first write; supports all standard options (`ttl:`, `ttl_refresh:`, `max_size:`, `if:`, `unless:`, `key:`); incompatible with `shared:` and `store:` (raises `ArgumentError`)
|
|
24
|
+
- `#fiber_local_memoized?(method_name, *args, **kwargs)` — returns `true` if the given call is currently cached in the current fiber's store
|
|
25
|
+
- `#reset_fiber_memo(method_name, *args, **kwargs)` — clears one or all fiber-local cached entries for a method in the current fiber
|
|
26
|
+
- `#reset_all_fiber_memos` — clears all fiber-local cached entries for this instance in the current fiber
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- `call_memo_hooks` no longer raises `Ractor::IsolationError` when called from a worker Ractor — `SafeMemoize.configuration` (a module-level ivar) is now accessed only from the main Ractor; `ActiveSupport::Notifications` and `StatsD` dispatch are silently skipped from worker Ractors; hook-error handling falls back to `warn` rather than reading the configuration handler
|
|
31
|
+
- CI coverage ordering — ractor specs now run *last* (after all other specs) so that Ractor background threads cannot disrupt Ruby's Coverage counters while collecting coverage for non-Ractor code; previously only store specs were ordered first
|
|
32
|
+
- Codecov reporting accuracy — switched SimpleCov output from `.resultset.json` (internal format, misread by Codecov as ~85%) to `coverage/coverage.json` via `simplecov_json_formatter`; CI now uploads the correct file
|
|
33
|
+
- CI coverage ordering — `bundle exec rspec` ran files alphabetically, causing `ractor_spec.rb` to execute before `spec/stores/`, disrupting Ruby's Coverage counters and dropping reported coverage to ~96%; CI now uses `bundle exec rake spec`, which enforces the store-first ordering already documented in the Rakefile
|
|
34
|
+
|
|
11
35
|
## [1.1.0] - 2026-05-22
|
|
12
36
|
|
|
13
37
|
### Added
|
|
@@ -25,7 +49,7 @@ from v1.0.0 onwards. Prior 0.x releases may include breaking changes between min
|
|
|
25
49
|
- `store:` type guard in `ClassMethods#memoize` collapsed to an inline guard clause so Ruby's Coverage module counts the raise correctly
|
|
26
50
|
- 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
51
|
|
|
28
|
-
## [1.0.0] - 2026-05-22
|
|
52
|
+
## [1.0.0] - 2026-05-22
|
|
29
53
|
|
|
30
54
|
### Added
|
|
31
55
|
|
data/README.md
CHANGED
|
@@ -67,6 +67,12 @@ SafeMemoize uses Ruby's `prepend` mechanism. When you call `memoize :method_name
|
|
|
67
67
|
- [`memo_age` and `memo_stale?` for TTL introspection](#cache-inspection)
|
|
68
68
|
- [Class-level `key:` option for shared cache key generation](#custom-cache-keys)
|
|
69
69
|
- [`shared_memo_age` and `shared_memo_stale?` for shared cache TTL inspection](#shared-cache)
|
|
70
|
+
- [Pluggable external cache stores — Redis, Rails.cache, or any custom adapter](#pluggable-cache-stores)
|
|
71
|
+
- [Global default store via `Configuration#default_store`](#pluggable-cache-stores)
|
|
72
|
+
- [`SafeMemoize::Adapters::ConcurrentRuby` — optional `concurrent-ruby` store with parallel-read locking](#concurrent-ruby-adapter)
|
|
73
|
+
- [Class-level `.safe_memoize_store=` — set a per-class default store without touching global config](#class-level-default-store-safe_memoize_store)
|
|
74
|
+
- [Fiber-local memoization via `fiber_local: true` — isolated per-fiber cache, no mutex, works with Async/Falcon](#fiber-local-memoization)
|
|
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)
|
|
70
76
|
|
|
71
77
|
## Installation
|
|
72
78
|
|
|
@@ -498,6 +504,48 @@ Hooks (`on_memo_hit`, `on_memo_miss`, `on_memo_expire`, `on_memo_evict`) fire on
|
|
|
498
504
|
|
|
499
505
|
[↑ Back to features](#features)
|
|
500
506
|
|
|
507
|
+
### Fiber-local memoization
|
|
508
|
+
|
|
509
|
+
Pass `fiber_local: true` to store results in `Fiber[:__safe_memoize__]` rather than instance variables. Each fiber gets its own isolated cache that is automatically discarded when the fiber terminates — no explicit cleanup required.
|
|
510
|
+
|
|
511
|
+
This is the right choice for Fiber-based concurrency frameworks like [Async](https://github.com/socketry/async), [Falcon](https://github.com/socketry/falcon), and Rails async controllers, where multiple fibers share the same object instance and must not see each other's cached values.
|
|
512
|
+
|
|
513
|
+
```ruby
|
|
514
|
+
class ApiClient
|
|
515
|
+
prepend SafeMemoize
|
|
516
|
+
|
|
517
|
+
def fetch(path)
|
|
518
|
+
http_get(path)
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
memoize :fetch, fiber_local: true
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
client = ApiClient.new
|
|
525
|
+
|
|
526
|
+
Fiber.new { client.fetch("/a") }.resume # computes in this fiber
|
|
527
|
+
Fiber.new { client.fetch("/a") }.resume # computes again — isolated cache
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
`fiber_local: true` works with all standard options: `ttl:`, `ttl_refresh:`, `max_size:`, `if:`, `unless:`, and `key:`. It is incompatible with `shared:` and `store:` (both raise `ArgumentError`).
|
|
531
|
+
|
|
532
|
+
No `Mutex` is acquired because fibers within a single thread are cooperative — only one fiber executes at a time.
|
|
533
|
+
|
|
534
|
+
**Fiber isolation guarantee**: Ruby's `Fiber.new` inherits the parent fiber's local storage by default. SafeMemoize detects inherited stores via an ownership sentinel and replaces them with a fresh, isolated store on first write, so child fibers never see the parent's cached entries.
|
|
535
|
+
|
|
536
|
+
Instance-level inspection and reset for fiber-local entries use dedicated methods:
|
|
537
|
+
|
|
538
|
+
```ruby
|
|
539
|
+
obj.fiber_local_memoized?(:fetch, "/a") # true / false for the current fiber
|
|
540
|
+
obj.reset_fiber_memo(:fetch) # clear all entries for :fetch in current fiber
|
|
541
|
+
obj.reset_fiber_memo(:fetch, "/a") # clear one specific entry
|
|
542
|
+
obj.reset_all_fiber_memos # clear all fiber-local entries for this instance
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
Lifecycle hooks and cache metrics work the same as for regular memoization. The existing `memoized?`, `reset_memo`, and `memo_count` methods operate on the instance-variable cache; use the `fiber_local_*` / `reset_fiber_*` API for fiber-local entries.
|
|
546
|
+
|
|
547
|
+
[↑ Back to features](#features)
|
|
548
|
+
|
|
501
549
|
### Bulk memoization
|
|
502
550
|
|
|
503
551
|
Use `memoize_all` to memoize every public method defined on the class in one call:
|
|
@@ -698,7 +746,7 @@ Both settings apply at definition time — methods already memoized before `conf
|
|
|
698
746
|
SafeMemoize.reset_configuration!
|
|
699
747
|
```
|
|
700
748
|
|
|
701
|
-
The configure block also accepts `on_hook_error`, `on_deprecation`, `active_support_notifications`, and `
|
|
749
|
+
The configure block also accepts `on_hook_error`, `on_deprecation`, `active_support_notifications`, `statsd_client`, and `default_store` (covered in [Hook error isolation](#hook-error-isolation), [Deprecation](#deprecation), [ActiveSupport::Notifications](#activesupportnotifications), [StatsD](#statsd), and [Pluggable cache stores](#pluggable-cache-stores)).
|
|
702
750
|
|
|
703
751
|
[↑ Back to features](#features)
|
|
704
752
|
|
|
@@ -864,6 +912,155 @@ end
|
|
|
864
912
|
|
|
865
913
|
[↑ Back to features](#features)
|
|
866
914
|
|
|
915
|
+
### Pluggable cache stores
|
|
916
|
+
|
|
917
|
+
By default, memoized results live in a per-instance hash — fast, but private to each object. Pass `store:` to route reads and writes through any external backend, enabling cross-process and distributed memoization.
|
|
918
|
+
|
|
919
|
+
#### Built-in: `Stores::Memory`
|
|
920
|
+
|
|
921
|
+
`Stores::Memory` is the built-in in-process store. It is used automatically by the `store:` default and is the reference implementation for custom adapters. You can pass your own instance to share a cache across multiple classes or to set a TTL on the shared store:
|
|
922
|
+
|
|
923
|
+
```ruby
|
|
924
|
+
SHARED_STORE = SafeMemoize::Stores::Memory.new
|
|
925
|
+
|
|
926
|
+
class UserService
|
|
927
|
+
prepend SafeMemoize
|
|
928
|
+
def find(id) = User.find(id)
|
|
929
|
+
memoize :find, store: SHARED_STORE, ttl: 60
|
|
930
|
+
end
|
|
931
|
+
|
|
932
|
+
class PostService
|
|
933
|
+
prepend SafeMemoize
|
|
934
|
+
def author(post) = User.find(post.user_id)
|
|
935
|
+
memoize :author, store: SHARED_STORE
|
|
936
|
+
end
|
|
937
|
+
```
|
|
938
|
+
|
|
939
|
+
The store is shared across all instances of a class, so the method is computed only once per unique argument set regardless of how many objects exist.
|
|
940
|
+
|
|
941
|
+
#### Redis adapter
|
|
942
|
+
|
|
943
|
+
Requires a Redis-compatible client (the `redis` gem or any drop-in replacement):
|
|
944
|
+
|
|
945
|
+
```ruby
|
|
946
|
+
require "safe_memoize/stores/redis"
|
|
947
|
+
require "redis"
|
|
948
|
+
|
|
949
|
+
REDIS_STORE = SafeMemoize::Stores::Redis.new(::Redis.new)
|
|
950
|
+
|
|
951
|
+
class PricingService
|
|
952
|
+
prepend SafeMemoize
|
|
953
|
+
def quote(sku) = api_fetch(sku)
|
|
954
|
+
memoize :quote, store: REDIS_STORE, ttl: 300
|
|
955
|
+
end
|
|
956
|
+
```
|
|
957
|
+
|
|
958
|
+
Values and keys are serialized with `Marshal` (Base64-encoded via `Array#pack("m0")`). TTL is forwarded to Redis as `PX` (milliseconds) for sub-second precision. `clear` uses `SCAN` so it never blocks the Redis event loop. All keys are namespace-scoped (default: `"safe_memoize"`) so multiple stores or applications can share one Redis instance:
|
|
959
|
+
|
|
960
|
+
```ruby
|
|
961
|
+
REDIS_STORE = SafeMemoize::Stores::Redis.new(::Redis.new, namespace: "myapp:memo")
|
|
962
|
+
```
|
|
963
|
+
|
|
964
|
+
#### Rails.cache adapter
|
|
965
|
+
|
|
966
|
+
Wraps any `ActiveSupport::Cache::Store`, including `Rails.cache`:
|
|
967
|
+
|
|
968
|
+
```ruby
|
|
969
|
+
require "safe_memoize/stores/rails_cache"
|
|
970
|
+
|
|
971
|
+
RAILS_STORE = SafeMemoize::Stores::RailsCache.new(Rails.cache)
|
|
972
|
+
|
|
973
|
+
class CatalogService
|
|
974
|
+
prepend SafeMemoize
|
|
975
|
+
def fetch(slug) = Catalog.find_by!(slug: slug)
|
|
976
|
+
memoize :fetch, store: RAILS_STORE, ttl: 600
|
|
977
|
+
end
|
|
978
|
+
```
|
|
979
|
+
|
|
980
|
+
Cached `nil` and `false` values are distinguished from a cache miss via a sentinel envelope, so falsy results are preserved correctly. TTL is forwarded as `expires_in:` for native store expiry. `clear` uses `delete_matched` scoped to the namespace.
|
|
981
|
+
|
|
982
|
+
#### Custom adapters
|
|
983
|
+
|
|
984
|
+
Subclass `SafeMemoize::Stores::Base` and implement the six-method contract:
|
|
985
|
+
|
|
986
|
+
```ruby
|
|
987
|
+
class MyStore < SafeMemoize::Stores::Base
|
|
988
|
+
def read(key) = ... # return MISS if absent
|
|
989
|
+
def write(key, value, expires_in: nil) = ...
|
|
990
|
+
def delete(key) = ...
|
|
991
|
+
def clear = ...
|
|
992
|
+
def keys = ... # Array of stored keys
|
|
993
|
+
end
|
|
994
|
+
```
|
|
995
|
+
|
|
996
|
+
Use `SafeMemoize::Stores::Base::MISS` (a frozen sentinel object) as the return value from `read` when the key is absent — this distinguishes a cache miss from a cached `nil` or `false`.
|
|
997
|
+
|
|
998
|
+
#### concurrent-ruby adapter
|
|
999
|
+
|
|
1000
|
+
`SafeMemoize::Adapters::ConcurrentRuby` replaces the default `Mutex`-backed store with `Concurrent::Map` and `Concurrent::ReentrantReadWriteLock` from the [`concurrent-ruby`](https://github.com/ruby-concurrency/concurrent-ruby) gem. Multiple readers proceed in parallel; writers still get exclusive access. For read-heavy hot paths this can meaningfully reduce lock contention.
|
|
1001
|
+
|
|
1002
|
+
`concurrent-ruby` is a **soft dependency** — it is not required at runtime unless you instantiate the adapter. Add it to your own `Gemfile`:
|
|
1003
|
+
|
|
1004
|
+
```ruby
|
|
1005
|
+
gem "concurrent-ruby"
|
|
1006
|
+
```
|
|
1007
|
+
|
|
1008
|
+
Opt in per class:
|
|
1009
|
+
|
|
1010
|
+
```ruby
|
|
1011
|
+
class HotService
|
|
1012
|
+
prepend SafeMemoize
|
|
1013
|
+
self.safe_memoize_store = SafeMemoize::Adapters::ConcurrentRuby.new
|
|
1014
|
+
|
|
1015
|
+
def expensive(id) = db.find(id)
|
|
1016
|
+
memoize :expensive
|
|
1017
|
+
end
|
|
1018
|
+
```
|
|
1019
|
+
|
|
1020
|
+
Or set it globally:
|
|
1021
|
+
|
|
1022
|
+
```ruby
|
|
1023
|
+
SafeMemoize.configure do |c|
|
|
1024
|
+
c.default_store = SafeMemoize::Adapters::ConcurrentRuby.new
|
|
1025
|
+
end
|
|
1026
|
+
```
|
|
1027
|
+
|
|
1028
|
+
A `LoadError` with an actionable message is raised at instantiation if `concurrent-ruby` is not installed. The adapter is incompatible with `max_size:` and `shared:` (same constraints as all external stores).
|
|
1029
|
+
|
|
1030
|
+
#### Class-level default store (`safe_memoize_store=`)
|
|
1031
|
+
|
|
1032
|
+
Set a default store for every `memoize` call on a single class without touching the global configuration:
|
|
1033
|
+
|
|
1034
|
+
```ruby
|
|
1035
|
+
class ReportService
|
|
1036
|
+
prepend SafeMemoize
|
|
1037
|
+
self.safe_memoize_store = SafeMemoize::Adapters::ConcurrentRuby.new
|
|
1038
|
+
|
|
1039
|
+
def summary = compute_summary # routed through ConcurrentRuby
|
|
1040
|
+
memoize :summary
|
|
1041
|
+
end
|
|
1042
|
+
```
|
|
1043
|
+
|
|
1044
|
+
The resolution order is: per-method `store:` → class-level `.safe_memoize_store` → global `SafeMemoize.configuration.default_store`. Assign `nil` to clear. An invalid value (not a `Stores::Base` instance) raises `ArgumentError`.
|
|
1045
|
+
|
|
1046
|
+
#### Global default store
|
|
1047
|
+
|
|
1048
|
+
Set a default store for all compatible `memoize` calls without specifying `store:` on each one:
|
|
1049
|
+
|
|
1050
|
+
```ruby
|
|
1051
|
+
SafeMemoize.configure do |c|
|
|
1052
|
+
c.default_store = SafeMemoize::Stores::Redis.new(::Redis.new)
|
|
1053
|
+
end
|
|
1054
|
+
```
|
|
1055
|
+
|
|
1056
|
+
A per-method `store:` option always takes precedence. Methods using `max_size:` or `shared:` silently bypass the global default (LRU and shared-mode use their own storage). An invalid value raises `ArgumentError` at `memoize` time. Reset with `SafeMemoize.reset_configuration!`.
|
|
1057
|
+
|
|
1058
|
+
#### Compatibility
|
|
1059
|
+
|
|
1060
|
+
The `store:` option composes with `ttl:`, `ttl_refresh:`, `if:`, `unless:`, lifecycle hooks, and cache metrics. It is incompatible with `max_size:` (use the store adapter's own eviction) and `shared:` (raise `ArgumentError` if combined).
|
|
1061
|
+
|
|
1062
|
+
[↑ Back to features](#features)
|
|
1063
|
+
|
|
867
1064
|
### Deprecation
|
|
868
1065
|
|
|
869
1066
|
SafeMemoize ships a structured deprecation helper for gem authors who build on top of it:
|
|
@@ -895,17 +1092,72 @@ end
|
|
|
895
1092
|
|
|
896
1093
|
[↑ Back to features](#features)
|
|
897
1094
|
|
|
1095
|
+
## Ractor-safe shared cache
|
|
1096
|
+
|
|
1097
|
+
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.
|
|
1098
|
+
|
|
1099
|
+
```ruby
|
|
1100
|
+
class PriceService
|
|
1101
|
+
prepend SafeMemoize
|
|
1102
|
+
|
|
1103
|
+
def fetch_price(item_id)
|
|
1104
|
+
external_api.get("/prices/#{item_id}")
|
|
1105
|
+
end
|
|
1106
|
+
|
|
1107
|
+
memoize :fetch_price, shared: true, ractor_safe: true, ttl: 300
|
|
1108
|
+
end
|
|
1109
|
+
|
|
1110
|
+
# Main Ractor — multiple threads share one cache entry
|
|
1111
|
+
20.times.map { Thread.new { PriceService.new.fetch_price(42) } }.map(&:value)
|
|
1112
|
+
|
|
1113
|
+
# Worker Ractors also read from and write to the same supervisor cache
|
|
1114
|
+
result = Ractor.new(PriceService) { |s| s.new.fetch_price(42) }.take
|
|
1115
|
+
```
|
|
1116
|
+
|
|
1117
|
+
### How it works
|
|
1118
|
+
|
|
1119
|
+
- A **supervisor `Ractor`** is created once per class the first time a `ractor_safe: true` method is memoized. It owns a plain Ruby `Hash` and responds to `:fetch`, `:store`, `:delete_all`, `:delete_one`, `:clear`, `:memoized`, and `:count` messages.
|
|
1120
|
+
- The memoize **wrapper Proc** is frozen via `Ractor.make_shareable` before being registered with `define_method`, so the class can be passed directly into `Ractor.new` blocks.
|
|
1121
|
+
- Cached values are deep-frozen via `Ractor.make_shareable`. Values that cannot be made shareable (e.g. a `Mutex`) raise `ArgumentError`.
|
|
1122
|
+
- **Thread safety** inside the main Ractor (multiple threads) is handled by per-call tags (`Thread.current.object_id`) combined with `Ractor.receive_if`, so concurrent threads never consume each other's replies.
|
|
1123
|
+
- `ttl:` is supported. Expired entries are skipped by the supervisor's `:fetch` handler.
|
|
1124
|
+
|
|
1125
|
+
### Constraints
|
|
1126
|
+
|
|
1127
|
+
`ractor_safe: true` is intentionally limited. The following options are incompatible and raise `ArgumentError` at `memoize` time:
|
|
1128
|
+
|
|
1129
|
+
| Option | Reason |
|
|
1130
|
+
|---|---|
|
|
1131
|
+
| `if:` / `unless:` | Conditional Procs are non-Ractor-shareable |
|
|
1132
|
+
| `max_size:` | LRU order tracking requires a non-shareable Ruby `Hash` |
|
|
1133
|
+
| `ttl_refresh:` | Requires re-examining the record on every hit |
|
|
1134
|
+
| `key:` | Custom key Procs are non-Ractor-shareable |
|
|
1135
|
+
| `store:` | External adapters are incompatible with the supervisor model |
|
|
1136
|
+
|
|
1137
|
+
### Class-level API
|
|
1138
|
+
|
|
1139
|
+
```ruby
|
|
1140
|
+
PriceService.ractor_memoized?(:fetch_price, 42) # → true / false
|
|
1141
|
+
PriceService.ractor_memo_count # → total live entries
|
|
1142
|
+
PriceService.ractor_memo_count(:fetch_price) # → entries for one method
|
|
1143
|
+
PriceService.reset_ractor_memo(:fetch_price, 42) # → clear one entry
|
|
1144
|
+
PriceService.reset_ractor_memo(:fetch_price) # → clear all entries for method
|
|
1145
|
+
PriceService.reset_all_ractor_memos # → clear entire shared cache
|
|
1146
|
+
```
|
|
1147
|
+
|
|
1148
|
+
[↑ Back to features](#features)
|
|
1149
|
+
|
|
898
1150
|
## Ractor compatibility
|
|
899
1151
|
|
|
900
|
-
|
|
1152
|
+
Regular `memoize` (without `ractor_safe: true`) is **not Ractor-compatible**. Passing a class that uses `memoize` into a `Ractor.new` block raises `RuntimeError: defined with an un-shareable Proc in a different Ractor`. There are two root causes:
|
|
901
1153
|
|
|
902
1154
|
1. **Non-shareable closures.** `ClassMethods#memoize` builds anonymous modules using `define_method` with blocks that close over local variables (`ttl`, `max_size`, `condition`, `shared_mutex`, …). Ruby marks those Procs as non-Ractor-shareable, so the host class cannot be sent to a Ractor.
|
|
903
1155
|
|
|
904
|
-
2. **Mutable module-level state.** `SafeMemoize.configuration` reads `@configuration` from the `SafeMemoize` module — a mutable ivar on a shared constant — which raises `Ractor::IsolationError` from a non-main Ractor.
|
|
1156
|
+
2. **Mutable module-level state.** `SafeMemoize.configuration` reads `@configuration` from the `SafeMemoize` module — a mutable ivar on a shared constant — which raises `Ractor::IsolationError` from a non-main Ractor.
|
|
905
1157
|
|
|
906
|
-
**Workaround
|
|
1158
|
+
**Workaround for shared caches:** use `memoize :method, shared: true, ractor_safe: true` (see [Ractor-safe shared cache](#ractor-safe-shared-cache) above).
|
|
907
1159
|
|
|
908
|
-
|
|
1160
|
+
**Workaround for per-instance caches:** Use Ruby Threads instead of Ractors — SafeMemoize is fully thread-safe via double-check locking and per-instance Mutexes. If you need true parallelism with Ractors, perform computation inside the Ractor without memoization and send frozen results back via `Ractor#send`.
|
|
909
1161
|
|
|
910
1162
|
## Development
|
|
911
1163
|
|
|
@@ -982,6 +1234,9 @@ Anything **not** listed here — internal modules, private methods, `@__safe_mem
|
|
|
982
1234
|
| `unless:` | `Symbol \| Proc \| nil` | `nil` | Store only when falsy |
|
|
983
1235
|
| `shared:` | `Boolean` | `false` | Class-level shared cache |
|
|
984
1236
|
| `key:` | `Proc \| nil` | `nil` | Class-level custom key generator |
|
|
1237
|
+
| `store:` | `Stores::Base \| nil` | `nil` | External cache store adapter; incompatible with `max_size:` and `shared:` |
|
|
1238
|
+
| `fiber_local:` | `Boolean` | `false` | Fiber-local cache; each fiber gets an isolated store; incompatible with `shared:` and `store:` |
|
|
1239
|
+
| `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:` |
|
|
985
1240
|
|
|
986
1241
|
### `memoize_all` options (class method)
|
|
987
1242
|
|
|
@@ -1055,6 +1310,14 @@ All `memoize` option keys above, plus:
|
|
|
1055
1310
|
| `memoize_with_custom_key(method_name) { \|*args, **kwargs\| … }` | Instance-level key generator |
|
|
1056
1311
|
| `clear_custom_keys(method_name = nil)` | Remove one or all key generators |
|
|
1057
1312
|
|
|
1313
|
+
**Fiber-local cache (when any method uses `fiber_local: true`)**
|
|
1314
|
+
|
|
1315
|
+
| Method | Returns |
|
|
1316
|
+
|---|---|
|
|
1317
|
+
| `fiber_local_memoized?(method_name, *args, **kwargs)` | `Boolean` — cached in the current fiber? |
|
|
1318
|
+
| `reset_fiber_memo(method_name, *args, **kwargs)` | `nil` — clear one or all entries in current fiber |
|
|
1319
|
+
| `reset_all_fiber_memos` | `nil` — clear all fiber-local entries for this instance |
|
|
1320
|
+
|
|
1058
1321
|
### Shared-cache class methods (added when any method uses `shared: true`)
|
|
1059
1322
|
|
|
1060
1323
|
| Method | Returns |
|
|
@@ -1066,6 +1329,15 @@ All `memoize` option keys above, plus:
|
|
|
1066
1329
|
| `shared_memo_age(method_name, *args, **kwargs)` | `Numeric \| nil` |
|
|
1067
1330
|
| `shared_memo_stale?(method_name, *args, **kwargs)` | `Boolean` |
|
|
1068
1331
|
|
|
1332
|
+
**Ractor-safe shared cache (added when any method uses `ractor_safe: true`)**
|
|
1333
|
+
|
|
1334
|
+
| Method | Returns |
|
|
1335
|
+
|---|---|
|
|
1336
|
+
| `reset_ractor_memo(method_name, *args, **kwargs)` | `nil` — clear one or all entries |
|
|
1337
|
+
| `reset_all_ractor_memos` | `nil` — clear the entire Ractor-safe shared cache |
|
|
1338
|
+
| `ractor_memoized?(method_name, *args, **kwargs)` | `Boolean` — live entry exists? |
|
|
1339
|
+
| `ractor_memo_count(method_name = nil)` | `Integer` — live entry count |
|
|
1340
|
+
|
|
1069
1341
|
### `SafeMemoize::Configuration` attributes
|
|
1070
1342
|
|
|
1071
1343
|
| Attribute | Type | Default |
|
|
@@ -1077,10 +1349,20 @@ All `memoize` option keys above, plus:
|
|
|
1077
1349
|
| `active_support_notifications` | `Boolean` | `false` |
|
|
1078
1350
|
| `statsd_client` | `Object \| nil` | `nil` |
|
|
1079
1351
|
| `opentelemetry_tracer` | `Object \| nil` | `nil` |
|
|
1352
|
+
| `default_store` | `Stores::Base \| nil` | `nil` |
|
|
1353
|
+
|
|
1354
|
+
### Store adapter classes (v1.1.0+)
|
|
1355
|
+
|
|
1356
|
+
| Class | Require | Notes |
|
|
1357
|
+
|---|---|---|
|
|
1358
|
+
| `SafeMemoize::Stores::Base` | auto | Abstract base — subclass to build custom adapters; exposes `MISS` sentinel |
|
|
1359
|
+
| `SafeMemoize::Stores::Memory` | auto | Built-in in-process store; reference implementation |
|
|
1360
|
+
| `SafeMemoize::Stores::Redis` | `"safe_memoize/stores/redis"` | Redis-backed adapter; Marshal serialization; `PX` TTL |
|
|
1361
|
+
| `SafeMemoize::Stores::RailsCache` | `"safe_memoize/stores/rails_cache"` | `ActiveSupport::Cache::Store` wrapper |
|
|
1080
1362
|
|
|
1081
1363
|
### Opt-in extensions (not guaranteed until their owning milestone ships)
|
|
1082
1364
|
|
|
1083
|
-
The following are available now but reside under `require "safe_memoize/rails"` and are not covered by the
|
|
1365
|
+
The following are available now but reside under `require "safe_memoize/rails"` and are not covered by the semver guarantee until the v1.x milestone that owns them is declared stable:
|
|
1084
1366
|
|
|
1085
1367
|
- `SafeMemoize::Rails` module (`track`, `reset_tracked!`)
|
|
1086
1368
|
- `SafeMemoize::Rails::RequestScoped` concern
|
data/ROADMAP.md
CHANGED
|
@@ -4,32 +4,6 @@ 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.*
|
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
|
|
@@ -29,6 +29,15 @@ module SafeMemoize
|
|
|
29
29
|
# {Stores::Base} subclass instance. The store is shared across all instances of the
|
|
30
30
|
# class. When +nil+, the default per-instance in-process hash is used.
|
|
31
31
|
# Cannot be combined with +max_size:+ or +shared:+.
|
|
32
|
+
# @param fiber_local [Boolean] when +true+, results are stored in
|
|
33
|
+
# +Fiber[:__safe_memoize__]+ rather than instance variables. Each fiber gets its
|
|
34
|
+
# own isolated cache that is automatically discarded when the fiber terminates. No
|
|
35
|
+
# mutex is acquired. Cannot be combined with +shared:+ or +store:+.
|
|
36
|
+
# @param ractor_safe [Boolean] when +true+, the class-level shared cache is owned by
|
|
37
|
+
# a supervisor +Ractor+ rather than a +Mutex+-protected ivar, making it accessible
|
|
38
|
+
# from worker Ractors. Requires +shared: true+. Cached values are deep-frozen via
|
|
39
|
+
# +Ractor.make_shareable+. Incompatible with +if:+, +unless:+, +max_size:+,
|
|
40
|
+
# +ttl_refresh:+, +key:+, and +store:+.
|
|
32
41
|
# @return [void]
|
|
33
42
|
# @raise [ArgumentError] if the method does not exist, or option values are invalid
|
|
34
43
|
#
|
|
@@ -46,7 +55,7 @@ module SafeMemoize
|
|
|
46
55
|
# @example With a custom store
|
|
47
56
|
# STORE = SafeMemoize::Stores::Memory.new
|
|
48
57
|
# 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)
|
|
58
|
+
def memoize(method_name, ttl: nil, max_size: nil, ttl_refresh: false, if: nil, unless: nil, shared: false, key: nil, store: nil, fiber_local: false, ractor_safe: false)
|
|
50
59
|
method_name = method_name.to_sym
|
|
51
60
|
|
|
52
61
|
unless method_defined?(method_name) || private_method_defined?(method_name) || protected_method_defined?(method_name)
|
|
@@ -99,17 +108,37 @@ module SafeMemoize
|
|
|
99
108
|
raise ArgumentError, "shared: and store: cannot be combined" if shared
|
|
100
109
|
end
|
|
101
110
|
|
|
102
|
-
|
|
103
|
-
|
|
111
|
+
if fiber_local
|
|
112
|
+
raise ArgumentError, "fiber_local: and shared: cannot be combined" if shared
|
|
113
|
+
raise ArgumentError, "fiber_local: and store: cannot be combined" if store
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
if ractor_safe
|
|
117
|
+
raise ArgumentError, "ractor_safe: requires shared: true" unless shared
|
|
118
|
+
raise ArgumentError, "ractor_safe: is incompatible with if:/unless:" if cond_if || cond_unless
|
|
119
|
+
raise ArgumentError, "ractor_safe: is incompatible with max_size:" if max_size
|
|
120
|
+
raise ArgumentError, "ractor_safe: is incompatible with ttl_refresh:" if ttl_refresh
|
|
121
|
+
raise ArgumentError, "ractor_safe: is incompatible with key:" if key
|
|
122
|
+
raise ArgumentError, "ractor_safe: is incompatible with store:" if store
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Resolve effective store: per-method store: wins; then class-level
|
|
126
|
+
# safe_memoize_store; then global default_store. max_size: and shared:
|
|
127
|
+
# are incompatible with external stores — fall back silently.
|
|
104
128
|
effective_store = store
|
|
105
129
|
if effective_store.nil? && !max_size && !shared
|
|
106
|
-
|
|
107
|
-
if
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
130
|
+
class_store = safe_memoize_store
|
|
131
|
+
if class_store
|
|
132
|
+
effective_store = class_store
|
|
133
|
+
else
|
|
134
|
+
global_default = SafeMemoize.configuration.default_store
|
|
135
|
+
if global_default
|
|
136
|
+
unless global_default.is_a?(SafeMemoize::Stores::Base)
|
|
137
|
+
raise ArgumentError,
|
|
138
|
+
"SafeMemoize.configuration.default_store must be a Stores::Base instance (got #{global_default.class})"
|
|
139
|
+
end
|
|
140
|
+
effective_store = global_default
|
|
111
141
|
end
|
|
112
|
-
effective_store = global_default
|
|
113
142
|
end
|
|
114
143
|
end
|
|
115
144
|
|
|
@@ -165,6 +194,77 @@ module SafeMemoize
|
|
|
165
194
|
return
|
|
166
195
|
end
|
|
167
196
|
|
|
197
|
+
if fiber_local
|
|
198
|
+
mod = Module.new do
|
|
199
|
+
define_method(method_name) do |*args, **kwargs, &block|
|
|
200
|
+
return super(*args, **kwargs, &block) if block
|
|
201
|
+
|
|
202
|
+
cache_key = compute_cache_key(method_name, args, kwargs)
|
|
203
|
+
fiber_cache = fiber_memo_cache!
|
|
204
|
+
record = fiber_cache[cache_key]
|
|
205
|
+
|
|
206
|
+
if memo_record_live?(record)
|
|
207
|
+
if max_size
|
|
208
|
+
lru = fiber_memo_lru![method_name] ||= {}
|
|
209
|
+
lru.delete(cache_key)
|
|
210
|
+
lru[cache_key] = true
|
|
211
|
+
end
|
|
212
|
+
record[:expires_at] = memo_expires_at(ttl) if ttl_refresh
|
|
213
|
+
record_cache_hit(method_name, args, kwargs)
|
|
214
|
+
call_memo_hooks(:on_hit, cache_key, record)
|
|
215
|
+
memo_record_value(record)
|
|
216
|
+
else
|
|
217
|
+
call_memo_hooks(:on_expire, cache_key, record) if record
|
|
218
|
+
|
|
219
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
220
|
+
value = Adapters::OpenTelemetry.trace(
|
|
221
|
+
SafeMemoize.configuration.opentelemetry_tracer, method_name, self.class.name
|
|
222
|
+
) { super(*args, **kwargs) }
|
|
223
|
+
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
224
|
+
|
|
225
|
+
new_record = memo_record(value, expires_at: memo_expires_at(ttl))
|
|
226
|
+
|
|
227
|
+
if !condition || condition.call(value)
|
|
228
|
+
if max_size
|
|
229
|
+
lru = fiber_memo_lru![method_name] ||= {}
|
|
230
|
+
if lru.size >= max_size
|
|
231
|
+
evict_key = lru.keys.first
|
|
232
|
+
lru.delete(evict_key)
|
|
233
|
+
evicted = fiber_cache.delete(evict_key)
|
|
234
|
+
call_memo_hooks(:on_evict, evict_key, evicted) if evicted
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
fiber_cache[cache_key] = new_record
|
|
238
|
+
if max_size
|
|
239
|
+
lru = fiber_memo_lru![method_name] ||= {}
|
|
240
|
+
lru.delete(cache_key)
|
|
241
|
+
lru[cache_key] = true
|
|
242
|
+
end
|
|
243
|
+
call_memo_hooks(:on_store, cache_key, new_record)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
record_cache_miss(method_name, args, kwargs, elapsed_time)
|
|
247
|
+
call_memo_hooks(:on_miss, cache_key, new_record)
|
|
248
|
+
|
|
249
|
+
value
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
send(visibility, method_name)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
prepend mod
|
|
257
|
+
|
|
258
|
+
return
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
if ractor_safe
|
|
262
|
+
extend(RactorSharedMethods) unless is_a?(RactorSharedMethods)
|
|
263
|
+
supervisor = __safe_memo_ractor_supervisor__
|
|
264
|
+
__memoize_ractor_safe__(method_name, ttl, visibility, supervisor)
|
|
265
|
+
return
|
|
266
|
+
end
|
|
267
|
+
|
|
168
268
|
if shared
|
|
169
269
|
klass = self
|
|
170
270
|
shared_mutex = klass.send(:__safe_memo_shared_mutex__)
|
|
@@ -303,6 +403,32 @@ module SafeMemoize
|
|
|
303
403
|
prepend mod
|
|
304
404
|
end
|
|
305
405
|
|
|
406
|
+
# Returns the class-level default cache store, or +nil+ if not set.
|
|
407
|
+
#
|
|
408
|
+
# Set this to any {Stores::Base} instance to route every +memoize+ call on
|
|
409
|
+
# this class through that store, without needing to pass +store:+ to each
|
|
410
|
+
# individual +memoize+ call. A per-method +store:+ option still takes
|
|
411
|
+
# precedence, and the global {SafeMemoize::Configuration#default_store} is
|
|
412
|
+
# the final fallback.
|
|
413
|
+
#
|
|
414
|
+
# @return [Stores::Base, nil]
|
|
415
|
+
def safe_memoize_store
|
|
416
|
+
@__safe_memoize_store__
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Sets the class-level default cache store.
|
|
420
|
+
#
|
|
421
|
+
# @param store [Stores::Base, nil] a store instance, or +nil+ to clear
|
|
422
|
+
# @return [Stores::Base, nil]
|
|
423
|
+
# @raise [ArgumentError] if +store+ is not a {Stores::Base} instance (and not +nil+)
|
|
424
|
+
def safe_memoize_store=(store)
|
|
425
|
+
if store && !store.is_a?(SafeMemoize::Stores::Base)
|
|
426
|
+
raise ArgumentError,
|
|
427
|
+
"safe_memoize_store= must be a SafeMemoize::Stores::Base instance (got #{store.class})"
|
|
428
|
+
end
|
|
429
|
+
@__safe_memoize_store__ = store
|
|
430
|
+
end
|
|
431
|
+
|
|
306
432
|
# Memoizes every eligible public instance method defined directly on the class.
|
|
307
433
|
#
|
|
308
434
|
# Accepts all options that {#memoize} accepts, plus +:except:+ and +:only:+.
|
|
@@ -477,5 +603,67 @@ module SafeMemoize
|
|
|
477
603
|
|
|
478
604
|
:public
|
|
479
605
|
end
|
|
606
|
+
|
|
607
|
+
# Builds and prepends the ractor_safe memoize wrapper in its own method so
|
|
608
|
+
# the Proc only closes over the four Ractor-shareable locals (method_name,
|
|
609
|
+
# ttl, visibility, supervisor) rather than the full memoize binding, which
|
|
610
|
+
# contains non-shareable objects like SafeMemoize.configuration.
|
|
611
|
+
#
|
|
612
|
+
# The Proc is created inside module_eval so its self is the anonymous
|
|
613
|
+
# module (a shareable object), then frozen via Ractor.make_shareable before
|
|
614
|
+
# being passed to define_method. Without that step, ANY define_method Proc
|
|
615
|
+
# is considered non-shareable by Ruby 3.x even when it captures nothing.
|
|
616
|
+
def __memoize_ractor_safe__(method_name, ttl, visibility, supervisor)
|
|
617
|
+
mod = Module.new
|
|
618
|
+
wrapper = mod.module_eval do
|
|
619
|
+
Ractor.make_shareable(
|
|
620
|
+
proc do |*args, **kwargs, &block|
|
|
621
|
+
return super(*args, **kwargs, &block) if block
|
|
622
|
+
|
|
623
|
+
cache_key = Ractor.make_shareable([method_name, deep_freeze_copy(args), deep_freeze_copy(kwargs)])
|
|
624
|
+
|
|
625
|
+
tag = Thread.current.object_id
|
|
626
|
+
supervisor.send(Ractor.make_shareable([Ractor.current, tag, :fetch, cache_key]))
|
|
627
|
+
response = Ractor.receive_if { |m| m.is_a?(Array) && m[0] == tag }[1]
|
|
628
|
+
|
|
629
|
+
if response[:hit]
|
|
630
|
+
record_cache_hit(method_name, args, kwargs)
|
|
631
|
+
call_memo_hooks(:on_hit, cache_key, response[:record])
|
|
632
|
+
return response[:record][:value]
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
636
|
+
value = super(*args, **kwargs)
|
|
637
|
+
elapsed_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time
|
|
638
|
+
|
|
639
|
+
begin
|
|
640
|
+
shareable_value = Ractor.make_shareable(value)
|
|
641
|
+
rescue => e
|
|
642
|
+
raise ArgumentError, "ractor_safe: memoized values must be Ractor-shareable (#{e.message})"
|
|
643
|
+
end
|
|
644
|
+
|
|
645
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
646
|
+
record = Ractor.make_shareable({
|
|
647
|
+
value: shareable_value,
|
|
648
|
+
expires_at: ttl ? now + ttl : nil,
|
|
649
|
+
cached_at: now
|
|
650
|
+
})
|
|
651
|
+
|
|
652
|
+
supervisor.send(Ractor.make_shareable([Ractor.current, tag, :store, cache_key, record]))
|
|
653
|
+
stored = Ractor.receive_if { |m| m.is_a?(Array) && m[0] == tag }[1]
|
|
654
|
+
stored_record = stored[:stored]
|
|
655
|
+
|
|
656
|
+
record_cache_miss(method_name, args, kwargs, elapsed_time)
|
|
657
|
+
call_memo_hooks(:on_store, cache_key, stored_record)
|
|
658
|
+
call_memo_hooks(:on_miss, cache_key, stored_record)
|
|
659
|
+
|
|
660
|
+
stored_record[:value]
|
|
661
|
+
end
|
|
662
|
+
)
|
|
663
|
+
end
|
|
664
|
+
mod.define_method(method_name, wrapper)
|
|
665
|
+
mod.send(visibility, method_name)
|
|
666
|
+
prepend mod
|
|
667
|
+
end
|
|
480
668
|
end
|
|
481
669
|
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
# Public and private helpers for fiber-local memoization.
|
|
5
|
+
#
|
|
6
|
+
# When a method is memoized with +fiber_local: true+, its cached values are
|
|
7
|
+
# stored in +Fiber[:__safe_memoize__]+ rather than instance variables, giving
|
|
8
|
+
# each fiber an isolated cache that is automatically discarded when the fiber
|
|
9
|
+
# terminates. No mutex is required because fibers are cooperative: only one
|
|
10
|
+
# fiber runs at a time within a thread.
|
|
11
|
+
module FiberLocalMethods
|
|
12
|
+
FIBER_STORE_KEY = :__safe_memoize__
|
|
13
|
+
|
|
14
|
+
# Returns +true+ if the given call is currently cached in the current fiber.
|
|
15
|
+
#
|
|
16
|
+
# @note In Ruby, +Fiber.new+ inherits the parent fiber's local storage. SafeMemoize
|
|
17
|
+
# detects inherited stores via an +:__owner__+ sentinel and creates a fresh
|
|
18
|
+
# isolated store for each fiber on first write.
|
|
19
|
+
#
|
|
20
|
+
# @param method_name [Symbol, String]
|
|
21
|
+
# @param args [Array]
|
|
22
|
+
# @param kwargs [Hash]
|
|
23
|
+
# @return [Boolean]
|
|
24
|
+
def fiber_local_memoized?(method_name, *args, **kwargs, &block)
|
|
25
|
+
return false if block
|
|
26
|
+
|
|
27
|
+
method_name = method_name.to_sym
|
|
28
|
+
cache_key = compute_cache_key(method_name, args, kwargs)
|
|
29
|
+
record = fiber_memo_cache_or_nil&.[](cache_key)
|
|
30
|
+
memo_record_live?(record)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Removes one or all fiber-local cached entries for a method in the current fiber.
|
|
34
|
+
#
|
|
35
|
+
# @param method_name [Symbol, String]
|
|
36
|
+
# @param args [Array]
|
|
37
|
+
# @param kwargs [Hash]
|
|
38
|
+
# @return [void]
|
|
39
|
+
def reset_fiber_memo(method_name, *args, **kwargs)
|
|
40
|
+
method_name = method_name.to_sym
|
|
41
|
+
cache = fiber_memo_cache_or_nil
|
|
42
|
+
return unless cache
|
|
43
|
+
|
|
44
|
+
if args.empty? && kwargs.empty?
|
|
45
|
+
cache.delete_if { |key, _| key[0] == method_name }
|
|
46
|
+
fiber_memo_lru_or_nil&.delete(method_name)
|
|
47
|
+
else
|
|
48
|
+
cache_key = compute_cache_key(method_name, args, kwargs)
|
|
49
|
+
cache.delete(cache_key)
|
|
50
|
+
fiber_memo_lru_or_nil&.[](method_name)&.delete(cache_key)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Clears all fiber-local cached entries for this instance in the current fiber.
|
|
55
|
+
#
|
|
56
|
+
# @return [void]
|
|
57
|
+
def reset_all_fiber_memos
|
|
58
|
+
store = Fiber[FIBER_STORE_KEY]
|
|
59
|
+
return unless store&.[](:__owner__) == Fiber.current.object_id
|
|
60
|
+
|
|
61
|
+
store.delete(object_id)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
# Returns the per-fiber top-level store hash, creating a fresh one for
|
|
67
|
+
# this fiber if the current store was inherited from a parent fiber.
|
|
68
|
+
def fiber_root_store!
|
|
69
|
+
store = Fiber[FIBER_STORE_KEY]
|
|
70
|
+
unless store&.[](:__owner__) == Fiber.current.object_id
|
|
71
|
+
store = {__owner__: Fiber.current.object_id}
|
|
72
|
+
Fiber[FIBER_STORE_KEY] = store
|
|
73
|
+
end
|
|
74
|
+
store
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def fiber_memo_store!
|
|
78
|
+
fiber_root_store![object_id] ||= {cache: {}, lru: {}}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def fiber_memo_cache!
|
|
82
|
+
fiber_memo_store![:cache]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def fiber_memo_lru!
|
|
86
|
+
fiber_memo_store![:lru]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def fiber_root_store_or_nil
|
|
90
|
+
store = Fiber[FIBER_STORE_KEY]
|
|
91
|
+
return nil unless store&.[](:__owner__) == Fiber.current.object_id
|
|
92
|
+
|
|
93
|
+
store
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def fiber_memo_store_or_nil
|
|
97
|
+
fiber_root_store_or_nil&.[](object_id)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def fiber_memo_cache_or_nil
|
|
101
|
+
fiber_memo_store_or_nil&.[](:cache)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def fiber_memo_lru_or_nil
|
|
105
|
+
fiber_memo_store_or_nil&.[](:lru)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -31,7 +31,8 @@ module SafeMemoize
|
|
|
31
31
|
hooks.each do |hook|
|
|
32
32
|
hook.call(cache_key, record)
|
|
33
33
|
rescue => error
|
|
34
|
-
|
|
34
|
+
# SafeMemoize.configuration is not accessible from non-main Ractors
|
|
35
|
+
handler = (Ractor.current == Ractor.main) ? SafeMemoize.configuration.on_hook_error : nil
|
|
35
36
|
if handler
|
|
36
37
|
handler.call(error, hook_type, cache_key)
|
|
37
38
|
else
|
|
@@ -39,6 +40,10 @@ module SafeMemoize
|
|
|
39
40
|
end
|
|
40
41
|
end
|
|
41
42
|
|
|
43
|
+
# ActiveSupport::Notifications and StatsD integration require main-Ractor
|
|
44
|
+
# configuration access; skip them from worker Ractors.
|
|
45
|
+
return if Ractor.current != Ractor.main
|
|
46
|
+
|
|
42
47
|
safe_memo_notify(hook_type, cache_key) if SafeMemoize.configuration.active_support_notifications
|
|
43
48
|
|
|
44
49
|
if (client = SafeMemoize.configuration.statsd_client)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SafeMemoize
|
|
4
|
+
# Class-level methods for Ractor-safe shared caching.
|
|
5
|
+
#
|
|
6
|
+
# Mixed into a class (via ClassMethods) when any method is memoized with
|
|
7
|
+
# +shared: true, ractor_safe: true+. The class owns a supervisor +Ractor+ that
|
|
8
|
+
# holds the mutable cache hash. All cache reads and writes are serialized through
|
|
9
|
+
# the supervisor's message loop, removing the need for a +Mutex+ (which is not
|
|
10
|
+
# Ractor-shareable).
|
|
11
|
+
#
|
|
12
|
+
# Constraints for +ractor_safe: true+ memoization:
|
|
13
|
+
# - Cached return values are made Ractor-shareable via +Ractor.make_shareable+
|
|
14
|
+
# (deep-frozen in place). Ensure return values can be frozen.
|
|
15
|
+
# - +if:+, +unless:+, +max_size:+, +ttl_refresh:+, +key:+, and +store:+ are
|
|
16
|
+
# incompatible and raise +ArgumentError+ at +memoize+ time.
|
|
17
|
+
# - When calling a ractor-safe memoized method from the main Ractor with multiple
|
|
18
|
+
# threads, responses are matched by thread identity so concurrent callers do not
|
|
19
|
+
# consume each other's replies.
|
|
20
|
+
module RactorSharedMethods
|
|
21
|
+
# Clears one or all entries from the Ractor-safe shared cache.
|
|
22
|
+
#
|
|
23
|
+
# @param method_name [Symbol, String]
|
|
24
|
+
# @param args [Array] positional args identifying a specific entry; omit to clear all
|
|
25
|
+
# @param kwargs [Hash]
|
|
26
|
+
# @return [void]
|
|
27
|
+
def reset_ractor_memo(method_name, *args, **kwargs)
|
|
28
|
+
method_name = method_name.to_sym
|
|
29
|
+
sup = @__safe_memo_ractor_supervisor__
|
|
30
|
+
return unless sup
|
|
31
|
+
|
|
32
|
+
if args.empty? && kwargs.empty?
|
|
33
|
+
__ractor_cache_send__(sup, :delete_all, method_name)
|
|
34
|
+
else
|
|
35
|
+
key = Ractor.make_shareable([method_name, args.freeze, kwargs.freeze])
|
|
36
|
+
__ractor_cache_send__(sup, :delete_one, key)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Clears the entire Ractor-safe shared cache for this class.
|
|
41
|
+
# @return [void]
|
|
42
|
+
def reset_all_ractor_memos
|
|
43
|
+
sup = @__safe_memo_ractor_supervisor__
|
|
44
|
+
return unless sup
|
|
45
|
+
|
|
46
|
+
__ractor_cache_send__(sup, :clear)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns +true+ if a live entry exists in the Ractor-safe shared cache.
|
|
50
|
+
#
|
|
51
|
+
# @param method_name [Symbol, String]
|
|
52
|
+
# @param args [Array]
|
|
53
|
+
# @param kwargs [Hash]
|
|
54
|
+
# @return [Boolean]
|
|
55
|
+
def ractor_memoized?(method_name, *args, **kwargs)
|
|
56
|
+
method_name = method_name.to_sym
|
|
57
|
+
sup = @__safe_memo_ractor_supervisor__
|
|
58
|
+
return false unless sup
|
|
59
|
+
|
|
60
|
+
key = Ractor.make_shareable([method_name, args.freeze, kwargs.freeze])
|
|
61
|
+
__ractor_cache_send__(sup, :memoized, key)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Returns the number of live entries in the Ractor-safe shared cache.
|
|
65
|
+
#
|
|
66
|
+
# @param method_name [Symbol, String, nil] when given, counts only entries for
|
|
67
|
+
# that method; when +nil+, counts all.
|
|
68
|
+
# @return [Integer]
|
|
69
|
+
def ractor_memo_count(method_name = nil)
|
|
70
|
+
sup = @__safe_memo_ractor_supervisor__
|
|
71
|
+
return 0 unless sup
|
|
72
|
+
|
|
73
|
+
__ractor_cache_send__(sup, :count, method_name&.to_sym)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Sends a message to the supervisor and blocks until the tagged response arrives.
|
|
79
|
+
# Uses Thread.current.object_id as a per-call tag so concurrent threads in the
|
|
80
|
+
# main Ractor do not steal each other's replies.
|
|
81
|
+
def __ractor_cache_send__(supervisor, op, *args)
|
|
82
|
+
tag = Thread.current.object_id
|
|
83
|
+
msg = Ractor.make_shareable([Ractor.current, tag, op, *args])
|
|
84
|
+
supervisor.send(msg)
|
|
85
|
+
Ractor.receive_if { |m| m.is_a?(Array) && m[0] == tag }[1]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Creates the supervisor Ractor that owns this class's Ractor-safe shared cache.
|
|
89
|
+
# Must be called from the main Ractor at class-definition time.
|
|
90
|
+
def __safe_memo_ractor_supervisor__
|
|
91
|
+
# :nocov:
|
|
92
|
+
@__safe_memo_ractor_supervisor__ ||= Ractor.new do
|
|
93
|
+
cache = {}
|
|
94
|
+
|
|
95
|
+
loop do
|
|
96
|
+
caller_ractor, tag, op, *args = Ractor.receive
|
|
97
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
98
|
+
|
|
99
|
+
result = case op
|
|
100
|
+
when :fetch
|
|
101
|
+
key = args[0]
|
|
102
|
+
record = cache[key]
|
|
103
|
+
live = record && (record[:expires_at].nil? || record[:expires_at] > now)
|
|
104
|
+
live ? {hit: true, record: record} : {hit: false, record: nil}
|
|
105
|
+
|
|
106
|
+
when :store
|
|
107
|
+
key, new_record = args
|
|
108
|
+
existing = cache[key]
|
|
109
|
+
live = existing && (existing[:expires_at].nil? || existing[:expires_at] > now)
|
|
110
|
+
cache[key] = new_record unless live
|
|
111
|
+
{stored: live ? existing : new_record}
|
|
112
|
+
|
|
113
|
+
when :delete_all
|
|
114
|
+
method_name = args[0]
|
|
115
|
+
cache.delete_if { |k, _| k[0] == method_name }
|
|
116
|
+
:ok
|
|
117
|
+
|
|
118
|
+
when :delete_one
|
|
119
|
+
cache.delete(args[0])
|
|
120
|
+
:ok
|
|
121
|
+
|
|
122
|
+
when :clear
|
|
123
|
+
cache.clear
|
|
124
|
+
:ok
|
|
125
|
+
|
|
126
|
+
when :memoized
|
|
127
|
+
key = args[0]
|
|
128
|
+
record = cache[key]
|
|
129
|
+
!!(record && (record[:expires_at].nil? || record[:expires_at] > now))
|
|
130
|
+
|
|
131
|
+
when :count
|
|
132
|
+
method_name = args[0]
|
|
133
|
+
cache.count do |k, r|
|
|
134
|
+
next false if r[:expires_at] && r[:expires_at] <= now
|
|
135
|
+
method_name.nil? || k[0] == method_name
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
response = Ractor.make_shareable([tag, result])
|
|
140
|
+
caller_ractor.send(response)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
# :nocov:
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -46,6 +46,31 @@ module SafeMemoize
|
|
|
46
46
|
contents.sub(UNRELEASED_HEADING, "#{UNRELEASED_HEADING}\n\n#{release_heading}")
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
+
# Removes milestone sections from ROADMAP.md where every feature row has
|
|
50
|
+
# "Shipped" status. Non-milestone sections (Versioning policy, Contributing,
|
|
51
|
+
# etc.) and sections with any non-Shipped row are left untouched.
|
|
52
|
+
#
|
|
53
|
+
# Sections are delimited by the +\n\n---\n\n+ horizontal-rule separator that
|
|
54
|
+
# the ROADMAP uses between headings. A milestone section is any section whose
|
|
55
|
+
# first non-blank line starts with +## v+.
|
|
56
|
+
def prune_roadmap(contents)
|
|
57
|
+
separator = "\n\n---\n\n"
|
|
58
|
+
sections = contents.split(separator)
|
|
59
|
+
|
|
60
|
+
pruned = sections.reject do |section|
|
|
61
|
+
next false unless section.lstrip.start_with?("## v")
|
|
62
|
+
|
|
63
|
+
# Table rows: lines starting with "|"; drop alignment rows (only |, -, :, whitespace)
|
|
64
|
+
rows = section.lines.select { |l| l.strip.start_with?("|") }
|
|
65
|
+
rows = rows.reject { |l| l.match?(/\A[\s|:-]+\z/) }
|
|
66
|
+
data_rows = rows.drop(1) # first row is the header
|
|
67
|
+
|
|
68
|
+
data_rows.any? && data_rows.all? { |row| row.strip.end_with?("Shipped |") }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
pruned.join(separator)
|
|
72
|
+
end
|
|
73
|
+
|
|
49
74
|
def extract_release_notes(contents, version)
|
|
50
75
|
normalized_version = normalize_version(version)
|
|
51
76
|
lines = contents.lines
|
data/lib/safe_memoize/version.rb
CHANGED
data/lib/safe_memoize.rb
CHANGED
|
@@ -6,6 +6,7 @@ require_relative "safe_memoize/stores/base"
|
|
|
6
6
|
require_relative "safe_memoize/stores/memory"
|
|
7
7
|
require_relative "safe_memoize/adapters/statsd"
|
|
8
8
|
require_relative "safe_memoize/adapters/opentelemetry"
|
|
9
|
+
require_relative "safe_memoize/adapters/concurrent_ruby"
|
|
9
10
|
require_relative "safe_memoize/class_methods"
|
|
10
11
|
require_relative "safe_memoize/public_methods"
|
|
11
12
|
require_relative "safe_memoize/cache_store_methods"
|
|
@@ -17,6 +18,8 @@ require_relative "safe_memoize/public_metrics_methods"
|
|
|
17
18
|
require_relative "safe_memoize/custom_key_methods"
|
|
18
19
|
require_relative "safe_memoize/public_custom_key_methods"
|
|
19
20
|
require_relative "safe_memoize/lru_methods"
|
|
21
|
+
require_relative "safe_memoize/fiber_local_methods"
|
|
22
|
+
require_relative "safe_memoize/ractor_shared_methods"
|
|
20
23
|
require_relative "safe_memoize/instance_methods"
|
|
21
24
|
|
|
22
25
|
# Thread-safe memoization for Ruby that correctly handles +nil+ and +false+ values.
|
data/sig/safe_memoize.rbs
CHANGED
|
@@ -38,8 +38,10 @@ module SafeMemoize
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
module ClassMethods
|
|
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
|
|
42
|
-
def
|
|
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?, ?fiber_local: bool, ?ractor_safe: bool) -> void
|
|
42
|
+
def safe_memoize_store: () -> Stores::Base?
|
|
43
|
+
def safe_memoize_store=: (Stores::Base?) -> Stores::Base?
|
|
44
|
+
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)?, ?fiber_local: bool) -> void
|
|
43
45
|
def reset_shared_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
|
|
44
46
|
def reset_all_shared_memos: () -> void
|
|
45
47
|
def shared_memoized?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool
|
|
@@ -187,6 +189,37 @@ module SafeMemoize
|
|
|
187
189
|
def lru_clear_all: () -> void
|
|
188
190
|
end
|
|
189
191
|
|
|
192
|
+
module FiberLocalMethods
|
|
193
|
+
FIBER_STORE_KEY: Symbol
|
|
194
|
+
|
|
195
|
+
def fiber_local_memoized?: (Symbol | String method_name, *untyped args, **untyped kwargs) ?{ () -> untyped } -> bool
|
|
196
|
+
def reset_fiber_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
|
|
197
|
+
def reset_all_fiber_memos: () -> void
|
|
198
|
+
|
|
199
|
+
private
|
|
200
|
+
|
|
201
|
+
def fiber_root_store!: () -> Hash[untyped, untyped]
|
|
202
|
+
def fiber_memo_store!: () -> { cache: Hash[memo_key, memo_record], lru: Hash[Symbol, Hash[memo_key, true]] }
|
|
203
|
+
def fiber_memo_cache!: () -> Hash[memo_key, memo_record]
|
|
204
|
+
def fiber_memo_lru!: () -> Hash[Symbol, Hash[memo_key, true]]
|
|
205
|
+
def fiber_root_store_or_nil: () -> Hash[untyped, untyped]?
|
|
206
|
+
def fiber_memo_store_or_nil: () -> { cache: Hash[memo_key, memo_record], lru: Hash[Symbol, Hash[memo_key, true]] }?
|
|
207
|
+
def fiber_memo_cache_or_nil: () -> Hash[memo_key, memo_record]?
|
|
208
|
+
def fiber_memo_lru_or_nil: () -> Hash[Symbol, Hash[memo_key, true]]?
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
module RactorSharedMethods
|
|
212
|
+
def reset_ractor_memo: (Symbol | String method_name, *untyped args, **untyped kwargs) -> void
|
|
213
|
+
def reset_all_ractor_memos: () -> void
|
|
214
|
+
def ractor_memoized?: (Symbol | String method_name, *untyped args, **untyped kwargs) -> bool
|
|
215
|
+
def ractor_memo_count: (?(Symbol | String) method_name) -> Integer
|
|
216
|
+
|
|
217
|
+
private
|
|
218
|
+
|
|
219
|
+
def __ractor_cache_send__: (untyped supervisor, Symbol op, *untyped args) -> untyped
|
|
220
|
+
def __safe_memo_ractor_supervisor__: () -> untyped
|
|
221
|
+
end
|
|
222
|
+
|
|
190
223
|
module InstanceMethods
|
|
191
224
|
include PublicMethods
|
|
192
225
|
include CacheStoreMethods
|
|
@@ -198,6 +231,7 @@ module SafeMemoize
|
|
|
198
231
|
include CustomKeyMethods
|
|
199
232
|
include PublicCustomKeyMethods
|
|
200
233
|
include LruMethods
|
|
234
|
+
include FiberLocalMethods
|
|
201
235
|
end
|
|
202
236
|
|
|
203
237
|
module Stores
|
|
@@ -237,6 +271,17 @@ module SafeMemoize
|
|
|
237
271
|
SPAN_NAME: String
|
|
238
272
|
def self.trace: (untyped tracer, Symbol | String method_name, String? class_name) { () -> untyped } -> untyped
|
|
239
273
|
end
|
|
274
|
+
|
|
275
|
+
class ConcurrentRuby < Stores::Base
|
|
276
|
+
def initialize: () -> void
|
|
277
|
+
def read: (untyped key) -> untyped
|
|
278
|
+
def write: (untyped key, untyped value, ?expires_in: Numeric?) -> void
|
|
279
|
+
def delete: (untyped key) -> void
|
|
280
|
+
def clear: () -> void
|
|
281
|
+
def keys: () -> Array[untyped]
|
|
282
|
+
private
|
|
283
|
+
def expired?: ({ expires_at: Float?, value: untyped, cached_at: Float }) -> bool
|
|
284
|
+
end
|
|
240
285
|
end
|
|
241
286
|
|
|
242
287
|
module Rails
|
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.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chuck Smith
|
|
@@ -52,6 +52,7 @@ files:
|
|
|
52
52
|
- benchmarks/benchmark.rb
|
|
53
53
|
- codecov.yml
|
|
54
54
|
- lib/safe_memoize.rb
|
|
55
|
+
- lib/safe_memoize/adapters/concurrent_ruby.rb
|
|
55
56
|
- lib/safe_memoize/adapters/opentelemetry.rb
|
|
56
57
|
- lib/safe_memoize/adapters/statsd.rb
|
|
57
58
|
- lib/safe_memoize/cache_metrics_methods.rb
|
|
@@ -60,6 +61,7 @@ files:
|
|
|
60
61
|
- lib/safe_memoize/class_methods.rb
|
|
61
62
|
- lib/safe_memoize/configuration.rb
|
|
62
63
|
- lib/safe_memoize/custom_key_methods.rb
|
|
64
|
+
- lib/safe_memoize/fiber_local_methods.rb
|
|
63
65
|
- lib/safe_memoize/hooks_methods.rb
|
|
64
66
|
- lib/safe_memoize/inspection_methods.rb
|
|
65
67
|
- lib/safe_memoize/instance_methods.rb
|
|
@@ -67,6 +69,7 @@ files:
|
|
|
67
69
|
- lib/safe_memoize/public_custom_key_methods.rb
|
|
68
70
|
- lib/safe_memoize/public_methods.rb
|
|
69
71
|
- lib/safe_memoize/public_metrics_methods.rb
|
|
72
|
+
- lib/safe_memoize/ractor_shared_methods.rb
|
|
70
73
|
- lib/safe_memoize/rails.rb
|
|
71
74
|
- lib/safe_memoize/rails/middleware.rb
|
|
72
75
|
- lib/safe_memoize/rails/request_scoped.rb
|