From 1917f17fb524631a5dd55d5cbef64482a0e4e775 Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Thu, 28 May 2026 16:50:12 -0400 Subject: [PATCH 1/3] Add plugin/extension architecture: SafeMemoize::Extension API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third-party gems can now extend memoize without monkey-patching: module MyExtension extend SafeMemoize::Extension handles_option :active_record_bust do |value, method_name, _opts| { cache_bust: -> { send(:updated_at) } } end on_cache_event :miss do |klass, method_name, _key, _record| MyMetrics.increment("cache.miss") end end SafeMemoize.register_extension(:active_record_bust, MyExtension) SafeMemoize::Extension DSL (via extend): handles_option(name, &processor) — declare a custom memoize option; processor returns a Hash of standard options to inject at definition time on_cache_event(*types, &handler) — global lifecycle event handler; fires after every matching event across all memoized methods Registry API on SafeMemoize module: register_extension(name, ext) / unregister_extension(name) extensions / reset_extensions! extension_for_option(name) / dispatch_extension_events(...) memoize now accepts **extension_options for unknown kwargs; each key is validated against registered extensions at definition time and the injected standard options (ttl:, namespace:, cache_bust:, etc.) are applied before the normal memoize logic runs. Duck-type compatibility: any object responding to handled_options, process_memoize_option, and dispatch_cache_event works without requiring extend SafeMemoize::Extension. Co-Authored-By: Claude Sonnet 4.6 --- lib/safe_memoize.rb | 69 ++++++ lib/safe_memoize/class_methods.rb | 21 +- lib/safe_memoize/extension.rb | 88 +++++++ lib/safe_memoize/hooks_methods.rb | 2 + sig/safe_memoize.rbs | 18 +- spec/extension_spec.rb | 371 ++++++++++++++++++++++++++++++ 6 files changed, 567 insertions(+), 2 deletions(-) create mode 100644 lib/safe_memoize/extension.rb create mode 100644 spec/extension_spec.rb diff --git a/lib/safe_memoize.rb b/lib/safe_memoize.rb index 398da2d..5c6da5f 100644 --- a/lib/safe_memoize.rb +++ b/lib/safe_memoize.rb @@ -2,6 +2,7 @@ require_relative "safe_memoize/version" require_relative "safe_memoize/configuration" +require_relative "safe_memoize/extension" require_relative "safe_memoize/stores/base" require_relative "safe_memoize/stores/memory" require_relative "safe_memoize/adapters/statsd" @@ -59,6 +60,11 @@ class Error < StandardError; end # @api private SHARED_CACHE_MUTEX = Mutex.new + # @api private + EXTENSION_REGISTRY = {} + # @api private + EXTENSION_MUTEX = Mutex.new + include InstanceMethods # @api private @@ -171,4 +177,67 @@ def self.shared_caches def self.reset_shared_caches! SHARED_CACHE_MUTEX.synchronize { SHARED_CACHE_REGISTRY.clear } end + + # Registers an extension under +name+. + # + # An extension is any Ruby object that optionally responds to the + # {Extension} interface: {Extension#handled_options}, {Extension#process_memoize_option}, + # and {Extension#dispatch_cache_event}. Use {Extension} as a mixin to get a + # convenient DSL for defining these. + # + # @param name [Symbol, String] a unique identifier for this extension + # @param extension [Object] the extension object or module + # @return [Object] the registered extension + def self.register_extension(name, extension) + EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY[name.to_sym] = extension } + end + + # Removes an extension from the registry. + # + # @param name [Symbol, String] + # @return [Object, nil] the removed extension, or +nil+ if not present + def self.unregister_extension(name) + EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY.delete(name.to_sym) } + end + + # Returns a snapshot of all registered extensions as a +Hash+. + # + # @return [Hash{Symbol => Object}] + def self.extensions + EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY.dup } + end + + # Removes all registered extensions. + # + # Useful in test suite +after+ hooks to prevent state leaking between examples. + # + # @return [void] + def self.reset_extensions! + EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY.clear } + end + + # Returns the first registered extension that declares it handles +option_name+, + # or +nil+ if none does. + # + # @param option_name [Symbol] + # @return [Object, nil] + def self.extension_for_option(option_name) + sym = option_name.to_sym + EXTENSION_MUTEX.synchronize do + EXTENSION_REGISTRY.values.find do |ext| + ext.respond_to?(:handled_options) && ext.handled_options.include?(sym) + end + end + end + + # Dispatches a cache lifecycle event to all registered extensions that + # respond to +dispatch_cache_event+. + # + # @api private + def self.dispatch_extension_events(event_type, klass, method_name, cache_key, record) + exts = EXTENSION_MUTEX.synchronize { EXTENSION_REGISTRY.values.dup } + exts.each do |ext| + ext.dispatch_cache_event(event_type, klass, method_name, cache_key, record) if ext.respond_to?(:dispatch_cache_event) + end + end end diff --git a/lib/safe_memoize/class_methods.rb b/lib/safe_memoize/class_methods.rb index 0c7451d..45140ed 100644 --- a/lib/safe_memoize/class_methods.rb +++ b/lib/safe_memoize/class_methods.rb @@ -76,13 +76,32 @@ module ClassMethods # @example With a custom store # STORE = SafeMemoize::Stores::Memory.new # memoize :fetch, store: STORE, ttl: 300 - 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, namespace: nil, shared_cache: nil, cache_bust: nil) + 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, namespace: nil, shared_cache: nil, cache_bust: nil, **extension_options) method_name = method_name.to_sym unless method_defined?(method_name) || private_method_defined?(method_name) || protected_method_defined?(method_name) raise ArgumentError, "cannot memoize :#{method_name} — no instance method with that name is defined on #{self}" end + unless extension_options.empty? + extension_options.each_key do |opt| + raise ArgumentError, "unknown memoize option :#{opt} — no registered extension handles it" unless SafeMemoize.extension_for_option(opt) + end + + injected = {} + extension_options.each do |opt, val| + result = SafeMemoize.extension_for_option(opt).process_memoize_option(opt, val, method_name, extension_options) + injected.merge!(result) + end + + ttl = injected[:ttl] if injected.key?(:ttl) + max_size = injected[:max_size] if injected.key?(:max_size) + namespace = injected[:namespace] if injected.key?(:namespace) + store = injected[:store] if injected.key?(:store) + shared_cache = injected[:shared_cache] if injected.key?(:shared_cache) + cache_bust = injected[:cache_bust] if injected.key?(:cache_bust) + end + visibility = memoized_method_visibility(method_name) config = SafeMemoize.configuration diff --git a/lib/safe_memoize/extension.rb b/lib/safe_memoize/extension.rb new file mode 100644 index 0000000..1dc4761 --- /dev/null +++ b/lib/safe_memoize/extension.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module SafeMemoize + # Mixin for defining SafeMemoize extensions. + # + # Extend this module in any Ruby module or class that you want to register + # as a SafeMemoize extension. It provides a DSL for declaring custom + # +memoize+ options and global cache lifecycle event handlers. + # + # @example Defining an extension + # module MyExtension + # extend SafeMemoize::Extension + # + # handles_option :active_record_bust do |value, method_name, _options| + # { cache_bust: -> { send(:updated_at) } } + # end + # + # on_cache_event :miss do |klass, method_name, _cache_key, _record| + # Rails.logger.debug "cache miss: #{klass}##{method_name}" + # end + # end + # + # SafeMemoize.register_extension(:active_record_bust, MyExtension) + module Extension + # Declares a custom +memoize+ option handled by this extension. + # + # The block is called at +memoize+ definition time whenever +option_name+ + # appears in the +memoize+ keyword arguments. It receives the option value, + # the method name being memoized, and the full hash of other extension options + # passed to that +memoize+ call. It must return a +Hash+ of standard + # {ClassMethods#memoize} options to inject (e.g. +{ cache_bust: ... }+), or + # +nil+/empty hash for no injection. + # + # @param option_name [Symbol] + # @yieldparam value [Object] the option value supplied by the caller + # @yieldparam method_name [Symbol] the method being memoized + # @yieldparam all_options [Hash] other extension options in the same +memoize+ call + # @yieldreturn [Hash, nil] standard memoize options to inject + # @return [void] + def handles_option(option_name, &processor) + @__handled_options__ ||= {} + @__handled_options__[option_name.to_sym] = processor + end + + # Registers a global cache lifecycle event handler. + # + # The block fires after every matching cache event across *all* memoized + # methods on all classes. Multiple event types can be listed in a single + # call. Valid types are +:on_hit+, +:on_miss+, +:on_store+, +:on_expire+, + # and +:on_evict+. + # + # Handlers execute on the main Ractor only; they are silently skipped from + # worker Ractors. + # + # @param event_types [Array] one or more of +:on_hit+, +:on_miss+, + # +:on_store+, +:on_expire+, +:on_evict+ + # @yieldparam klass [Class] the class whose instance triggered the event + # @yieldparam method_name [Symbol] bare method name (namespace stripped) + # @yieldparam cache_key [Array] the full cache key + # @yieldparam record [Hash, nil] the cache record (+value+, +expires_at+, +cached_at+) + # @return [void] + def on_cache_event(*event_types, &handler) + @__event_handlers__ ||= {} + event_types.each { |type| (@__event_handlers__[type.to_sym] ||= []) << handler } + end + + # @api private + def handled_options + @__handled_options__&.keys || [] + end + + # @api private + def process_memoize_option(option_name, value, method_name, all_options) + processor = @__handled_options__&.[](option_name.to_sym) + result = processor&.call(value, method_name, all_options) + result.is_a?(Hash) ? result : {} + end + + # @api private + def dispatch_cache_event(event_type, klass, method_name, cache_key, record) + return unless @__event_handlers__ + + (@__event_handlers__[event_type] || []).each do |handler| + handler.call(klass, method_name, cache_key, record) + end + end + end +end diff --git a/lib/safe_memoize/hooks_methods.rb b/lib/safe_memoize/hooks_methods.rb index 72f3944..3c84267 100644 --- a/lib/safe_memoize/hooks_methods.rb +++ b/lib/safe_memoize/hooks_methods.rb @@ -49,6 +49,8 @@ def call_memo_hooks(hook_type, cache_key, record) if (client = SafeMemoize.configuration.statsd_client) Adapters::StatsD.dispatch(client, hook_type, cache_key, self.class.name) end + + SafeMemoize.dispatch_extension_events(hook_type, self.class, safe_memo_bare_method_name(cache_key[0]), cache_key, record) end def safe_memo_notify(hook_type, cache_key) diff --git a/sig/safe_memoize.rbs b/sig/safe_memoize.rbs index f68357c..c188f49 100644 --- a/sig/safe_memoize.rbs +++ b/sig/safe_memoize.rbs @@ -20,6 +20,8 @@ module SafeMemoize SHARED_CACHE_REGISTRY: Hash[String, Stores::Base] SHARED_CACHE_MUTEX: Mutex + EXTENSION_REGISTRY: Hash[Symbol, untyped] + EXTENSION_MUTEX: Mutex def self.prepended: (Class base) -> void def self.configure: () { (Configuration) -> void } -> void @@ -32,6 +34,20 @@ module SafeMemoize def self.drop_shared_cache: (String name) -> Stores::Base? def self.shared_caches: () -> Hash[String, Stores::Base] def self.reset_shared_caches!: () -> void + def self.register_extension: (Symbol | String name, untyped extension) -> untyped + def self.unregister_extension: (Symbol | String name) -> untyped? + def self.extensions: () -> Hash[Symbol, untyped] + def self.reset_extensions!: () -> void + def self.extension_for_option: (Symbol option_name) -> untyped? + def self.dispatch_extension_events: (Symbol event_type, Class klass, Symbol method_name, untyped cache_key, untyped record) -> void + + module Extension + def handles_option: (Symbol option_name) { (untyped value, Symbol method_name, Hash[Symbol, untyped] all_options) -> Hash[Symbol, untyped]? } -> void + def on_cache_event: (*Symbol event_types) { (Class klass, Symbol method_name, untyped cache_key, untyped record) -> void } -> void + def handled_options: () -> Array[Symbol] + def process_memoize_option: (Symbol option_name, untyped value, Symbol method_name, Hash[Symbol, untyped] all_options) -> Hash[Symbol, untyped] + def dispatch_cache_event: (Symbol event_type, Class klass, Symbol method_name, untyped cache_key, untyped record) -> void + end class Configuration attr_accessor default_ttl: Numeric? @@ -48,7 +64,7 @@ module SafeMemoize end module ClassMethods - 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, ?namespace: String?, ?shared_cache: String?, ?cache_bust: (^() -> untyped) | Symbol | nil) -> void + 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, ?namespace: String?, ?shared_cache: String?, ?cache_bust: (^() -> untyped) | Symbol | nil, **untyped extension_options) -> void def safe_memoize_store: () -> Stores::Base? def safe_memoize_store=: (Stores::Base?) -> Stores::Base? def safe_memoize_namespace: () -> String? diff --git a/spec/extension_spec.rb b/spec/extension_spec.rb new file mode 100644 index 0000000..beaf237 --- /dev/null +++ b/spec/extension_spec.rb @@ -0,0 +1,371 @@ +# frozen_string_literal: true + +RSpec.describe "SafeMemoize extension API" do + after do + SafeMemoize.reset_extensions! + SafeMemoize.reset_configuration! + end + + # --------------------------------------------------------------------------- + # SafeMemoize::Extension DSL + # --------------------------------------------------------------------------- + + describe "SafeMemoize::Extension DSL" do + it "provides handled_options" do + ext = Module.new do + extend SafeMemoize::Extension + + handles_option(:my_opt) { {} } + end + expect(ext.handled_options).to eq([:my_opt]) + end + + it "process_memoize_option calls the declared processor" do + ext = Module.new do + extend SafeMemoize::Extension + + handles_option(:my_opt) { |val, _method, _opts| {ttl: val} } + end + result = ext.process_memoize_option(:my_opt, 30, :compute, {}) + expect(result).to eq({ttl: 30}) + end + + it "process_memoize_option returns empty hash for undeclared options" do + ext = Module.new { extend SafeMemoize::Extension } + expect(ext.process_memoize_option(:unknown, 1, :x, {})).to eq({}) + end + + it "dispatch_cache_event calls registered handlers" do + fired = [] + ext = Module.new do + extend SafeMemoize::Extension + + on_cache_event(:on_miss) { |klass, method_name, _key, _rec| fired << [klass, method_name] } + end + ext.dispatch_cache_event(:on_miss, String, :fetch, [:fetch, [], {}], nil) + expect(fired).to eq([[String, :fetch]]) + end + + it "dispatch_cache_event is a no-op for unregistered event types" do + ext = Module.new { extend SafeMemoize::Extension } + expect { ext.dispatch_cache_event(:on_miss, String, :x, [], nil) }.not_to raise_error + end + + it "on_cache_event accepts multiple event types in one call" do + fired = [] + ext = Module.new do + extend SafeMemoize::Extension + + on_cache_event(:on_hit, :on_miss) { |_, name, _, _| fired << name } + end + ext.dispatch_cache_event(:on_hit, String, :a, [], nil) + ext.dispatch_cache_event(:on_miss, String, :b, [], nil) + ext.dispatch_cache_event(:on_store, String, :c, [], nil) + expect(fired).to eq([:a, :b]) + end + end + + # --------------------------------------------------------------------------- + # Registry API + # --------------------------------------------------------------------------- + + describe "registry" do + let(:ext) { Module.new { extend SafeMemoize::Extension } } + + it "register_extension stores under a symbol key" do + SafeMemoize.register_extension(:my_ext, ext) + expect(SafeMemoize.extensions).to eq({my_ext: ext}) + end + + it "register_extension accepts a string name and normalizes to symbol" do + SafeMemoize.register_extension("str_ext", ext) + expect(SafeMemoize.extensions.keys).to include(:str_ext) + end + + it "unregister_extension removes the entry" do + SafeMemoize.register_extension(:rm_me, ext) + SafeMemoize.unregister_extension(:rm_me) + expect(SafeMemoize.extensions).not_to have_key(:rm_me) + end + + it "unregister_extension returns nil for unknown name" do + expect(SafeMemoize.unregister_extension(:ghost)).to be_nil + end + + it "extensions returns a dup" do + SafeMemoize.register_extension(:e, ext) + SafeMemoize.extensions[:injected] = ext + expect(SafeMemoize.extensions).not_to have_key(:injected) + end + + it "reset_extensions! empties the registry" do + SafeMemoize.register_extension(:one, ext) + SafeMemoize.reset_extensions! + expect(SafeMemoize.extensions).to be_empty + end + end + + # --------------------------------------------------------------------------- + # extension_for_option + # --------------------------------------------------------------------------- + + describe ".extension_for_option" do + it "returns the extension that handles the option" do + ext = Module.new do + extend SafeMemoize::Extension + + handles_option(:special) { {} } + end + SafeMemoize.register_extension(:special, ext) + expect(SafeMemoize.extension_for_option(:special)).to be(ext) + end + + it "returns nil when no extension handles the option" do + expect(SafeMemoize.extension_for_option(:nonexistent)).to be_nil + end + end + + # --------------------------------------------------------------------------- + # Custom memoize options + # --------------------------------------------------------------------------- + + describe "custom memoize options via extension" do + it "raises ArgumentError for unrecognised options" do + klass = Class.new { + prepend SafeMemoize + + def x = 1 + } + + expect { klass.memoize(:x, unknown_opt: true) } + .to raise_error(ArgumentError, /unknown memoize option :unknown_opt/) + end + + it "injects cache_bust: from an extension option" do + ext = Module.new do + extend SafeMemoize::Extension + + handles_option(:bust_on) do |value, _method, _opts| + {cache_bust: value} + end + end + SafeMemoize.register_extension(:bust_on, ext) + + calls = 0 + klass = Class.new do + prepend SafeMemoize + + attr_accessor :version + + def initialize = (@version = 1) + + define_method(:compute) { calls += 1 } + memoize :compute, bust_on: -> { @version } + end + + obj = klass.new + obj.compute + obj.compute + expect(calls).to eq(1) + + obj.version = 2 + obj.compute + expect(calls).to eq(2) + end + + it "injects ttl: from an extension option" do + ext = Module.new do + extend SafeMemoize::Extension + + handles_option(:short_ttl) { |val, _, _| {ttl: val} } + end + SafeMemoize.register_extension(:short_ttl, ext) + + klass = Class.new do + prepend SafeMemoize + + def data = 42 + memoize :data, short_ttl: 0.01 + end + + obj = klass.new + obj.data + expect(obj.memoized?(:data)).to be true + sleep(0.02) + expect(obj.memoized?(:data)).to be false + end + + it "injects namespace: from an extension option" do + ext = Module.new do + extend SafeMemoize::Extension + + handles_option(:scope) { |val, _, _| {namespace: val} } + end + SafeMemoize.register_extension(:scope, ext) + + klass = Class.new do + prepend SafeMemoize + + def result = 1 + memoize :result, scope: "tenant_42" + end + + obj = klass.new + obj.result + expect(obj.memoized?(:result)).to be true + expect(obj.memo_count(:result)).to eq(1) + end + + it "extension injects nothing (returns empty hash) without error" do + ext = Module.new do + extend SafeMemoize::Extension + + handles_option(:noop) { |_, _, _| {} } + end + SafeMemoize.register_extension(:noop, ext) + + klass = Class.new { + prepend SafeMemoize + + def x = 1 + } + + expect { klass.memoize(:x, noop: true) }.not_to raise_error + end + + it "multiple extensions can coexist" do + ext1 = Module.new do + extend SafeMemoize::Extension + + handles_option(:opt1) { |val, _, _| {ttl: val} } + end + ext2 = Module.new do + extend SafeMemoize::Extension + + handles_option(:opt2) { |val, _, _| {namespace: val} } + end + SafeMemoize.register_extension(:ext1, ext1) + SafeMemoize.register_extension(:ext2, ext2) + + klass = Class.new { + prepend SafeMemoize + + def x = 1 + } + + expect { klass.memoize(:x, opt1: 60, opt2: "ns") }.not_to raise_error + end + end + + # --------------------------------------------------------------------------- + # Global lifecycle event dispatch + # --------------------------------------------------------------------------- + + describe "global lifecycle events" do + it "fires on_miss handlers when a method is called cold" do + events = [] + ext = Module.new do + extend SafeMemoize::Extension + + on_cache_event(:on_miss) { |_klass, method_name, _key, _rec| events << method_name } + end + SafeMemoize.register_extension(:obs, ext) + + klass = Class.new do + prepend SafeMemoize + + def fetch = 1 + memoize :fetch + end + + klass.new.fetch + expect(events).to include(:fetch) + end + + it "fires on_hit handlers on subsequent calls" do + hits = 0 + ext = Module.new do + extend SafeMemoize::Extension + + on_cache_event(:on_hit) { hits += 1 } + end + SafeMemoize.register_extension(:hit_obs, ext) + + klass = Class.new { + prepend SafeMemoize + + def x = 1 + memoize :x + } + + obj = klass.new + obj.x + obj.x + obj.x + expect(hits).to eq(2) + end + + it "strips namespace from method_name passed to handlers" do + received = [] + ext = Module.new do + extend SafeMemoize::Extension + + on_cache_event(:on_miss) { |_klass, method_name, _key, _rec| received << method_name } + end + SafeMemoize.register_extension(:ns_obs, ext) + + klass = Class.new { + prepend SafeMemoize + + def value = 1 + memoize :value, namespace: "v1" + } + + klass.new.value + expect(received).to include(:value) + end + + it "does not fire for unregistered event types" do + fired = [] + ext = Module.new do + extend SafeMemoize::Extension + + on_cache_event(:on_hit) { fired << :hit } + end + SafeMemoize.register_extension(:selective, ext) + + klass = Class.new { + prepend SafeMemoize + + def y = 2 + memoize :y + } + + klass.new.y # fires on_miss + on_store, not on_hit + expect(fired).to be_empty + end + end + + # --------------------------------------------------------------------------- + # Duck-type compatibility (no extend SafeMemoize::Extension required) + # --------------------------------------------------------------------------- + + describe "duck-type extension interface" do + it "accepts any object responding to the interface methods" do + duck_ext = Object.new + def duck_ext.handled_options = [:quack] + def duck_ext.process_memoize_option(_name, val, _method, _opts) = {ttl: val} + def duck_ext.dispatch_cache_event(*) = nil + + SafeMemoize.register_extension(:duck, duck_ext) + + klass = Class.new { + prepend SafeMemoize + + def x = 1 + } + + expect { klass.memoize(:x, quack: 10) }.not_to raise_error + end + end +end From 09234b6a4af55442ce7638a691265c6c21b0962d Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Thu, 28 May 2026 16:52:36 -0400 Subject: [PATCH 2/3] Update CHANGELOG, ROADMAP, README for extension architecture Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 11 +++++++ README.md | 90 ++++++++++++++++++++++++++++++++++++++++++++++++++++ ROADMAP.md | 2 +- 3 files changed, 102 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42c544d..48fa236 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,17 @@ from v1.0.0 onwards. Prior 0.x releases may include breaking changes between min ### 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:`. diff --git a/README.md b/README.md index 22a0ab4..e9cac5f 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ SafeMemoize uses Ruby's `prepend` mechanism. When you call `memoize :method_name - [Cache namespacing — per-method `namespace:`, class-level `.safe_memoize_namespace=`, and global `Configuration#namespace` for multi-tenant and versioned deployments](#cache-namespacing) - [Named shared caches via `shared_cache: "name"` — cross-class cache sharing backed by a globally-registered store](#named-shared-caches) - [Automatic cache busting via `cache_bust:` — version-token-based invalidation; works with ActiveRecord `updated_at` and any comparable value](#automatic-cache-busting) +- [Plugin / extension architecture — `SafeMemoize::Extension` DSL for adding custom `memoize` options and global lifecycle handlers without monkey-patching](#plugin--extension-architecture) ## Installation @@ -732,6 +733,89 @@ Metrics are per-instance and reset independently from the cache itself — clear [↑ Back to features](#features) +### Plugin / extension architecture + +`SafeMemoize::Extension` lets third-party gems add custom `memoize` options and global lifecycle handlers without monkey-patching SafeMemoize internals. + +```ruby +module MyExtension + extend SafeMemoize::Extension + + # Declare a custom memoize option. + # The processor block runs at memoize definition time and returns + # a Hash of standard memoize options to inject. + handles_option :active_record_bust do |_value, _method_name, _options| + { cache_bust: -> { send(:updated_at) } } + end + + # Register a global lifecycle handler (fires for every memoized method). + on_cache_event :miss do |klass, method_name, _cache_key, _record| + Rails.logger.debug "cache miss: #{klass}##{method_name}" + end +end + +SafeMemoize.register_extension(:active_record_bust, MyExtension) +``` + +Once registered, the custom option is accepted by `memoize`: + +```ruby +class OrderDecorator + prepend SafeMemoize + + def initialize(order) = (@order = order) + + def summary = expensive_compute(@order) + memoize :summary, active_record_bust: true + # ↑ MyExtension injects cache_bust: -> { updated_at } automatically +end +``` + +#### `handles_option` processor return values + +The processor block must return a Hash of **standard** `memoize` option keys to inject. Any standard option is supported: + +```ruby +handles_option :short_lived do |ttl, _, _| { ttl: ttl } end +handles_option :versioned do |ns, _, _| { namespace: ns } end +handles_option :via_redis do |store, _, _| { store: store } end +handles_option :bust_on do |fn, _, _| { cache_bust: fn } end +``` + +#### `on_cache_event` handler signature + +```ruby +on_cache_event :on_hit, :on_miss do |klass, method_name, cache_key, record| + # klass — the class whose instance triggered the event + # method_name — bare Symbol (namespace stripped) + # cache_key — full cache key Array + # record — { value:, expires_at:, cached_at: } or nil +end +``` + +Valid event types: `:on_hit`, `:on_miss`, `:on_store`, `:on_expire`, `:on_evict`. + +#### Registry API + +```ruby +SafeMemoize.register_extension(:name, MyExtension) +SafeMemoize.unregister_extension(:name) +SafeMemoize.extensions # { name: MyExtension, … } +SafeMemoize.reset_extensions! # clear registry (test teardown) +SafeMemoize.extension_for_option(:active_record_bust) # → MyExtension +``` + +#### Duck-type compatibility + +An extension does not need to `extend SafeMemoize::Extension`. Any object responding to `handled_options`, `process_memoize_option`, and `dispatch_cache_event` is accepted. + +#### Constraints + +- Unknown `memoize` keywords raise `ArgumentError` unless a registered extension claims them — typos are still caught. +- `on_cache_event` handlers run on the main Ractor only; they are silently skipped from worker Ractors. + +[↑ Back to features](#features) + ### Automatic cache busting `cache_bust:` ties a method's cache lifetime to a version token derived from instance state. When the token changes, the old cache key no longer matches — the method is recomputed automatically, with no explicit `reset_memo` required. @@ -1424,6 +1508,11 @@ Anything **not** listed here — internal modules, private methods, `@__safe_mem | `SafeMemoize.drop_shared_cache(name)` | module method | Removes the named store from the registry | | `SafeMemoize.shared_caches` | module method | Returns a snapshot of the registry | | `SafeMemoize.reset_shared_caches!` | module method | Clears the entire registry (test teardown) | +| `SafeMemoize.register_extension(name, ext)` | module method | Registers a plugin extension | +| `SafeMemoize.unregister_extension(name)` | module method | Removes an extension | +| `SafeMemoize.extensions` | module method | Returns snapshot of extension registry | +| `SafeMemoize.reset_extensions!` | module method | Clears all extensions (test teardown) | +| `SafeMemoize.extension_for_option(name)` | module method | Returns the extension handling the named option | ### `memoize` DSL (class method, added by `prepend SafeMemoize`) @@ -1442,6 +1531,7 @@ Anything **not** listed here — internal modules, private methods, `@__safe_mem | `namespace:` | `String \| nil` | `nil` | Namespace prefix prepended to the cache key's first element; must not contain `:`; takes precedence over the class-level and global namespace | | `shared_cache:` | `String \| nil` | `nil` | Name of a globally-registered shared store; incompatible with `shared:`, `store:`, `fiber_local:`, `ractor_safe:`, and `max_size:` | | `cache_bust:` | `Proc \| Symbol \| nil` | `nil` | Version-token callable; invoked on the instance at each lookup; token is folded into the key; incompatible with `key:` | +| *(extension options)* | any | — | Unknown kwargs are validated against registered extensions; raise `ArgumentError` if unclaimed | ### `memoize_all` options (class method) diff --git a/ROADMAP.md b/ROADMAP.md index 876d740..52433b2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,7 +10,7 @@ This document tracks the planned evolution of SafeMemoize through v1.0.0 and bey | Feature | Description | Status | |---|---|---| -| Plugin / extension architecture | A formal `SafeMemoize::Extension` API so third-party gems can add new options, hooks, or store adapters without monkey-patching | Planned | +| Plugin / extension architecture | A formal `SafeMemoize::Extension` API so third-party gems can add new options, hooks, or store adapters without monkey-patching | Shipped | | DSL refinements | Evaluate alternative syntax proposals (`memoize_method`, block form, annotation approach) based on community feedback; introduce the preferred form with a migration path from the current API | Planned | | Cross-instance cache sharing | Beyond the class-level `shared: true`, support explicitly named shared caches that span unrelated classes | Shipped | | Cache namespacing | Allow a namespace prefix on all keys for multi-tenant or versioned deployments (especially useful with external stores) | Shipped | From 781e1bb8043f4f6fe49526a91ecd4e9cf354285a Mon Sep 17 00:00:00 2001 From: Chuck Smith Date: Thu, 28 May 2026 16:55:11 -0400 Subject: [PATCH 3/3] Remove shipped features from ROADMAP ahead of v1.3.0 release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shipped items (namespace, cross-instance sharing, cache_bust, extension architecture) are removed. DSL refinements remains as the only planned item — it requires community feedback before implementation. Co-Authored-By: Claude Sonnet 4.6 --- ROADMAP.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 52433b2..8d684fd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -10,11 +10,7 @@ This document tracks the planned evolution of SafeMemoize through v1.0.0 and bey | Feature | Description | Status | |---|---|---| -| Plugin / extension architecture | A formal `SafeMemoize::Extension` API so third-party gems can add new options, hooks, or store adapters without monkey-patching | Shipped | | DSL refinements | Evaluate alternative syntax proposals (`memoize_method`, block form, annotation approach) based on community feedback; introduce the preferred form with a migration path from the current API | Planned | -| Cross-instance cache sharing | Beyond the class-level `shared: true`, support explicitly named shared caches that span unrelated classes | Shipped | -| Cache namespacing | Allow a namespace prefix on all keys for multi-tenant or versioned deployments (especially useful with external stores) | Shipped | -| Automatic cache busting | Optional integration with ActiveRecord's `updated_at` timestamp so object mutations automatically invalidate their own cached entries | Shipped | ---