From 0d9b0b581391eb01083468b52d4acd95a3dfb74d Mon Sep 17 00:00:00 2001 From: Corey Farwell Date: Wed, 8 Jul 2026 17:56:12 -0400 Subject: [PATCH 1/4] Define named priority methods as real methods Replaces method_missing dispatch for named priority class methods (Delayed::Priority.eventual) and instance predicates (priority.eventual?) with real method definitions, (re)defined whenever names are assigned. Real methods are visible to runtime reflection, so documentation tools and Sorbet's tapioca can see them; method_missing methods are not, which forced Sorbet-adopting apps to shim every named priority by hand. Pre-existing methods are never overridden, matching the old method_missing behavior where a real method always won. Also fixes a test-order leak in priority_spec.rb: assign_at_midpoint was never reset by the around hook, so any example that ran after a midpoint example saw midpoint-shifted priority values. --- lib/delayed/priority.rb | 45 +++++++++++++++++++---------------- spec/delayed/priority_spec.rb | 31 +++++++++++++++++++++++- 2 files changed, 54 insertions(+), 22 deletions(-) diff --git a/lib/delayed/priority.rb b/lib/delayed/priority.rb index fc676268..b28da0b0 100644 --- a/lib/delayed/priority.rb +++ b/lib/delayed/priority.rb @@ -74,6 +74,7 @@ def names=(names) @alerts = nil @names_to_priority = nil @names = names&.sort_by(&:last)&.to_h&.transform_values { |v| new(v) } + redefine_named_priority_methods end def alerts=(alerts) @@ -124,16 +125,30 @@ def midpoint(low, high) low + ((high - low).to_d / 2).ceil end - def respond_to_missing?(method_name, include_private = false) - names_to_priority.key?(method_name) || super + # Defines a class method (e.g. `Priority.eventual`) and an instance predicate (e.g. + # `priority.eventual?`) per name, as real, introspectable methods (visible to `methods`, + # RDoc, and Sorbet's tapioca, unlike `method_missing` dispatch). Pre-existing methods + # (e.g. a name of `superclass`) are never overridden. + def redefine_named_priority_methods + @named_priority_class_methods&.each { |m| singleton_class.send(:remove_method, m) } + @named_priority_predicates&.each { |m| remove_method(m) } + @named_priority_class_methods = names.keys.filter_map { |name| define_priority_class_method(name) } + @named_priority_predicates = names.keys.filter_map { |name| define_priority_predicate(name) } end - def method_missing(method_name, *args) - if names_to_priority.key?(method_name) && args.none? - names_to_priority[method_name] - else - super - end + def define_priority_class_method(name) + return if singleton_class.method_defined?(name) || singleton_class.private_method_defined?(name) + + define_singleton_method(name) { names_to_priority.fetch(name) } + name + end + + def define_priority_predicate(name) + predicate = :"#{name}?" + return if method_defined?(predicate) || private_method_defined?(predicate) + + define_method(predicate) { name.to_s == to_s } + predicate end end @@ -188,18 +203,6 @@ def to_d to_i.to_d end - private - - def respond_to_missing?(method_name, include_private = false) - (method_name.to_s.end_with?('?') && self.class.names.key?(method_name.to_s[0..-2].to_sym)) || super - end - - def method_missing(method_name, *args) - if method_name.to_s.end_with?('?') && self.class.names.key?(method_name.to_s[0..-2].to_sym) - method_name.to_s[0..-2] == to_s - else - super - end - end + send(:redefine_named_priority_methods) end end diff --git a/spec/delayed/priority_spec.rb b/spec/delayed/priority_spec.rb index 77332833..89b1ac5c 100644 --- a/spec/delayed/priority_spec.rb +++ b/spec/delayed/priority_spec.rb @@ -13,9 +13,10 @@ ensure described_class.alerts = nil described_class.names = nil + described_class.assign_at_midpoint = nil end - describe '.names, .ranges, .alerts, .names_to_priority, method_missing' do + describe '.names, .ranges, .alerts, .names_to_priority, named priority methods' do it 'defaults to interactive, user_visible, eventual, reporting' do expect(described_class.names).to eq( interactive: 0, @@ -68,6 +69,34 @@ end end + it 'defines named priority methods as real, introspectable methods' do + expect(described_class.singleton_class.method_defined?(:eventual)).to be true + expect(described_class.method_defined?(:eventual?)).to be true + end + + it 'removes methods for the old names when names are reassigned' do + described_class.names = { high: 0 } + expect(described_class).to respond_to(:high) + expect(described_class.new(0)).to be_high + + described_class.names = nil + expect(described_class).not_to respond_to(:high) + expect(described_class.new(0)).not_to respond_to(:high?) + expect(described_class).to respond_to(:interactive) + expect(described_class.new(0)).to be_interactive + end + + context 'when a name collides with an existing method' do + let(:custom_names) { { superclass: 0, zero: 10 } } + + it 'does not redefine the existing method' do + expect(described_class.superclass).to eq Numeric # Class#superclass, not the priority + expect(described_class.new(0).zero?).to be true # Numeric#zero?, not the range predicate + expect(described_class.zero).to eq 10 + expect(described_class.new(5).superclass?).to be true + end + end + context 'when customized to high, medium, low' do let(:custom_names) { { high: 0, medium: 100, low: 500 } } From b6e303e4cff50cf22a21af640974d1e9f414eef4 Mon Sep 17 00:00:00 2001 From: Corey Farwell Date: Thu, 9 Jul 2026 11:15:39 -0400 Subject: [PATCH 2/4] Drive named priority method (re)definition off @names alone Address review feedback: remove the @named_priority_class_methods and @named_priority_predicates tracking ivars. On reassignment, remove the methods for the previously-specified names (read from @names before it is overwritten) using method_defined?(name, false) so only methods we defined ourselves are removed, leaving any pre-existing method a colliding name never overrode (e.g. Class#superclass, Numeric#zero?) in place. --- lib/delayed/priority.rb | 41 +++++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/lib/delayed/priority.rb b/lib/delayed/priority.rb index b28da0b0..9b4498a1 100644 --- a/lib/delayed/priority.rb +++ b/lib/delayed/priority.rb @@ -70,11 +70,12 @@ def alerts def names=(names) raise "must include a name for priority >= 0" if names && !names.value?(0) + remove_named_priority_methods @ranges = nil @alerts = nil @names_to_priority = nil @names = names&.sort_by(&:last)&.to_h&.transform_values { |v| new(v) } - redefine_named_priority_methods + define_named_priority_methods end def alerts=(alerts) @@ -129,26 +130,30 @@ def midpoint(low, high) # `priority.eventual?`) per name, as real, introspectable methods (visible to `methods`, # RDoc, and Sorbet's tapioca, unlike `method_missing` dispatch). Pre-existing methods # (e.g. a name of `superclass`) are never overridden. - def redefine_named_priority_methods - @named_priority_class_methods&.each { |m| singleton_class.send(:remove_method, m) } - @named_priority_predicates&.each { |m| remove_method(m) } - @named_priority_class_methods = names.keys.filter_map { |name| define_priority_class_method(name) } - @named_priority_predicates = names.keys.filter_map { |name| define_priority_predicate(name) } + def define_named_priority_methods + names.each_key do |name| + predicate = :"#{name}?" + define_singleton_method(name) { names_to_priority.fetch(name) } unless method_defined_on?(singleton_class, name) + define_method(predicate) { name.to_s == to_s } unless method_defined_on?(self, predicate) + end end - def define_priority_class_method(name) - return if singleton_class.method_defined?(name) || singleton_class.private_method_defined?(name) - - define_singleton_method(name) { names_to_priority.fetch(name) } - name + # Removes the methods previously defined for the current `names` (called before `names` is + # reassigned). The non-inherited (`false`) checks mean we only remove methods we defined + # ourselves, leaving any pre-existing method a colliding name never overrode (e.g. + # `Class#superclass`, `Numeric#zero?`) in place. + def remove_named_priority_methods + names.each_key do |name| + predicate = :"#{name}?" + singleton_class.send(:remove_method, name) if singleton_class.method_defined?(name, false) + remove_method(predicate) if method_defined?(predicate, false) + end end - def define_priority_predicate(name) - predicate = :"#{name}?" - return if method_defined?(predicate) || private_method_defined?(predicate) - - define_method(predicate) { name.to_s == to_s } - predicate + # Whether `mod` already responds to `name` (public, protected, private, or inherited), in + # which case a same-named priority must not override it. + def method_defined_on?(mod, name) + mod.method_defined?(name) || mod.private_method_defined?(name) end end @@ -203,6 +208,6 @@ def to_d to_i.to_d end - send(:redefine_named_priority_methods) + send(:define_named_priority_methods) end end From 61a7ea7e03df6719329e03efbd8c6a96a9326076 Mon Sep 17 00:00:00 2001 From: Corey Farwell Date: Thu, 9 Jul 2026 13:09:54 -0400 Subject: [PATCH 3/4] Bump version to 3.1.0 --- lib/delayed/version.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/delayed/version.rb b/lib/delayed/version.rb index 6c5ef3a3..d5ac1e9f 100644 --- a/lib/delayed/version.rb +++ b/lib/delayed/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Delayed - VERSION = '3.0.1' + VERSION = '3.1.0' end From 040d4d95f6b5de3f6c4bc0f69468f48d239e9c61 Mon Sep 17 00:00:00 2001 From: Corey Farwell Date: Fri, 10 Jul 2026 16:38:34 -0400 Subject: [PATCH 4/4] Update Gemfile.lock to delayed 3.1.0 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index e99178d9..bf469e67 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - delayed (3.0.1) + delayed (3.1.0) activerecord (>= 6.0) concurrent-ruby