Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning from v1.0.0 onwards. Prior 0.x releases may include breaking changes between minor versions.
Unreleased
[1.2.0] - 2026-05-27
Added
SafeMemoize::Adapters::ConcurrentRuby— optional store adapter backed byconcurrent-ruby; usesConcurrent::Mapas the backing hash andConcurrent::ReentrantReadWriteLockto allow multiple readers to proceed in parallel while writers still get exclusive access; reduces lock contention on hot read paths compared to the defaultMutex-backedStores::Memory;concurrent-rubyis a soft dependency — aLoadErrorwith an actionable message is raised at instantiation if the gem is not available.safe_memoize_store=/.safe_memoize_store— class-level attribute for setting a default store on an individual class without touching the globalSafeMemoize.configuredefault; takes precedence overSafeMemoize.configuration.default_storebut is overridden by a per-methodstore:argument; accepts anySafeMemoize::Stores::Baseinstance ornil; raisesArgumentErrorfor invalid valuesractor_safe: trueoption onmemoize— replaces theMutex-backed class-level shared cache with a supervisorRactorthat owns the mutable cache hash; all reads and writes are serialised through message passing so the cache is safe to use from multiple Ractors; requiresshared: true; cached values are deep-frozen viaRactor.make_shareable; the memoize wrapper Proc is frozen withRactor.make_shareablebefore being passed todefine_methodso that classes usingractor_safe: truecan be passed directly into worker Ractors; incompatible withif:,unless:,max_size:,ttl_refresh:,key:, andstore:(raisesArgumentError);ttl:is supported.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.reset_all_ractor_memos— class method to clear the entire Ractor-safe shared cache for this class.ractor_memoized?(method_name, *args, **kwargs)— returnstrueif a live entry exists in the Ractor-safe shared cache for the given call signature.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 givenfiber_local: trueoption onmemoize— stores results inFiber[:__safe_memoize__]rather than instance variables, giving each fiber its own isolated cache that is automatically discarded when the fiber terminates; noMutexis 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 withshared:andstore:(raisesArgumentError)#fiber_local_memoized?(method_name, *args, **kwargs)— returnstrueif the given call is currently cached in the current fiber's store#reset_fiber_memo(method_name, *args, **kwargs)— clears one or all fiber-local cached entries for a method in the current fiber#reset_all_fiber_memos— clears all fiber-local cached entries for this instance in the current fiber
Fixed
call_memo_hooksno longer raisesRactor::IsolationErrorwhen called from a worker Ractor —SafeMemoize.configuration(a module-level ivar) is now accessed only from the main Ractor;ActiveSupport::NotificationsandStatsDdispatch are silently skipped from worker Ractors; hook-error handling falls back towarnrather than reading the configuration handler- 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
- Codecov reporting accuracy — switched SimpleCov output from
.resultset.json(internal format, misread by Codecov as ~85%) tocoverage/coverage.jsonviasimplecov_json_formatter; CI now uploads the correct file - CI coverage ordering —
bundle exec rspecran files alphabetically, causingractor_spec.rbto execute beforespec/stores/, disrupting Ruby's Coverage counters and dropping reported coverage to ~96%; CI now usesbundle exec rake spec, which enforces the store-first ordering already documented in the Rakefile
[1.1.0] - 2026-05-22
Added
SafeMemoize::Stores::Base— abstract adapter base class defining the cache store contract:read(key),write(key, value, expires_in: nil),delete(key),clear,keys, andexist?(key); a frozenMISSsentinel onBasedistinguishes cache misses from cachednilorfalsevalues;exist?has a default implementation that delegates toreadSafeMemoize::Stores::Memory— built-in in-process store that wraps a plainHashbehind aMutex; supports per-entry TTL viaexpires_in:with lazy expiry on read; serves as both the default store and the reference implementation for custom adaptersConfiguration#default_store— set viaSafeMemoize.configure { |c| c.default_store = MyStore.new }to route everymemoizecall that has no explicitstore:through the given adapter; methods usingmax_size:orshared:are incompatible and fall back silently to the per-instance hash; an invalid value raisesArgumentErroratmemoizetime; cleared byreset_configuration!SafeMemoize::Stores::RailsCache— opt-in adapter (require "safe_memoize/stores/rails_cache") wrapping anyActiveSupport::Cache::Store(includingRails.cache); values are wrapped in a sentinel envelope so cachednil/falseare distinguished from a cache miss; TTL forwarded asexpires_in:for native store expiry;clearusesdelete_matchedscoped to the namespace;keysreturns[](AS::Cache has no enumeration API)SafeMemoize::Stores::Redis— opt-in adapter (require "safe_memoize/stores/redis") backed by any Redis-compatible client responding to#get,#set,#del, and#scan_each; values and keys are serialized with Marshal +pack("m0"); TTL is forwarded asPX(milliseconds, rounded up) for sub-second precision;clearusesSCANto avoid blocking; all entries are namespaced (default:"safe_memoize") so multiple stores or applications can share one Redis instancestore:option onmemoize— accepts anyStores::Basesubclass instance; routes all reads and writes through the adapter'sread/writeinterface; the store is shared across all instances of the class;ttl:is forwarded asexpires_in:towrite,ttl_refresh:re-writes on every hit, andif:/unless:conditional storage is enforced at the SafeMemoize layer; raisesArgumentErrorif combined withmax_size:(LRU belongs in the adapter) orshared:
Changed
- Test suite achieves 100% line coverage —
spec_helpernow requires opt-in store adapters (Stores::Redis,Stores::RailsCache) afterSimpleCov.startso Coverage tracks them;Rakefilerunsspec/stores/before other specs to prevent Ruby 3.4 Coverage counter disruption from Ractor/concurrency tests;version.rbexcluded from coverage reporting store:type guard inClassMethods#memoizecollapsed to an inline guard clause so Ruby's Coverage module counts the raise correctly- Hook-error isolation tests (
concurrency_spec,hooks_spec) now configureon_hook_error = ->(*) {}to silence expected stderr warnings rather than leaking them into test output; StatsD error-resilience test asserts on the emitted warning withexpect { }.to output(...).to_stderr
[1.0.0] - 2026-05-22
Added
- Ractor compatibility audit —
spec/ractor_spec.rbdocuments the specific failure modes (non-shareable closures indefine_methodblocks,Ractor::IsolationErroronSafeMemoize.configuration); README section explains the limitation and the Thread-based workaround - Semantic versioning guarantee — README
## Public API and versioning guaranteesection enumerates every public constant, method, option key, andConfigurationattribute 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 - Full API reference — YARD documentation added to all public methods, classes, and modules;
SafeMemoize::Adapters::StatsDandSafeMemoize::Adapters::OpenTelemetryfully documented with usage examples; internal modules marked@api private;.yardoptsandrake doctask added;gem "yard"added as a development dependency - Deprecation sweep — pre-v1.0.0 API consistency audit:
memoized?,memo_ttl_remaining,memo_touch,memo_age,memo_stale?now usecompute_cache_keyinstead ofsafe_memo_cache_keyso they correctly resolve entries stored with a custom key (instance-levelmemoize_with_custom_keyor class-levelkey:);memo_matcher_for(used byreset_memoandmemo_refresh) receives the same fix;SafeMemoize::Erroradded to the public API guarantee table and to RBS + Sorbet signatures; RBS and.rbiwarm_memoblock annotation corrected back to mandatory (was incorrectly marked optional in v0.9.0 signatures) - Ruby version policy — README
## Ruby version supportsection 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 - Complete RBS + Sorbet signatures —
sig/safe_memoize.rbscorrected:SafeMemoize::Adapters::StatsDadded;memo_count,memo_keys,memo_valuesfixed from rest-arg to proper optional single arg;clear_memo_hooksandclear_custom_keysoptional-arg annotations corrected;warm_memoblock marked optional; newrbi/safe_memoize.rbiships Sorbet stubs covering the full public API, allConfigurationattributes, adapters, and opt-in Rails helpers - Upgrade guide —
UPGRADING.mddocuments 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_valuesshape change,memoizedefinition-time raise, argument mutation fix, hook exception isolation, and the two custom-key introspection fixes landing in v1.0.0
[0.9.0] - 2026-05-22
Added
ActiveSupport::Notificationsintegration — opt-in viaSafeMemoize.configure { |c| c.active_support_notifications = true }; emitscache_hit.safe_memoize,cache_miss.safe_memoize,cache_evict.safe_memoize,cache_expire.safe_memoize, andcache_store.safe_memoizeevents; each payload includes:method,:key, and:class; zero overhead when ActiveSupport is not loadedSafeMemoize::Adapters::StatsD— thin optional adapter that routes lifecycle events to any StatsD client viaSafeMemoize.configure { |c| c.statsd_client = my_client }; emitssafe_memoize.hit,safe_memoize.miss,safe_memoize.evict,safe_memoize.expire, andsafe_memoize.storewithmethod:andclass:tags; client errors are rescued and warned rather than raised- Formal benchmark suite (
benchmarks/benchmark.rb) — six scenarios covering zero-arg cache hit/miss, with-argument hit, fast vs locked path, shared vs instance cache, and concurrent throughput under 8-thread contention; optional comparisons againstmemeryandmemo_wise; run withbundle exec ruby benchmarks/benchmark.rb - Concurrency stress test suite (
spec/concurrency_spec.rb) — 18 barrier-synchronized examples hammering the fast path, locked path, and shared cache under 30 concurrent threads; covers exactly-once computation, LRU size invariant, hook count integrity, metric accuracy, TTL pruning, and deadlock detection (10-second timeout per run) SafeMemoize::Adapters::OpenTelemetry— optional adapter that wraps each cache-miss computation in an OpenTelemetry span; configure viaSafeMemoize.configure { |c| c.opentelemetry_tracer = OpenTelemetry.tracer_provider.tracer("safe_memoize") }; span name is"safe_memoize.compute"with attributessafe_memoize.method,safe_memoize.class, andsafe_memoize.cache_hit; falls back to untraced execution when the tracer is absent or does not respond toin_spanSafeMemoize::Rails— opt-in request-scope helpers (require "safe_memoize/rails"):SafeMemoize::Rails::RequestScopedconcern auto-registersafter_action :reset_all_memosin controllers and exposesreset_request_memoselsewhere;SafeMemoize::Rails::MiddlewareRack middleware resets all thread-tracked instances (SafeMemoize::Rails.track(self)) at the end of each request even on error
[0.8.0] - 2026-05-21
Added
- Raise
ArgumentErrorat definition time whenmemoizeis called on a method that does not exist on the class — previously the error only surfaced at runtime whensuperhad nothing to call - Key serialization safety: argument arrays, hashes, and strings are deep-frozen into an independent copy when the cache key is built, so callers that mutate their arguments after a call can no longer corrupt or miss the cached entry
memo_inspect— single-entry deep-inspection helper returning all metadata for one cached call in one mutex-held read:cached,value,hits,misses,ttl_remaining,age,custom_key, andlru_position; returnsnilwhen the entry is not cached- Deprecation infrastructure:
SafeMemoize.deprecate(subject, message:, horizon:)emits a structured[SafeMemoize]warning to stderr by default; configurable viaSafeMemoize.configure { |c| c.on_deprecation = ->(msg) { ... } }to raise, log, or collect warnings memoize_all only:— symmetric counterpart toexcept:; explicitly lists the methods to memoize and skips all others; raisesArgumentErrorwhen bothonly:andexcept:are given- Hook error isolation: exceptions raised inside lifecycle hooks no longer propagate to the caller; by default a
[SafeMemoize] Hook error in <type>: <message>warning is emitted to stderr; configurable viaSafeMemoize.configure { |c| c.on_hook_error = ->(error, hook_type, cache_key) { ... } }to raise, log, or silence
0.7.0 - 2026-05-18
Added
memo_preloadto batch-warm multiple cache entries in one call —obj.memo_preload(:find, [1], [2], [3])calls the memoized method for each arg set, caches all results, and returns them in input orderon_memo_storehook that fires whenever a value is written to the cache (miss,warm_memo, orload_memo); completes the full lifecycle hook set alongsideon_hit,on_miss,on_expire, andon_evictSafeMemoize.configurefor global default options —default_ttlanddefault_max_sizeapply to all subsequently memoized methods; per-call options override the global defaultsSafeMemoize.reset_configuration!to restore all global defaults tonilmemo_touchto reset the expiry clock on a cached entry without recomputing — accepts an optionalttl:override; returnstrueon success,falseif the entry is not cached or already expiredshared_memo_ageclass method to inspect how long ago a shared entry was cachedshared_memo_stale?class method to check whether a shared entry's TTL has elapsedkey:option onmemoizefor class-level cache key generation — calls whose key block returns the same value share one cache entry; instance-levelmemoize_with_custom_keystill takes prioritymemo_refreshto force-recompute a cached entry and store the new value in one callmemo_ageto return how many seconds ago an entry was cached (nilif not cached or expired)memo_stale?to check whether a cached entry exists but its TTL has elapsed
Changed
cache_metrics_resetnow accepts an optional method name to clear stats for a single method only; calling without arguments still clears all metricsshared:support inmemoize_allis now tested and documented (was already functional via**optionspassthrough)- RBS type signatures updated for all new methods and the
Configurationclass
0.6.3 - 2026-05-18
Changed
- Upgrade
softprops/action-gh-releasefrom v2 to v3 to resolve Node.js 20 deprecation warning in release workflow
0.6.2 - 2026-05-18
Added
- 100% line coverage across all lib files — added tests for edge cases in
CacheRecordMethods,CacheStoreMethods,InspectionMethods, andReleaseTooling; added SimpleCov filter to exclude/specfrom coverage reporting
0.6.1 - 2026-05-17
Changed
- Refactored
cache_stats/cache_stats_forto share aggregation logic via private helpers
Fixed
memo_keysandmemo_valuesshowedargs: custom_key, kwargs: nilfor methods usingmemoize_with_custom_key— now correctly surfaces ascustom_key:
0.6.0 - 2026-05-17
Added
ttl:option onwarm_memoso warmed entries can be given an expirymax_size:support forshared: truememoization (class-level LRU eviction)ttl_refresh: trueoption onmemoizefor sliding window TTL — resets the expiry clock on every cache hit so the entry only expires after a full TTL of inactivityinclude_protected:andinclude_private:options onmemoize_allmemo_ttl_remainingfor TTL introspection — returns seconds until expiry,nilfor no TTL,0for uncached or expired
Fixed
- TTL clock started at
memoizedefinition time instead of at first method call - Metrics key silently dropped kwargs, causing methods that differ only in kwargs to share a metrics bucket
- Stale LRU references remained in the order list after expired entries were pruned
0.5.0 - 2026-05-17
Removed
- Support for Ruby 3.2 (EOL); minimum required version is now Ruby 3.3
0.4.0 - 2026-05-17
Added
warm_memo,dump_memo, andload_memofor cache warm-up and persistence — pre-populate entries without calling the method, export live entries as a plain hash, and restore from a snapshotshared: trueoption onmemoizeto store results on the class instead of per-instance — includesreset_shared_memo,reset_all_shared_memos,shared_memoized?, andshared_memo_count; supportsttl:,if:, andunless:memoize_allto memoize every public method defined on the class in one call — accepts allmemoizeoptions plusexcept:to skip specific methodson_memo_misshook that fires on every cache miss, completing the full lifecycle hook set
0.3.0 - 2026-05-15
Added
on_memo_hithook that fires on every cache hit- Conditional memoization via
if:andunless:predicates onmemoize— uncached calls recompute on every invocation until the condition is satisfied; composes withttl:,max_size:, and hooks - LRU cache size limit via
max_size:onmemoize— evicts the least-recently-used entry when the limit is reached; cache hits promote entries; fireson_evict; thread-safe
0.2.0 - 2026-05-14
Added
- Optional TTL expiration for memoized entries
on_memo_expireandon_memo_evictlifecycle hooks;clear_memo_hooksto remove registered hooks- Cache metrics:
cache_stats,cache_stats_for,cache_hit_rate,cache_miss_rate, andcache_metrics_reset - Custom cache key generation via
memoize_with_custom_keyandclear_custom_keys
0.1.2 - 2026-05-13
Added
- Method visibility preservation (public, protected, private) for memoized methods
- Targeted
reset_memo— clear one cached argument combination or all entries for a method memoized?helper to check whether a specific call is cachedmemo_count,memo_keys, andmemo_valueshelpers for cache introspection
0.1.1 - 2026-05-13
Added
- Automated release tooling (
bin/release) and GitHub Actions workflow for RubyGems publishing and GitHub releases
0.1.0 - 2026-02-26
Added
- Initial release