safe_memoize 0.9.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0b47b6031a8f395991376eec0407b93d1cf02caecad23f4d77e4d68234ef87b5
4
- data.tar.gz: 7542678912a0a39425a75e3addfc718f2abb35068e28e0cdc0444c294dd4c4c1
3
+ metadata.gz: 6beffd3f5a1de6f8582c9a164502f353b8ac6c55caec6326edb4c52b0bf913ec
4
+ data.tar.gz: 44ebaf0f89254c692b9d33d8962577c9644df6d910818905e46395ba5e28cf89
5
5
  SHA512:
6
- metadata.gz: a304040bf86b1bc359c91f3d687a9f45e020bdaddded53b82a87e473553491aa9daba1ac45f8abadffb8ede8af270479f73762ab47357486aef9b46043afacde
7
- data.tar.gz: 79d6a552f98f9f2ef1482832c211cc7b0a58f6481c989320c19db63b870a1723b482304955a133c661a0082a07c17de539258a9f869da0b5cbd0aaa2acacd96a
6
+ metadata.gz: 0f22cd22816499ec3400a7b98cf51c3a3b77a43a0e26451ff97d6f01877d02df8772b7b3be0209e86b5c6c00d435bc96e7db8fd3a28619169e1f0e97b8ab0263
7
+ data.tar.gz: af315893620e46fe419a2a61ff4092f7d27f71bbfe98185ba0714162148c7d19b2d31ed9db3909d500f3e12f474f51d66ec027d81cd2d7e9948e4bc388d3a5db
data/.yardopts ADDED
@@ -0,0 +1,10 @@
1
+ --markup markdown
2
+ --output-dir doc/
3
+ --main README.md
4
+ --files CHANGELOG.md,ROADMAP.md
5
+ --no-private
6
+ lib/**/*.rb
7
+ -
8
+ README.md
9
+ CHANGELOG.md
10
+ ROADMAP.md
data/CHANGELOG.md CHANGED
@@ -8,6 +8,18 @@ from v1.0.0 onwards. Prior 0.x releases may include breaking changes between min
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.0.0] - 2026-05-22
12
+
13
+ ### Added
14
+
15
+ - Ractor compatibility audit — `spec/ractor_spec.rb` documents the specific failure modes (non-shareable closures in `define_method` blocks, `Ractor::IsolationError` on `SafeMemoize.configuration`); README section explains the limitation and the Thread-based workaround
16
+ - Semantic versioning guarantee — README `## Public API and versioning guarantee` section enumerates every public constant, method, option key, and `Configuration` attribute covered by semver from v1.0.0 onwards; opt-in extensions (`SafeMemoize::Rails`, `SafeMemoize::Adapters::*`) are explicitly called out as not yet covered until their owning milestone ships
17
+ - Full API reference — YARD documentation added to all public methods, classes, and modules; `SafeMemoize::Adapters::StatsD` and `SafeMemoize::Adapters::OpenTelemetry` fully documented with usage examples; internal modules marked `@api private`; `.yardopts` and `rake doc` task added; `gem "yard"` added as a development dependency
18
+ - Deprecation sweep — pre-v1.0.0 API consistency audit: `memoized?`, `memo_ttl_remaining`, `memo_touch`, `memo_age`, `memo_stale?` now use `compute_cache_key` instead of `safe_memo_cache_key` so they correctly resolve entries stored with a custom key (instance-level `memoize_with_custom_key` or class-level `key:`); `memo_matcher_for` (used by `reset_memo` and `memo_refresh`) receives the same fix; `SafeMemoize::Error` added to the public API guarantee table and to RBS + Sorbet signatures; RBS and `.rbi` `warm_memo` block annotation corrected back to mandatory (was incorrectly marked optional in v0.9.0 signatures)
19
+ - Ruby version policy — README `## Ruby version support` section formalises the supported version window (Ruby ≥ 3.3; current stable plus two previous non-EOL minors), the cadence for dropping EOL versions (minor release only, never a patch), and a history table of dropped versions; CI matrix documents covered versions with their EOL dates
20
+ - Complete RBS + Sorbet signatures — `sig/safe_memoize.rbs` corrected: `SafeMemoize::Adapters::StatsD` added; `memo_count`, `memo_keys`, `memo_values` fixed from rest-arg to proper optional single arg; `clear_memo_hooks` and `clear_custom_keys` optional-arg annotations corrected; `warm_memo` block marked optional; new `rbi/safe_memoize.rbi` ships Sorbet stubs covering the full public API, all `Configuration` attributes, adapters, and opt-in Rails helpers
21
+ - Upgrade guide — `UPGRADING.md` documents every breaking change introduced across the 0.x series, with before/after code examples and migration steps for each; covers Ruby 3.2 removal, TTL clock change, `memo_keys`/`memo_values` shape change, `memoize` definition-time raise, argument mutation fix, hook exception isolation, and the two custom-key introspection fixes landing in v1.0.0
22
+
11
23
  ## [0.9.0] - 2026-05-22
12
24
 
13
25
  ### Added
data/README.md CHANGED
@@ -895,11 +895,25 @@ end
895
895
 
896
896
  [↑ Back to features](#features)
897
897
 
898
+ ## Ractor compatibility
899
+
900
+ SafeMemoize is **not Ractor-compatible** in its current form. 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
+
902
+ 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
+
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. This affects every memoized call because hooks and adapters always read the configuration.
905
+
906
+ **Workaround:** 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`.
907
+
908
+ Ractor support is tracked in the v1.0.0 roadmap. The fix would require replacing closed-over variables with frozen shareable bindings and making `Configuration` a frozen value object, which is a significant redesign.
909
+
898
910
  ## Development
899
911
 
900
912
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `bundle exec rspec` to run the tests. You can also run `bin/console` for an interactive prompt.
901
913
 
902
- To run the benchmark suite: `bundle exec ruby benchmarks/benchmark.rb`. Install `memery` and `memo_wise` first if you want comparison columns against those gems.
914
+ To run the benchmark suite: `bundle exec ruby benchmarks/benchmark.rb`.
915
+
916
+ To generate API documentation locally: `bundle exec rake doc`. Output is written to `doc/` (gitignored). The online reference is published automatically to [RubyDoc.info](https://rubydoc.info/gems/safe_memoize) on every release. Install `memery` and `memo_wise` first if you want comparison columns against those gems.
903
917
 
904
918
  GitHub Actions also runs the full `bundle exec rake` suite automatically for pull requests, manual workflow runs, and pushes to `main` via `.github/workflows/ci.yml`.
905
919
 
@@ -936,9 +950,174 @@ To preview the changelog/version update without changing anything, use:
936
950
  bin/release 0.1.1 --dry-run
937
951
  ```
938
952
 
953
+ ## Public API and versioning guarantee
954
+
955
+ From **v1.0.0** onwards SafeMemoize follows [Semantic Versioning](https://semver.org/). The table below declares every constant, method, and option key that forms the public contract. If you only call items listed here, you are guaranteed that:
956
+
957
+ - **Patch** releases (1.x.**y**) contain bug fixes only — no behaviour changes.
958
+ - **Minor** releases (1.**x**.0) add new features in a backwards-compatible way.
959
+ - **Major** releases (**x**.0.0) may break the items below; a migration guide will be published for every such release.
960
+
961
+ Anything **not** listed here — internal modules, private methods, `@__safe_memo_*__` ivars, the structure of the cache hash itself — is subject to change without notice in any release, including patch releases.
962
+
963
+ ### Top-level module
964
+
965
+ | Symbol | Kind | Notes |
966
+ |---|---|---|
967
+ | `SafeMemoize::VERSION` | constant | Semver string, always present |
968
+ | `SafeMemoize::Error` | class | Base error class (`< StandardError`) for rescuing any SafeMemoize-raised exception |
969
+ | `SafeMemoize.configure { \|c\| … }` | module method | Yields `Configuration`; sets global defaults |
970
+ | `SafeMemoize.configuration` | module method | Returns the current `Configuration` |
971
+ | `SafeMemoize.reset_configuration!` | module method | Restores all configuration to defaults |
972
+ | `SafeMemoize.deprecate(subject, message:, horizon:)` | module method | Emits a structured deprecation warning |
973
+
974
+ ### `memoize` DSL (class method, added by `prepend SafeMemoize`)
975
+
976
+ | Option key | Type | Default | Notes |
977
+ |---|---|---|---|
978
+ | `ttl:` | `Numeric \| nil` | `nil` | Seconds until entry expires |
979
+ | `ttl_refresh:` | `Boolean` | `false` | Sliding window — resets clock on every hit |
980
+ | `max_size:` | `Integer \| nil` | `nil` | LRU entry limit per method |
981
+ | `if:` | `Symbol \| Proc \| nil` | `nil` | Store only when truthy |
982
+ | `unless:` | `Symbol \| Proc \| nil` | `nil` | Store only when falsy |
983
+ | `shared:` | `Boolean` | `false` | Class-level shared cache |
984
+ | `key:` | `Proc \| nil` | `nil` | Class-level custom key generator |
985
+
986
+ ### `memoize_all` options (class method)
987
+
988
+ All `memoize` option keys above, plus:
989
+
990
+ | Option key | Type | Default |
991
+ |---|---|---|
992
+ | `except:` | `Array<Symbol>` | `[]` |
993
+ | `only:` | `Array<Symbol>` | `[]` |
994
+ | `include_protected:` | `Boolean` | `false` |
995
+ | `include_private:` | `Boolean` | `false` |
996
+
997
+ ### Instance methods (public)
998
+
999
+ **Inspection**
1000
+
1001
+ | Method | Returns |
1002
+ |---|---|
1003
+ | `memoized?(method_name, *args, **kwargs)` | `Boolean` |
1004
+ | `memo_count(method_name = nil)` | `Integer` |
1005
+ | `memo_keys(method_name = nil)` | `Array` |
1006
+ | `memo_values(method_name = nil)` | `Array` |
1007
+ | `memo_inspect(method_name, *args, **kwargs)` | `Hash \| nil` |
1008
+ | `memo_ttl_remaining(method_name, *args, **kwargs)` | `Numeric \| nil` |
1009
+ | `memo_age(method_name, *args, **kwargs)` | `Numeric \| nil` |
1010
+ | `memo_stale?(method_name, *args, **kwargs)` | `Boolean` |
1011
+
1012
+ **Invalidation and mutation**
1013
+
1014
+ | Method | Returns |
1015
+ |---|---|
1016
+ | `reset_memo(method_name, *args, **kwargs)` | `nil` |
1017
+ | `reset_all_memos` | `nil` |
1018
+ | `memo_touch(method_name, *args, ttl: nil, **kwargs)` | `Boolean` |
1019
+ | `memo_refresh(method_name, *args, **kwargs)` | cached value |
1020
+
1021
+ **Warm-up and persistence**
1022
+
1023
+ | Method | Returns |
1024
+ |---|---|
1025
+ | `warm_memo(method_name, *args, ttl: nil, **kwargs)` | cached value |
1026
+ | `memo_preload(method_name, *arg_sets)` | `Array` |
1027
+ | `dump_memo(method_name = nil)` | `Hash` |
1028
+ | `load_memo(snapshot)` | `nil` |
1029
+
1030
+ **Lifecycle hooks**
1031
+
1032
+ | Method | Fires when |
1033
+ |---|---|
1034
+ | `on_memo_hit { \|key\| … }` | cache hit |
1035
+ | `on_memo_miss { \|key\| … }` | cache miss |
1036
+ | `on_memo_store { \|key, value\| … }` | value written |
1037
+ | `on_memo_expire { \|key\| … }` | TTL expires |
1038
+ | `on_memo_evict { \|key\| … }` | LRU eviction |
1039
+ | `clear_memo_hooks(hook_type = nil)` | — |
1040
+
1041
+ **Metrics**
1042
+
1043
+ | Method | Returns |
1044
+ |---|---|
1045
+ | `cache_stats` | `Hash` |
1046
+ | `cache_stats_for(method_name)` | `Hash` |
1047
+ | `cache_hit_rate` | `Float` |
1048
+ | `cache_miss_rate` | `Float` |
1049
+ | `cache_metrics_reset(method_name = nil)` | `nil` |
1050
+
1051
+ **Custom keys**
1052
+
1053
+ | Method | Notes |
1054
+ |---|---|
1055
+ | `memoize_with_custom_key(method_name) { \|*args, **kwargs\| … }` | Instance-level key generator |
1056
+ | `clear_custom_keys(method_name = nil)` | Remove one or all key generators |
1057
+
1058
+ ### Shared-cache class methods (added when any method uses `shared: true`)
1059
+
1060
+ | Method | Returns |
1061
+ |---|---|
1062
+ | `reset_shared_memo(method_name, *args, **kwargs)` | `nil` |
1063
+ | `reset_all_shared_memos` | `nil` |
1064
+ | `shared_memoized?(method_name, *args, **kwargs)` | `Boolean` |
1065
+ | `shared_memo_count(method_name = nil)` | `Integer` |
1066
+ | `shared_memo_age(method_name, *args, **kwargs)` | `Numeric \| nil` |
1067
+ | `shared_memo_stale?(method_name, *args, **kwargs)` | `Boolean` |
1068
+
1069
+ ### `SafeMemoize::Configuration` attributes
1070
+
1071
+ | Attribute | Type | Default |
1072
+ |---|---|---|
1073
+ | `default_ttl` | `Numeric \| nil` | `nil` |
1074
+ | `default_max_size` | `Integer \| nil` | `nil` |
1075
+ | `on_deprecation` | `Proc \| nil` | `nil` (writes to stderr) |
1076
+ | `on_hook_error` | `Proc \| nil` | `nil` (warns to stderr) |
1077
+ | `active_support_notifications` | `Boolean` | `false` |
1078
+ | `statsd_client` | `Object \| nil` | `nil` |
1079
+ | `opentelemetry_tracer` | `Object \| nil` | `nil` |
1080
+
1081
+ ### Opt-in extensions (not guaranteed until their owning milestone ships)
1082
+
1083
+ The following are available now but reside under `require "safe_memoize/rails"` and are not covered by the v1.0.0 semver guarantee until the v1.x milestone that owns them is declared stable:
1084
+
1085
+ - `SafeMemoize::Rails` module (`track`, `reset_tracked!`)
1086
+ - `SafeMemoize::Rails::RequestScoped` concern
1087
+ - `SafeMemoize::Rails::Middleware` Rack middleware
1088
+ - `SafeMemoize::Adapters::StatsD`
1089
+ - `SafeMemoize::Adapters::OpenTelemetry`
1090
+
1091
+ ## Ruby version support
1092
+
1093
+ ### Supported versions
1094
+
1095
+ SafeMemoize requires **Ruby ≥ 3.3**. Every non-EOL Ruby version in the table below is actively tested in CI and receives bug-fix backports for critical issues.
1096
+
1097
+ | Ruby | Status | EOL |
1098
+ |---|---|---|
1099
+ | 3.3 | Supported | Mar 2027 |
1100
+ | 3.4 | Supported | Mar 2028 |
1101
+ | 4.0 | Supported | ~ Dec 2028 |
1102
+
1103
+ EOL dates follow the [Ruby maintenance schedule](https://www.ruby-lang.org/en/downloads/branches/).
1104
+
1105
+ ### Policy
1106
+
1107
+ - **Dropping an EOL version is a minor-version change**, not a major one — it will appear in the CHANGELOG under `### Removed` and the gemspec `required_ruby_version` will be updated accordingly.
1108
+ - SafeMemoize targets the **current stable release plus the two previous non-EOL minors** at any given time. When Ruby releases a new version in December, CI gains a new column; when a version reaches EOL the next minor release removes it.
1109
+ - **No patch release will ever raise the minimum Ruby version.** Only `x.y.0` minor releases may do so.
1110
+ - Prerelease Rubies (dev / preview builds) are not officially supported but breakage is investigated on a best-effort basis.
1111
+
1112
+ ### History
1113
+
1114
+ | Dropped in | Ruby version removed |
1115
+ |---|---|
1116
+ | v0.5.0 | Ruby 3.2 (reached EOL) |
1117
+
939
1118
  ## Roadmap
940
1119
 
941
- See [ROADMAP.md](ROADMAP.md) for the planned path from v0.7.0 to v1.0.0 and beyond, including upcoming features, API stability goals, and the versioning policy.
1120
+ See [ROADMAP.md](ROADMAP.md) for the planned path to v1.0.0 and beyond, including upcoming features, API stability goals, and the versioning policy.
942
1121
 
943
1122
  ## Contributing
944
1123
 
data/ROADMAP.md CHANGED
@@ -4,34 +4,19 @@ This document tracks the planned evolution of SafeMemoize through v1.0.0 and bey
4
4
 
5
5
  ---
6
6
 
7
- ## v0.9.0 — Observability & Ecosystem Integration
8
-
9
- *Goal: make SafeMemoize a first-class citizen in Rails/ActiveSupport stacks and in observability pipelines.*
10
-
11
- | Feature | Description | Status |
12
- |---|---|---|
13
- | ActiveSupport::Notifications integration | Emit `cache.hit`, `cache.miss`, `cache.evict`, and `cache.expire` events when ActiveSupport is available (opt-in via configuration) | Shipped |
14
- | StatsD adapter | Thin optional module (`SafeMemoize::Adapters::StatsD`) that routes lifecycle hooks to a StatsD client with sensible metric names and tags | Shipped |
15
- | OpenTelemetry spans | Optional adapter (`SafeMemoize::Adapters::OpenTelemetry`) wrapping computation time in a trace span for distributed tracing pipelines | Shipped |
16
- | Rails request-scope helper | Guide + optional mixin for resetting instance memos at the end of each request (controller concern, middleware, or Active Model pattern) | Shipped |
17
- | Formal benchmark suite | `benchmarks/` directory with comparisons against `memery`, `memo_wise`, and raw `||=`, covering single-threaded throughput and contention under concurrent load | Shipped |
18
- | Concurrency stress tests | Dedicated spec suite hammering shared-cache paths and LRU eviction under high thread counts to surface race conditions | Shipped |
19
-
20
- ---
21
-
22
7
  ## v1.0.0 — Stable API
23
8
 
24
9
  *Goal: declare a stable, semver-governed public API that downstream code can depend on with confidence.*
25
10
 
26
11
  | Feature | Description | Status |
27
12
  |---|---|---|
28
- | Semantic versioning guarantee | Document which constants, methods, and option keys are public API; breaking changes require a major bump henceforth | Planned |
29
- | Complete RBS + Sorbet signatures | Cover all public methods including overloads for optional keyword arguments; publish `.rbi` stubs as a companion package if demand warrants | Planned |
30
- | 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 | Planned |
31
- | Ractor compatibility audit | Investigate and either support Ractor-compatible operation (Mutex replacement, shared-cache storage) or document the limitation clearly | Planned |
32
- | Ruby version policy | Formalise the supported Ruby version window and cadence for dropping EOL versions | Planned |
33
- | Deprecation sweep | Resolve or formally deprecate any unstable internal APIs before the stable release | Planned |
34
- | Upgrade guide | Document all breaking changes from 0.x and provide a migration path for users of deprecated behaviour | Planned |
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 |
35
20
 
36
21
  ---
37
22
 
data/Rakefile CHANGED
@@ -7,4 +7,9 @@ RSpec::Core::RakeTask.new(:spec)
7
7
 
8
8
  require "standard/rake"
9
9
 
10
+ require "yard"
11
+ YARD::Rake::YardocTask.new(:doc) do |t|
12
+ t.options = ["--fail-on-warning"]
13
+ end
14
+
10
15
  task default: %i[spec standard]
data/UPGRADING.md ADDED
@@ -0,0 +1,197 @@
1
+ # Upgrading to SafeMemoize 1.0.0
2
+
3
+ This guide covers every behavioral change introduced across the 0.x series that could affect code written against an earlier release. Work through the sections that apply to your starting version.
4
+
5
+ ---
6
+
7
+ ## Summary of breaking changes
8
+
9
+ | Change | Introduced | Impact |
10
+ |---|---|---|
11
+ | Ruby 3.2 dropped | v0.5.0 | High if still on Ruby 3.2 |
12
+ | TTL clock starts at first call, not definition | v0.6.0 | Medium — affects TTL precision |
13
+ | `memo_keys`/`memo_values` shape for custom keys | v0.6.1 | Low — only if inspecting key metadata |
14
+ | `memoize` raises at definition time for undefined methods | v0.8.0 | Medium — affects dynamic class construction |
15
+ | Argument mutation no longer corrupts cache | v0.8.0 | Low — was silent bug; may surface latent test issues |
16
+ | Hook exceptions no longer propagate | v0.8.0 | Medium — only if catching hook exceptions |
17
+ | `memoized?` / introspection methods now respect custom keys | v1.0.0 | Low — bug fix; only if using custom keys |
18
+ | `reset_memo` with args and `memo_refresh` respect custom keys | v1.0.0 | Low — bug fix; only if using custom keys |
19
+
20
+ ---
21
+
22
+ ## Upgrading from 0.1.x or 0.2.x
23
+
24
+ Follow every section below in order.
25
+
26
+ ## Upgrading from 0.3.x – 0.4.x
27
+
28
+ Follow sections starting from [Ruby 3.2 dropped](#ruby-32-dropped-v050).
29
+
30
+ ## Upgrading from 0.5.x
31
+
32
+ Follow sections starting from [TTL clock fix](#ttl-clock-now-starts-at-first-call-v060).
33
+
34
+ ## Upgrading from 0.6.x
35
+
36
+ Follow sections starting from [memoize on undefined methods](#memoize-on-an-undefined-method-raises-at-definition-time-v080).
37
+
38
+ ## Upgrading from 0.7.x – 0.9.x
39
+
40
+ Follow sections starting from [custom key introspection fix](#introspection-methods-now-respect-custom-keys-v100).
41
+
42
+ ---
43
+
44
+ ## Ruby 3.2 dropped (v0.5.0)
45
+
46
+ **Impact:** High — the gem will not install on Ruby 3.2.
47
+
48
+ Ruby 3.2 reached end-of-life and was removed from the supported matrix in v0.5.0. The gemspec now enforces `ruby >= 3.3.0`.
49
+
50
+ **Migration:** Upgrade to Ruby 3.3 or later before updating the gem.
51
+
52
+ ---
53
+
54
+ ## TTL clock now starts at first call, not definition (v0.6.0)
55
+
56
+ **Impact:** Medium — affects how long entries actually live in the cache.
57
+
58
+ Before v0.6.0, the TTL countdown began when `memoize :method, ttl: N` was evaluated (class load time). If a class was loaded 30 seconds before the first call, entries would expire 30 seconds earlier than expected.
59
+
60
+ From v0.6.0 onwards, the clock starts on the first cache write — the entry lives for exactly `ttl` seconds after it is populated.
61
+
62
+ **Migration:** No code change required. Entries now live for the duration you specified. If you intentionally set very short TTLs and relied on the early-start behaviour (unlikely), reduce your TTL value accordingly.
63
+
64
+ ---
65
+
66
+ ## `memo_keys`/`memo_values` shape change for custom-keyed entries (v0.6.1)
67
+
68
+ **Impact:** Low — only affects code that reads the hashes returned by these methods.
69
+
70
+ Before v0.6.1, entries stored via `memoize_with_custom_key` were surfaced in `memo_keys` and `memo_values` as:
71
+
72
+ ```ruby
73
+ { args: <the_custom_key>, kwargs: nil }
74
+ ```
75
+
76
+ From v0.6.1 onwards they are correctly surfaced as:
77
+
78
+ ```ruby
79
+ { custom_key: <the_custom_key> }
80
+ ```
81
+
82
+ **Migration:** If your code inspects the hash returned by `memo_keys` or `memo_values` and checks for an `:args` key to detect custom-keyed entries, update it to check for `:custom_key` instead:
83
+
84
+ ```ruby
85
+ # Before
86
+ entry[:args] # custom key was smuggled here
87
+
88
+ # After
89
+ entry[:custom_key] # explicit field
90
+ entry[:args] # only present for default-keyed entries
91
+ ```
92
+
93
+ ---
94
+
95
+ ## `memoize` on an undefined method raises at definition time (v0.8.0)
96
+
97
+ **Impact:** Medium — affects any code that calls `memoize` before the method is defined,
98
+ or that dynamically defines methods after `memoize` is called.
99
+
100
+ Before v0.8.0, calling `memoize :missing_method` silently succeeded at class load time. The error only appeared at runtime when the memoized wrapper tried to call `super` and found nothing to call.
101
+
102
+ From v0.8.0 onwards, `memoize` raises `ArgumentError` immediately if the named method does not exist at the time of the call.
103
+
104
+ **Migration:** Ensure `memoize` is always called *after* the method it wraps:
105
+
106
+ ```ruby
107
+ # Wrong — memoize called before def (raises ArgumentError in v0.8.0+)
108
+ memoize :compute
109
+ def compute = expensive_work
110
+
111
+ # Correct
112
+ def compute = expensive_work
113
+ memoize :compute
114
+ ```
115
+
116
+ If you use `Module#prepend` or `include` to add methods dynamically, make sure `memoize` is called after the module is prepended/included:
117
+
118
+ ```ruby
119
+ include MyMethods # defines :compute
120
+ memoize :compute # now safe
121
+ ```
122
+
123
+ ---
124
+
125
+ ## Argument mutation no longer corrupts the cache (v0.8.0)
126
+
127
+ **Impact:** Low — this was a silent bug. Existing code is unlikely to rely on it, but test suites that mutate arguments after a call and expect a cache miss may need updating.
128
+
129
+ Before v0.8.0, mutable cache keys (arrays, hashes, strings passed as arguments) were stored by reference. Mutating an argument after a call could cause the cache to behave unpredictably — sometimes missing on identical arguments, sometimes returning stale values.
130
+
131
+ From v0.8.0 onwards, argument arrays, hashes, and strings are deep-frozen into an independent copy when the cache key is built. Callers can mutate their arguments after a call without affecting the cache.
132
+
133
+ **Migration:** No migration required for production code. If a test mutates arguments after a memoized call and expects a cache miss, the test was relying on the buggy behaviour — update it to use distinct argument values instead.
134
+
135
+ ---
136
+
137
+ ## Hook exceptions no longer propagate to callers (v0.8.0)
138
+
139
+ **Impact:** Medium — only affects code that wraps memoized calls in `rescue` to catch errors raised by hooks.
140
+
141
+ Before v0.8.0, an exception raised inside an `on_memo_hit`, `on_memo_miss`, `on_memo_store`, `on_memo_expire`, or `on_memo_evict` hook would propagate through the memoized method call and be visible to the caller.
142
+
143
+ From v0.8.0 onwards, hook exceptions are isolated. By default a `[SafeMemoize] Hook error in <type>: <message>` warning is written to `$stderr` and execution continues normally.
144
+
145
+ **Migration:** If you need hook exceptions to propagate (e.g. in a strict test environment), configure `on_hook_error` to re-raise:
146
+
147
+ ```ruby
148
+ SafeMemoize.configure do |c|
149
+ c.on_hook_error = ->(error, hook_type, cache_key) { raise error }
150
+ end
151
+ ```
152
+
153
+ Or to route them to your error tracker without raising:
154
+
155
+ ```ruby
156
+ SafeMemoize.configure do |c|
157
+ c.on_hook_error = ->(error, _type, _key) { Bugsnag.notify(error) }
158
+ end
159
+ ```
160
+
161
+ ---
162
+
163
+ ## Introspection methods now respect custom keys (v1.0.0)
164
+
165
+ **Impact:** Low — only affects code that uses `memoize_with_custom_key` or the `key:` option together with any of the introspection methods listed below.
166
+
167
+ Before v1.0.0, the following methods looked up cache entries using the *default* key (derived from raw arguments) rather than the *custom* key generator. This meant they always returned incorrect results when a custom key was active:
168
+
169
+ - `memoized?`
170
+ - `memo_ttl_remaining`
171
+ - `memo_touch`
172
+ - `memo_age`
173
+ - `memo_stale?`
174
+
175
+ From v1.0.0 onwards, all five methods correctly call `compute_cache_key`, which checks for a custom key generator first.
176
+
177
+ **Migration:** No code change required — the methods now return correct results. If your code had workarounds that bypassed these methods (e.g. manually inspecting `memo_keys` to determine if an entry existed), you can simplify them to use `memoized?` directly.
178
+
179
+ ---
180
+
181
+ ## `reset_memo` with args and `memo_refresh` respect custom keys (v1.0.0)
182
+
183
+ **Impact:** Low — only affects code that uses custom keys and calls `reset_memo` with specific arguments, or calls `memo_refresh`.
184
+
185
+ Before v1.0.0, `reset_memo(:method, *args)` with explicit arguments built a default key from raw args to match entries. When the method used a custom key generator, no entry was found and nothing was cleared. `memo_refresh` inherited the same flaw — the old entry survived and `refresh` returned the cached (stale) value instead of recomputing.
186
+
187
+ From v1.0.0 onwards, both methods resolve the cache key through `compute_cache_key`.
188
+
189
+ **Migration:** No code change required — the methods now behave correctly. Note that `reset_memo(:method)` with *no* arguments still clears all entries for the method regardless of key format; this behaviour is unchanged.
190
+
191
+ ---
192
+
193
+ ## New stable API
194
+
195
+ All symbols listed in the README `## Public API and versioning guarantee` section are now covered by [Semantic Versioning](https://semver.org/) from v1.0.0 onwards. Breaking changes to any of those symbols require a major version bump.
196
+
197
+ Opt-in extensions (`SafeMemoize::Rails`, `SafeMemoize::Adapters::StatsD`, `SafeMemoize::Adapters::OpenTelemetry`) are available but are *not* included in the v1.0.0 semver guarantee; they will be stabilised in a subsequent minor release.
@@ -1,10 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeMemoize
4
+ # Optional adapters for external observability systems.
5
+ # None are auto-required; load them explicitly when needed.
4
6
  module Adapters
7
+ # Optional OpenTelemetry adapter.
8
+ #
9
+ # Wraps each cache-miss computation in an OpenTelemetry span so the time
10
+ # spent computing uncached values is visible in distributed traces.
11
+ #
12
+ # Configure via {Configuration#opentelemetry_tracer}:
13
+ #
14
+ # SafeMemoize.configure do |c|
15
+ # c.opentelemetry_tracer = OpenTelemetry.tracer_provider.tracer("safe_memoize")
16
+ # end
17
+ #
18
+ # Each span is named {SPAN_NAME} and carries the attributes
19
+ # +safe_memoize.method+, +safe_memoize.class+, and +safe_memoize.cache_hit+.
20
+ # Falls back to untraced execution when no tracer is configured or the tracer
21
+ # does not respond to +#in_span+.
5
22
  module OpenTelemetry
23
+ # The name given to every span created by this adapter.
6
24
  SPAN_NAME = "safe_memoize.compute"
7
25
 
26
+ # Wraps the block in a span if a tracer is available; otherwise yields directly.
27
+ #
28
+ # @param tracer [Object, nil] an object responding to +#in_span+
29
+ # @param method_name [Symbol, String]
30
+ # @param class_name [String, nil]
31
+ # @yield the computation block (cache-miss path)
32
+ # @return [Object] the result of the block
8
33
  def self.trace(tracer, method_name, class_name)
9
34
  return yield unless tracer&.respond_to?(:in_span)
10
35
 
@@ -2,7 +2,31 @@
2
2
 
3
3
  module SafeMemoize
4
4
  module Adapters
5
+ # Optional StatsD adapter.
6
+ #
7
+ # Routes SafeMemoize lifecycle events to any StatsD-compatible client
8
+ # (any object that responds to +#increment+).
9
+ #
10
+ # Configure via {Configuration#statsd_client}:
11
+ #
12
+ # SafeMemoize.configure do |c|
13
+ # c.statsd_client = Datadog::Statsd.new
14
+ # end
15
+ #
16
+ # Emitted metrics:
17
+ #
18
+ # | Metric | Fires on |
19
+ # |---|---|
20
+ # | +safe_memoize.hit+ | cache hit |
21
+ # | +safe_memoize.miss+ | cache miss |
22
+ # | +safe_memoize.store+ | value written |
23
+ # | +safe_memoize.evict+ | LRU eviction |
24
+ # | +safe_memoize.expire+ | TTL expiration |
25
+ #
26
+ # Every metric is tagged with +method:<name>+ and +class:<name>+.
27
+ # Client errors are rescued and warned rather than raised.
5
28
  module StatsD
29
+ # @api private
6
30
  METRIC_NAMES = {
7
31
  on_hit: "safe_memoize.hit",
8
32
  on_miss: "safe_memoize.miss",
@@ -11,6 +35,13 @@ module SafeMemoize
11
35
  on_store: "safe_memoize.store"
12
36
  }.freeze
13
37
 
38
+ # Dispatches a lifecycle event to the StatsD client.
39
+ #
40
+ # @param client [Object] a StatsD-compatible client responding to +#increment+
41
+ # @param hook_type [Symbol] one of the keys in {METRIC_NAMES}
42
+ # @param cache_key [Array] the internal cache key (first element is the method name)
43
+ # @param class_name [String, nil]
44
+ # @return [void]
14
45
  def self.dispatch(client, hook_type, cache_key, class_name)
15
46
  metric = METRIC_NAMES[hook_type]
16
47
  return unless metric
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeMemoize
4
+ # @api private
4
5
  module CacheMetricsMethods
5
6
  private
6
7
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeMemoize
4
+ # @api private
4
5
  module CacheRecordMethods
5
6
  private
6
7
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SafeMemoize
4
+ # @api private
4
5
  module CacheStoreMethods
5
6
  private
6
7