From feebe056ec41bd829fba98a2ca5294d618a9b60a Mon Sep 17 00:00:00 2001 From: anuj-pal27 Date: Thu, 9 Apr 2026 20:04:07 +0530 Subject: [PATCH 1/3] Update deferred retry backoff to quartic formula and default max retries to 20 --- lib/rage/deferred/task.rb | 7 ++----- spec/deferred/task_spec.rb | 28 +++++++++++++++++++--------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/lib/rage/deferred/task.rb b/lib/rage/deferred/task.rb index 820b98cd..34365a9b 100644 --- a/lib/rage/deferred/task.rb +++ b/lib/rage/deferred/task.rb @@ -31,12 +31,9 @@ # ``` # module Rage::Deferred::Task - MAX_ATTEMPTS = 5 + MAX_ATTEMPTS = 20 private_constant :MAX_ATTEMPTS - BACKOFF_INTERVAL = 5 - private_constant :BACKOFF_INTERVAL - # @private CONTEXT_KEY = :__rage_deferred_execution_context @@ -191,7 +188,7 @@ def __next_retry_in(attempts, exception) # @private def __default_backoff(attempt) - rand(BACKOFF_INTERVAL * 2**attempt) + 1 + (attempt**4) + 10 + (rand(15) * attempt) end end end diff --git a/spec/deferred/task_spec.rb b/spec/deferred/task_spec.rb index 8cce2131..c8abd962 100644 --- a/spec/deferred/task_spec.rb +++ b/spec/deferred/task_spec.rb @@ -44,17 +44,27 @@ def perform(arg, kwarg:) end describe ".__next_retry_in" do - it "returns the next retry interval with exponential backoff" do - expect(task_class.__next_retry_in(0, nil)).to be_between(1, 5) - expect(task_class.__next_retry_in(1, nil)).to be_between(1, 10) - expect(task_class.__next_retry_in(2, nil)).to be_between(1, 20) - expect(task_class.__next_retry_in(3, nil)).to be_between(1, 40) - expect(task_class.__next_retry_in(4, nil)).to be_between(1, 80) + it "returns the next retry interval with quartic backoff" do + # Formula: (attempt**4) + 10 + (rand(15) * attempt) + # rand(15) => 0..14 + + # attempt 0 -> 0^4 + 10 + (0..14)*0 = 10 + expect(task_class.__next_retry_in(0, nil)).to eq(10) + # attempt 1 -> 1^4 + 10 + (0..14)*1 = 11..25 + expect(task_class.__next_retry_in(1, nil)).to be_between(11, 25) + # attempt 2 -> 2^4 + 10 + (0..14)*2 = 26..54 + expect(task_class.__next_retry_in(2, nil)).to be_between(26, 54) + # attempt 3 -> 3^4 + 10 + (0..14)*3 = 91..133 + expect(task_class.__next_retry_in(3, nil)).to be_between(91, 133) + # attempt 4 -> 4^4 + 10 + (0..14)*4 = 266..322 + expect(task_class.__next_retry_in(4, nil)).to be_between(266, 322) end it "returns nil when attempts exceed max" do - expect(task_class.__next_retry_in(5, nil)).to be_between(1, 160) - expect(task_class.__next_retry_in(6, nil)).to be_nil + # With MAX_ATTEMPTS=20 and current guard (attempts > max), + # attempt 20 still retries, attempt 21 stops. + expect(task_class.__next_retry_in(20, nil)).to be_a(Numeric) + expect(task_class.__next_retry_in(21, nil)).to be_nil end end @@ -167,7 +177,7 @@ def perform(arg, kwarg:) it "__next_retry_in uses default backoff for unmatched" do interval = task_class.__next_retry_in(1, StandardError.new) - expect(interval).to be_between(1, 10) + expect(interval).to be_between(11, 25) end it "__next_retry_in enforces max_retries even with custom interval" do From b992ba3fdc497861dd9d2027d50abb479dda9579 Mon Sep 17 00:00:00 2001 From: anuj-pal27 Date: Thu, 9 Apr 2026 22:28:17 +0530 Subject: [PATCH 2/3] Update changelog for deferred retry strategy changes (#251) --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4a28ebc..a01806ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Add singular `resource` routing with plural controller mapping and document the helper by [@anuj-pal27](https://github.com/anuj-pal27). +### Changed + +- [Deferred] Increase default retry limit to 20 and update default retry backoff to `(attempt**4) + 10 + (rand(15) * attempt)` by [@anuj-pal27](https://github.com/anuj-pal27) (#251). + ## [1.22.1] - 2026-04-01 ### Fixed From 7d85da032719fd56b6a89d17bcc0bba84454161f Mon Sep 17 00:00:00 2001 From: anuj-pal27 Date: Wed, 22 Apr 2026 13:40:08 +0530 Subject: [PATCH 3/3] add Metadata.will_retry_in and memorize __next_retry_in per attempt --- lib/rage/deferred/metadata.rb | 8 ++++++++ lib/rage/deferred/task.rb | 23 +++++++++++++++++++---- spec/deferred/metadata_spec.rb | 16 ++++++++++++++++ spec/deferred/task_spec.rb | 25 +++++++++++++++++++++---- 4 files changed, 64 insertions(+), 8 deletions(-) diff --git a/lib/rage/deferred/metadata.rb b/lib/rage/deferred/metadata.rb index 00dec22a..c78619f2 100644 --- a/lib/rage/deferred/metadata.rb +++ b/lib/rage/deferred/metadata.rb @@ -30,6 +30,14 @@ def will_retry? !!task.__next_retry_in(attempts, nil) end + # Returns the number of seconds until the next retry, or `nil` if no retry will occur. + # The result is memoized per attempt so that the value reported here matches what the queue uses to schedule the retry. + # @return [Numeric, nil] retry delay in seconds, or `nil` if the task won't be retried + def will_retry_in + task = Rage::Deferred::Context.get_task(context) + task.__next_retry_in(attempts, nil) + end + private def context diff --git a/lib/rage/deferred/task.rb b/lib/rage/deferred/task.rb index 34365a9b..a1843a20 100644 --- a/lib/rage/deferred/task.rb +++ b/lib/rage/deferred/task.rb @@ -37,6 +37,10 @@ module Rage::Deferred::Task # @private CONTEXT_KEY = :__rage_deferred_execution_context + # @private + RETRY_IN_CACHE_KEY = :__rage_deferred_retry_in + private_constant :RETRY_IN_CACHE_KEY + def perform end @@ -172,18 +176,29 @@ def enqueue(*args, delay: nil, delay_until: nil, **kwargs) # @private def __next_retry_in(attempts, exception) + cached = Fiber[RETRY_IN_CACHE_KEY] + if cached && cached[0] == attempts + return cached[1] + end + max = @__max_retries || MAX_ATTEMPTS - return if attempts > max + return __cache_retry_in(attempts, nil) if attempts > max interval = retry_interval(exception, attempt: attempts) - return if !interval + return __cache_retry_in(attempts, nil) if !interval unless interval.is_a?(Numeric) Rage.logger.warn("#{name}.retry_interval returned #{interval.class}, expected Numeric, false, or nil; falling back to default backoff") - return __default_backoff(attempts) + return __cache_retry_in(attempts, __default_backoff(attempts)) end - interval + __cache_retry_in(attempts, interval) + end + + # @private + def __cache_retry_in(attempts, value) + Fiber[RETRY_IN_CACHE_KEY] = [attempts, value] + value end # @private diff --git a/spec/deferred/metadata_spec.rb b/spec/deferred/metadata_spec.rb index a3cc9ac8..7ea3cb42 100644 --- a/spec/deferred/metadata_spec.rb +++ b/spec/deferred/metadata_spec.rb @@ -79,4 +79,20 @@ expect(subject.will_retry?).to eq(false) end end + + describe ".will_retry_in" do + before do + Rage::Deferred::Context.inc_attempts(context) + end + + it "returns the retry interval when retries remain" do + expect(task).to receive(:__next_retry_in).with(2, nil).and_return(15) + expect(subject.will_retry_in).to eq(15) + end + + it "returns nil when no retries remain" do + expect(task).to receive(:__next_retry_in).with(2, nil).and_return(nil) + expect(subject.will_retry_in).to be_nil + end + end end diff --git a/spec/deferred/task_spec.rb b/spec/deferred/task_spec.rb index 56de1dcf..0310e56c 100644 --- a/spec/deferred/task_spec.rb +++ b/spec/deferred/task_spec.rb @@ -14,6 +14,10 @@ def perform(arg, kwarg:) stub_const("MyTask", task_class) end + after do + Fiber[:__rage_deferred_retry_in] = nil + end + describe ".enqueue" do let(:queue) { instance_double(Rage::Deferred::Queue, enqueue: true) } let(:context) { { class: "MyTask" } } @@ -66,6 +70,19 @@ def perform(arg, kwarg:) expect(task_class.__next_retry_in(20, nil)).to be_a(Numeric) expect(task_class.__next_retry_in(21, nil)).to be_nil end + + it "returns the same value on repeated calls with the same attempts" do + first = task_class.__next_retry_in(2, nil) + second = task_class.__next_retry_in(2, nil) + expect(first).to eq(second) + end + + it "returns different values for different attempts" do + val_at_0 = task_class.__next_retry_in(0, nil) + val_at_4 = task_class.__next_retry_in(4, nil) + expect(val_at_0).to be_a(Numeric) + expect(val_at_4).to be_a(Numeric) + end end describe ".max_retries" do @@ -124,7 +141,7 @@ def perform(arg, kwarg:) it "returns an interval for any attempt" do interval = task_class.retry_interval(StandardError.new, attempt: 1) expect(interval).to be_a(Integer) - expect(interval).to be >= 1 + expect(interval).to be_between(11, 25) end it "always returns a backoff (max check is in __next_retry_in)" do @@ -164,7 +181,7 @@ def perform(arg, kwarg:) it "falls back to default for unmatched exception" do interval = task_class.retry_interval(StandardError.new, attempt: 1) expect(interval).to be_a(Integer) - expect(interval).to be >= 1 + expect(interval).to be_between(11, 25) end it "__next_retry_in returns interval for retryable" do @@ -226,7 +243,7 @@ def perform(arg, kwarg:) task_class.define_singleton_method(:retry_interval) { |_exception, attempt:| "invalid" } result = task_class.__next_retry_in(1, StandardError.new) expect(result).to be_a(Numeric) - expect(result).to be >= 1 + expect(result).to be_between(11, 25) expect(logger).to have_received(:warn).with(/returned String, expected Numeric/) end @@ -234,7 +251,7 @@ def perform(arg, kwarg:) task_class.define_singleton_method(:retry_interval) { |_exception, attempt:| [10] } result = task_class.__next_retry_in(1, StandardError.new) expect(result).to be_a(Numeric) - expect(result).to be >= 1 + expect(result).to be_between(11, 25) expect(logger).to have_received(:warn).with(/returned Array, expected Numeric/) end end