Skip to content

Releases: eclectic-coding/safe_memoize

v1.7.0

Choose a tag to compare

@github-actions github-actions released this 02 Jun 19:38

SafeMemoize 1.7.0

Added

  • SafeMemoize::Stores::Multilevel — multi-level (L1/L2/…) cache store that checks faster layers first and promotes values from deeper layers into shallower ones on a miss ("read-through promotion"). Reads walk the list from first (fastest) to last; writes go to every level simultaneously; deletes and clears apply to all levels. Accepts promote_expires_in: to control the TTL of promoted entries (default: no TTL, relying on the L1 store's own eviction). Raises ArgumentError if fewer than two stores are supplied.
  • store: [l1, l2] shorthand on memoize — passing an Array of Stores::Base instances is automatically converted to Stores::Multilevel.new(*stores), enabling multi-level caching without explicit wrapper construction.
  • SafeMemoize::Stores::XFetch — wraps any Stores::Base adapter with probabilistic early expiry (the XFetch algorithm) to prevent cache stampedes. Values are stored with an envelope that includes expires_at; on read the XFetch formula now − (delta × beta × log(rand)) ≥ expires_at decides whether to return the value or MISS (triggering early recomputation). Configurable via beta: (aggressiveness scalar, default 1.0) and delta: (estimated computation time in seconds, default 0.1). Composes naturally with Multilevel and CircuitBreaker.
  • stampede_protection: option on memoize — enables the XFetch algorithm for the per-instance in-process cache. Pass true (default beta 1.0) or a Numeric for a custom beta. Records actual computation time as delta on each miss so the XFetch probability adapts to real observed latency. Requires ttl:. Incompatible with store: (use Stores::XFetch for external stores) and ractor_safe:. Accepted by safe_memoize_options as a class-wide default.

v1.6.0

Choose a tag to compare

@github-actions github-actions released this 02 Jun 18:46

SafeMemoize 1.6.0

Added

  • SafeMemoize::Stores::CircuitBreaker — a Stores::Base wrapper that protects any external store adapter with a three-state circuit breaker (:closed:open:half_open). When the wrapped store raises on read or write, the error is swallowed: reads return MISS (triggering the per-instance in-process fallback cache) and writes are no-ops (the return value is unaffected). After a configurable number of consecutive failures (error_threshold:, default 5) the circuit opens and all store calls are bypassed until a probe interval elapses (probe_interval:, default 30 s), at which point a single probe request is let through; success closes the circuit, failure resets the timer. Any successful call in :closed state resets the consecutive error counter, so transient blips do not accumulate toward the threshold.
    • state — returns :closed, :open, or :half_open
    • open?true when the circuit is not fully closed
    • error_count — current consecutive error count
    • reset! — manually close the circuit and clear the counter
    • wrapped_store, error_threshold, probe_interval — readers
  • circuit_breaker: option on memoize — syntactic sugar that auto-wraps the configured store: adapter in a CircuitBreaker. Pass true to use defaults, or a Hash with :error_threshold and/or :probe_interval keys to customise. Raises ArgumentError if no store is configured. Does not double-wrap a store that is already a CircuitBreaker. Accepted by safe_memoize_options as a class-wide default.

v1.5.0

Choose a tag to compare

@github-actions github-actions released this 02 Jun 18:17

SafeMemoize 1.5.0

Added

  • group: option on memoize — assigns a method to a named invalidation group (memoize :find, group: :database). Groups are stored on the class and survive re-memoization; a method can belong to at most one group at a time (re-memoizing with a different group moves it). Accepts any non-empty Symbol or String. Can be set as a class default via safe_memoize_options group: :my_group.
  • reset_memo_group(group_name) instance method — clears all per-instance cached entries for every method in the named group in a single call; each evicted entry fires the :on_evict hook. A no-op for unknown groups.
  • reset_shared_memo_group(group_name) class method — the shared-cache equivalent of reset_memo_group; clears all shared-cache entries for every method in the group that was memoized with shared: true.
  • memo_group_methods(group_name) instance method — returns the array of method names belonging to the given group on the instance's class (empty array for unknown groups).
  • memo_groups instance method — returns all group names registered on the instance's class.
  • safe_memo_group_methods(group_name) class method — class-level equivalent of memo_group_methods.
  • safe_memo_groups class method — class-level equivalent of memo_groups.

v1.4.0

Choose a tag to compare

@github-actions github-actions released this 02 Jun 15:48

SafeMemoize 1.4.0

Added

  • safe_memoize_options(**opts) class-level macro — sets default options for every subsequent memoize call on the class. Per-call options take precedence; class defaults take precedence over global SafeMemoize.configure defaults. Accepts all memoize options except mode-switch options (shared:, fiber_local:, ractor_safe:, shared_cache:), which must be specified per call. Call with no arguments to clear class-level defaults.
  • copy_on_read: true option on memoize — returns a dup (or deep_dup when available, e.g. ActiveRecord objects) of the cached value on every read, preventing callers from mutating shared cached state. nil and frozen values are returned as-is. Works across all cache paths (per-instance, LRU, shared, fiber-local, and external store). Incompatible with ractor_safe: (ractor-safe values are always frozen; use that guarantee instead). Can be set as a class default via safe_memoize_options copy_on_read: true.

v1.3.0

Choose a tag to compare

@github-actions github-actions released this 28 May 20:57

SafeMemoize 1.3.0

Added

  • SafeMemoize::Extension — mixin for building SafeMemoize extensions. Extend it in any module to get a DSL for declaring custom memoize options and global lifecycle event handlers without monkey-patching SafeMemoize internals.

    • handles_option(name, &processor) — declares a custom keyword argument that memoize will accept; the processor block is called at definition time with (value, method_name, all_extension_options) and must return a Hash of standard memoize options to inject (e.g. {cache_bust: ...}, {ttl: 60}, {namespace: "v2"}).
    • on_cache_event(*event_types, &handler) — registers a global lifecycle handler that fires after every matching event (:on_hit, :on_miss, :on_store, :on_expire, :on_evict) across all memoized methods on all classes; handler receives (klass, method_name, cache_key, record); runs on the main Ractor only.
    • Duck-type compatible — any object responding to handled_options, process_memoize_option, and dispatch_cache_event works without extend SafeMemoize::Extension.
  • SafeMemoize.register_extension(name, extension) — registers an extension under a symbolic name.

  • SafeMemoize.unregister_extension(name) — removes an extension.

  • SafeMemoize.extensions — returns a snapshot of the registry.

  • SafeMemoize.reset_extensions! — clears the registry (test teardown).

  • SafeMemoize.extension_for_option(option_name) — returns the registered extension that handles the named option, or nil.

  • memoize now accepts **extension_options for any unknown keyword argument; each key is validated against registered extensions at call time and raises ArgumentError if no extension claims it, preserving the existing strict-options behaviour for typos.

  • cache_bust: callable option on memoize — automatic cache invalidation driven by a version token. A callable (Proc, lambda, or Symbol naming an instance method) is invoked on the instance at every cache lookup; the returned token is folded into the cache key alongside the normal arguments. When the token changes (e.g. an ActiveRecord updated_at advances after a save), the old key no longer matches any entry — the method body is recomputed and stored under the new key without any explicit reset_memo call. Accepts a zero-argument callable invoked via instance_exec (giving access to self, instance variables, and methods) or a Symbol naming an instance method. Returns any comparable value as the token: a Time, Integer, String, Array, etc. Old token entries accumulate as stale; pair with ttl: or a store adapter's eviction to bound memory. Incompatible with key:. Composes with namespace:, ttl:, if:, unless:, and shared_cache:.

  • shared_cache: "name" option on memoize — routes all reads and writes through a globally-registered named Stores::Base instance, enabling cross-class cache sharing. Any number of unrelated classes can share the same backing store by referencing the same name. The store is resolved at memoize definition time via SafeMemoize.shared_cache("name"), which auto-creates a Stores::Memory instance on first access; supply a custom adapter (Redis, RailsCache, etc.) by calling SafeMemoize.register_shared_cache("name", store) before any class that references the name is loaded. Incompatible with shared:, store:, fiber_local:, ractor_safe:, and max_size:; composes naturally with namespace:, ttl:, if:, unless:, and key:.

  • SafeMemoize.shared_cache(name) — returns the Stores::Base instance for the given name, creating a new Stores::Memory if none is registered.

  • SafeMemoize.register_shared_cache(name, store) — registers a custom Stores::Base instance under a name; must be called before any class that uses that name via shared_cache: is loaded.

  • SafeMemoize.clear_shared_cache(name) — calls clear on the named store, evicting all entries. No-op for unregistered names.

  • SafeMemoize.drop_shared_cache(name) — removes the named store from the registry; subsequent shared_cache(name) calls will auto-create a new Memory store.

  • SafeMemoize.shared_caches — returns a dup of the current registry as a Hash{String => Stores::Base}.

  • SafeMemoize.reset_shared_caches! — clears the entire registry; useful in test-suite after hooks to prevent state leaking between examples.

  • namespace: option on memoize — a String prefix scoped to a single method; prepended to the cache key's first element so that entries with different namespaces never collide, even when sharing the same store or the same per-instance hash. Must be a non-empty string without :. Useful for versioning one method independently of its peers.

  • .safe_memoize_namespace / .safe_memoize_namespace= — class-level namespace attribute; applies to every memoize call on the class that does not specify its own namespace: option. Takes precedence over the global SafeMemoize::Configuration#namespace.

  • SafeMemoize::Configuration#namespace — global namespace prefix applied to every memoize call site that has no per-method or class-level namespace set. Set via SafeMemoize.configure { |c| c.namespace = "v1" }. Useful for versioned deployments and multi-tenant setups. Cleared by reset_configuration!.

  • Resolution priority: per-method namespace: > class .safe_memoize_namespace > global Configuration#namespace.

  • All introspection methods (memoized?, memo_count, memo_keys, memo_values, reset_memo, reset_all_memos, dump_memo, cache_stats_for, cache_metrics_reset, shared-cache equivalents, etc.) accept the bare method name regardless of which namespace tier is active; the :method field in projections always returns the bare method name.

  • Ractor-safe: namespace resolution uses instance_variable_get (read-only) so worker Ractors can call compute_cache_key without triggering unshareable class-level ivar initialization.

v1.2.0

Choose a tag to compare

@github-actions github-actions released this 27 May 18:27

SafeMemoize 1.2.0

Added

  • 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

  • .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

  • 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

  • .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) — returns true if 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 given

  • 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)

  • #fiber_local_memoized?(method_name, *args, **kwargs) — returns true if 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_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
  • 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%) to coverage/coverage.json via simplecov_json_formatter; CI now uploads the correct file
  • 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

v1.1.0

Choose a tag to compare

@github-actions github-actions released this 22 May 16:40

SafeMemoize 1.1.0

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, and exist?(key); a frozen MISS sentinel on Base distinguishes cache misses from cached nil or false values; exist? has a default implementation that delegates to read
  • SafeMemoize::Stores::Memory — built-in in-process store that wraps a plain Hash behind a Mutex; supports per-entry TTL via expires_in: with lazy expiry on read; serves as both the default store and the reference implementation for custom adapters
  • Configuration#default_store — set via SafeMemoize.configure { |c| c.default_store = MyStore.new } to route every memoize call that has no explicit store: through the given adapter; methods using max_size: or shared: are incompatible and fall back silently to the per-instance hash; an invalid value raises ArgumentError at memoize time; cleared by reset_configuration!
  • SafeMemoize::Stores::RailsCache — opt-in adapter (require "safe_memoize/stores/rails_cache") wrapping any ActiveSupport::Cache::Store (including Rails.cache); values are wrapped in a sentinel envelope so cached nil/false are distinguished from a cache miss; TTL forwarded as expires_in: for native store expiry; clear uses delete_matched scoped to the namespace; keys returns [] (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 as PX (milliseconds, rounded up) for sub-second precision; clear uses SCAN to avoid blocking; all entries are namespaced (default: "safe_memoize") so multiple stores or applications can share one Redis instance
  • store: option on memoize — accepts any Stores::Base subclass instance; routes all reads and writes through the adapter's read/write interface; the store is shared across all instances of the class; ttl: is forwarded as expires_in: to write, ttl_refresh: re-writes on every hit, and if:/unless: conditional storage is enforced at the SafeMemoize layer; raises ArgumentError if combined with max_size: (LRU belongs in the adapter) or shared:

Changed

  • Test suite achieves 100% line coverage — spec_helper now requires opt-in store adapters (Stores::Redis, Stores::RailsCache) after SimpleCov.start so Coverage tracks them; Rakefile runs spec/stores/ before other specs to prevent Ruby 3.4 Coverage counter disruption from Ractor/concurrency tests; version.rb excluded from coverage reporting
  • store: type guard in ClassMethods#memoize collapsed to an inline guard clause so Ruby's Coverage module counts the raise correctly
  • 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

v1.0.0

Choose a tag to compare

@github-actions github-actions released this 22 May 13:19

SafeMemoize 1.0.0

Added

  • 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
  • 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
  • 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
  • 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)
  • 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
  • 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
  • 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

v0.9.0

Choose a tag to compare

@github-actions github-actions released this 22 May 11:43

SafeMemoize 0.9.0

Added

  • ActiveSupport::Notifications integration — opt-in via SafeMemoize.configure { |c| c.active_support_notifications = true }; emits cache_hit.safe_memoize, cache_miss.safe_memoize, cache_evict.safe_memoize, cache_expire.safe_memoize, and cache_store.safe_memoize events; each payload includes :method, :key, and :class; zero overhead when ActiveSupport is not loaded
  • SafeMemoize::Adapters::StatsD — thin optional adapter that routes lifecycle events to any StatsD client via SafeMemoize.configure { |c| c.statsd_client = my_client }; emits safe_memoize.hit, safe_memoize.miss, safe_memoize.evict, safe_memoize.expire, and safe_memoize.store with method: and class: 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 against memery and memo_wise; run with bundle 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 via SafeMemoize.configure { |c| c.opentelemetry_tracer = OpenTelemetry.tracer_provider.tracer("safe_memoize") }; span name is "safe_memoize.compute" with attributes safe_memoize.method, safe_memoize.class, and safe_memoize.cache_hit; falls back to untraced execution when the tracer is absent or does not respond to in_span
  • SafeMemoize::Rails — opt-in request-scope helpers (require "safe_memoize/rails"): SafeMemoize::Rails::RequestScoped concern auto-registers after_action :reset_all_memos in controllers and exposes reset_request_memos elsewhere; SafeMemoize::Rails::Middleware Rack middleware resets all thread-tracked instances (SafeMemoize::Rails.track(self)) at the end of each request even on error

v0.8.0

Choose a tag to compare

@github-actions github-actions released this 22 May 00:11

SafeMemoize 0.8.0

Added

  • Raise ArgumentError at definition time when memoize is called on a method that does not exist on the class — previously the error only surfaced at runtime when super had 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, and lru_position; returns nil when the entry is not cached
  • Deprecation infrastructure: SafeMemoize.deprecate(subject, message:, horizon:) emits a structured [SafeMemoize] warning to stderr by default; configurable via SafeMemoize.configure { |c| c.on_deprecation = ->(msg) { ... } } to raise, log, or collect warnings
  • memoize_all only: — symmetric counterpart to except:; explicitly lists the methods to memoize and skips all others; raises ArgumentError when both only: and except: 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 via SafeMemoize.configure { |c| c.on_hook_error = ->(error, hook_type, cache_key) { ... } } to raise, log, or silence