chrono_forge 0.9.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -1
- data/README.md +390 -46
- data/Rakefile +4 -0
- data/cliff.toml +62 -0
- data/docs/design/per-child-commit-overhead.md +213 -0
- data/docs/fanout-scale-test.md +247 -0
- data/docs/superpowers/plans/2026-06-25-chrono_forge-dashboard.md +1748 -0
- data/docs/superpowers/plans/2026-06-25-chrono_forge-dashboard.md.tasks.json +17 -0
- data/docs/superpowers/plans/2026-06-25-composite-retry-policies.md +930 -0
- data/docs/superpowers/plans/2026-06-25-composite-retry-policies.md.tasks.json +54 -0
- data/docs/superpowers/plans/2026-06-25-reserved-kwarg-guard.md +241 -0
- data/docs/superpowers/plans/2026-06-25-reserved-kwarg-guard.md.tasks.json +12 -0
- data/docs/superpowers/plans/2026-06-26-branches-spawn-merge.md +1378 -0
- data/docs/superpowers/plans/2026-06-26-branches-spawn-merge.md.tasks.json +67 -0
- data/docs/superpowers/plans/2026-06-26-deferral-continuation-race-and-catchup.md +709 -0
- data/docs/superpowers/plans/2026-06-26-deferral-continuation-race-and-catchup.md.tasks.json +19 -0
- data/docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md +205 -0
- data/docs/superpowers/plans/2026-06-30-poller-rekick-and-eta-cadence.md.tasks.json +33 -0
- data/docs/superpowers/plans/2026-07-01-workflow-definition-dag.md +1373 -0
- data/docs/superpowers/plans/2026-07-01-workflow-definition-dag.md.tasks.json +68 -0
- data/docs/superpowers/specs/2026-06-03-unified-retry-policy-design.md +226 -0
- data/docs/superpowers/specs/2026-06-25-chrono_forge-dashboard-design.md +190 -0
- data/docs/superpowers/specs/2026-06-25-composite-retry-policies-design.md +228 -0
- data/docs/superpowers/specs/2026-06-25-reserved-kwarg-guard-design.md +169 -0
- data/docs/superpowers/specs/2026-06-25-spawn-merge-branches-design.md +468 -0
- data/docs/superpowers/specs/2026-06-26-dashboard-branch-view-design.md +142 -0
- data/docs/superpowers/specs/2026-06-26-deferral-continuation-race-and-catchup-design.md +265 -0
- data/docs/superpowers/specs/2026-07-01-workflow-definition-dag-design.md +203 -0
- data/lib/chrono_forge/branch_merge_job.rb +275 -0
- data/lib/chrono_forge/branch_probe.rb +70 -0
- data/lib/chrono_forge/cleanup.rb +6 -0
- data/lib/chrono_forge/configuration.rb +25 -0
- data/lib/chrono_forge/definition.rb +37 -0
- data/lib/chrono_forge/definition_analyzer.rb +501 -0
- data/lib/chrono_forge/execution_log.rb +6 -0
- data/lib/chrono_forge/executor/composite_retry_policy.rb +47 -0
- data/lib/chrono_forge/executor/context.rb +23 -0
- data/lib/chrono_forge/executor/lock_strategy.rb +10 -3
- data/lib/chrono_forge/executor/methods/branch.rb +185 -0
- data/lib/chrono_forge/executor/methods/continue_if.rb +15 -6
- data/lib/chrono_forge/executor/methods/durably_execute.rb +36 -26
- data/lib/chrono_forge/executor/methods/durably_repeat.rb +148 -39
- data/lib/chrono_forge/executor/methods/merge_branches.rb +84 -0
- data/lib/chrono_forge/executor/methods/wait.rb +2 -4
- data/lib/chrono_forge/executor/methods/wait_until.rb +25 -25
- data/lib/chrono_forge/executor/methods/workflow_states.rb +50 -46
- data/lib/chrono_forge/executor/methods.rb +2 -0
- data/lib/chrono_forge/executor/retry_policy.rb +111 -0
- data/lib/chrono_forge/executor.rb +241 -28
- data/lib/chrono_forge/version.rb +1 -1
- data/lib/chrono_forge/workflow.rb +10 -1
- data/lib/chrono_forge.rb +8 -0
- data/lib/generators/chrono_forge/migration_actions.rb +1 -0
- data/lib/generators/chrono_forge/templates/add_chrono_forge_parent_execution_log.rb +38 -0
- data/lib/tasks/release.rake +212 -0
- metadata +67 -4
- data/lib/chrono_forge/executor/retry_strategy.rb +0 -29
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
module ChronoForge
|
|
2
|
+
module Executor
|
|
3
|
+
module Methods
|
|
4
|
+
module MergeBranches
|
|
5
|
+
# Join one or more named branches. Separate from dispatch so branches run
|
|
6
|
+
# concurrently. Does one immediate check; if not done, hands off to the
|
|
7
|
+
# lightweight BranchMergeJob and halts (the heavy parent is not replayed
|
|
8
|
+
# per poll). Poll cadence is driven by estimated time-to-drain, clamped
|
|
9
|
+
# between min/max (see BranchMergeJob#reschedule_delay).
|
|
10
|
+
def merge_branches(*names, min_interval: 5.seconds, max_interval: 5.minutes)
|
|
11
|
+
names.each do |nm|
|
|
12
|
+
validate_step_name_segment!(nm) # rejects "$"
|
|
13
|
+
if nm.to_s.include?(",")
|
|
14
|
+
raise InvalidStepName,
|
|
15
|
+
"branch name may not contain ',' (reserved merge separator): #{nm.inspect}"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Validate cadence here, in the parent, so a misconfiguration fails at the
|
|
20
|
+
# call site instead of deep inside the poller — where the clamp to
|
|
21
|
+
# [min_interval, max_interval] would raise ArgumentError, a non-transient
|
|
22
|
+
# error that dead-letters BranchMergeJob and orphans the parent.
|
|
23
|
+
if min_interval > max_interval
|
|
24
|
+
raise ArgumentError,
|
|
25
|
+
"min_interval (#{min_interval}) must be <= max_interval (#{max_interval})"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
names = names.map(&:to_s).uniq
|
|
29
|
+
step_name = "merge$#{names.sort.join(",")}"
|
|
30
|
+
log = find_or_create_execution_log!(step_name) { |l| l.started_at = Time.current }
|
|
31
|
+
|
|
32
|
+
if log.completed?
|
|
33
|
+
# Already done — remove from registry so the completion gate does not
|
|
34
|
+
# see these as unmerged, then skip.
|
|
35
|
+
names.each { |nm| @open_branches&.delete(nm.to_s) }
|
|
36
|
+
return
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
branch_log_ids = names.map { |nm| open_branch!(nm)[:log_id] }
|
|
40
|
+
|
|
41
|
+
if branches_done?(branch_log_ids)
|
|
42
|
+
names.each { |nm| @open_branches&.delete(nm.to_s) }
|
|
43
|
+
log.update!(state: :completed, completed_at: Time.current)
|
|
44
|
+
return
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
enqueue_branch_merge_job(branch_log_ids, min_interval, max_interval)
|
|
48
|
+
halt_execution!
|
|
49
|
+
end
|
|
50
|
+
alias_method :merge_branch, :merge_branches
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def open_branch!(name)
|
|
55
|
+
(@open_branches || {}).fetch(name.to_s) do
|
|
56
|
+
raise UnknownBranchError, "no open branch named #{name.inspect} (open it with `branch #{name.inspect} do … end` first)"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def branches_done?(branch_log_ids)
|
|
61
|
+
branch_log_ids.all? { |id| BranchProbe.done?(id) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def enqueue_branch_merge_job(branch_log_ids, min_interval, max_interval)
|
|
65
|
+
# Mint a fresh fencing token and stamp it on each branch log under a row
|
|
66
|
+
# lock — the read-modify-write must not clobber a concurrent poll-state
|
|
67
|
+
# write from an in-flight poller. Rotating the token orphans any prior
|
|
68
|
+
# poller chain (its token no longer matches), so only the chain we enqueue
|
|
69
|
+
# below drives the merge. See BranchMergeJob#superseded?.
|
|
70
|
+
token = SecureRandom.uuid
|
|
71
|
+
ExecutionLog.where(id: branch_log_ids).find_each do |log|
|
|
72
|
+
log.with_lock do
|
|
73
|
+
log.update!(metadata: (log.metadata || {}).merge("poll_token" => token))
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
BranchMergeJob.perform_later(
|
|
77
|
+
@workflow.key, self.class.to_s, branch_log_ids,
|
|
78
|
+
min_interval.to_i, max_interval.to_i, token
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -102,10 +102,8 @@ module ChronoForge
|
|
|
102
102
|
last_executed_at: Time.current
|
|
103
103
|
)
|
|
104
104
|
|
|
105
|
-
#
|
|
106
|
-
|
|
107
|
-
.set(wait: duration)
|
|
108
|
-
.perform_later(@workflow.key)
|
|
105
|
+
# Record the reschedule; the executor publishes it after lock release.
|
|
106
|
+
enqueue_continuation(wait: duration)
|
|
109
107
|
|
|
110
108
|
# Halt current execution
|
|
111
109
|
halt_execution!
|
|
@@ -14,7 +14,11 @@ module ChronoForge
|
|
|
14
14
|
# The method should return a truthy value when the condition is met.
|
|
15
15
|
# @param timeout [ActiveSupport::Duration] Maximum time to wait for condition (default: 1.hour)
|
|
16
16
|
# @param check_interval [ActiveSupport::Duration] Time between condition checks (default: 15.minutes)
|
|
17
|
-
# @param
|
|
17
|
+
# @param retry_policy [RetryPolicy, nil] Policy governing errors raised *while
|
|
18
|
+
# evaluating the condition* (not the poll cadence). When nil, uses
|
|
19
|
+
# RetryPolicy.wait_default, which retries nothing — a raised condition fails
|
|
20
|
+
# fast. Pass a policy with `retry_on:` to opt specific errors into retrying.
|
|
21
|
+
# Note: unlike steps, wait_until does NOT inherit the class-level default.
|
|
18
22
|
#
|
|
19
23
|
# @return [true] When the condition is met
|
|
20
24
|
#
|
|
@@ -31,7 +35,7 @@ module ChronoForge
|
|
|
31
35
|
# wait_until :database_migration_complete?,
|
|
32
36
|
# timeout: 2.hours,
|
|
33
37
|
# check_interval: 30.seconds,
|
|
34
|
-
# retry_on: [ActiveRecord::ConnectionNotEstablished, Net::TimeoutError]
|
|
38
|
+
# retry_policy: RetryPolicy.new(retry_on: [ActiveRecord::ConnectionNotEstablished, Net::TimeoutError])
|
|
35
39
|
#
|
|
36
40
|
# @example Waiting for external system
|
|
37
41
|
# def third_party_service_ready?
|
|
@@ -42,7 +46,7 @@ module ChronoForge
|
|
|
42
46
|
# wait_until :third_party_service_ready?,
|
|
43
47
|
# timeout: 1.hour,
|
|
44
48
|
# check_interval: 2.minutes,
|
|
45
|
-
# retry_on: [Net::TimeoutError, Net::HTTPClientException]
|
|
49
|
+
# retry_policy: RetryPolicy.new(retry_on: [Net::TimeoutError, Net::HTTPClientException])
|
|
46
50
|
#
|
|
47
51
|
# @example Waiting for file processing
|
|
48
52
|
# def file_processing_complete?
|
|
@@ -60,7 +64,7 @@ module ChronoForge
|
|
|
60
64
|
# The condition method is called on each check interval:
|
|
61
65
|
# - Should return truthy value when condition is met
|
|
62
66
|
# - Should return falsy value when condition is not yet met
|
|
63
|
-
# - Can raise exceptions that will be handled based on
|
|
67
|
+
# - Can raise exceptions that will be handled based on the retry_policy
|
|
64
68
|
#
|
|
65
69
|
# === Timeout Handling
|
|
66
70
|
# - Timeout is calculated from the first execution start time
|
|
@@ -69,9 +73,10 @@ module ChronoForge
|
|
|
69
73
|
#
|
|
70
74
|
# === Error Handling
|
|
71
75
|
# - Exceptions during condition evaluation are caught and logged
|
|
72
|
-
# - If
|
|
73
|
-
#
|
|
74
|
-
# -
|
|
76
|
+
# - If the retry_policy deems the error retryable, it triggers a retry with the
|
|
77
|
+
# policy's backoff
|
|
78
|
+
# - Otherwise the error causes immediate failure with ExecutionFailedError
|
|
79
|
+
# - Backoff is governed by the resolved RetryPolicy
|
|
75
80
|
#
|
|
76
81
|
# === Persistence and Resumability
|
|
77
82
|
# - Wait state is persisted in execution logs with metadata
|
|
@@ -85,7 +90,8 @@ module ChronoForge
|
|
|
85
90
|
# - Tracks attempt count and execution times
|
|
86
91
|
# - Records final result (true for success, :timed_out for timeout)
|
|
87
92
|
#
|
|
88
|
-
def wait_until(condition, timeout: 1.hour, check_interval: 15.minutes,
|
|
93
|
+
def wait_until(condition, timeout: 1.hour, check_interval: 15.minutes, retry_policy: nil)
|
|
94
|
+
policy = wait_retry_policy(retry_policy)
|
|
89
95
|
validate_step_name_segment!(condition)
|
|
90
96
|
step_name = "wait_until$#{condition}"
|
|
91
97
|
# Find or create execution log
|
|
@@ -117,16 +123,15 @@ module ChronoForge
|
|
|
117
123
|
Rails.logger.error { "Error evaluating condition #{condition}: #{e.message}" }
|
|
118
124
|
self.class::ExecutionTracker.track_error(workflow, e, execution_log: execution_log)
|
|
119
125
|
|
|
120
|
-
# Optional retry logic
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
)
|
|
126
|
+
# Optional retry logic for errors raised while evaluating the
|
|
127
|
+
# condition. The poll cadence (check_interval/timeout) below is
|
|
128
|
+
# separate and unaffected by the retry policy.
|
|
129
|
+
backoff = policy.retry_backoff(e, attempts: execution_log.attempts) do |policy_key|
|
|
130
|
+
bump_retry_count!(execution_log, policy_key)
|
|
131
|
+
end
|
|
132
|
+
if backoff
|
|
133
|
+
# Reschedule with the policy's backoff (published after lock release).
|
|
134
|
+
enqueue_continuation(wait: backoff)
|
|
130
135
|
|
|
131
136
|
# Halt current execution
|
|
132
137
|
halt_execution!
|
|
@@ -167,13 +172,8 @@ module ChronoForge
|
|
|
167
172
|
raise error
|
|
168
173
|
end
|
|
169
174
|
|
|
170
|
-
# Reschedule
|
|
171
|
-
|
|
172
|
-
.set(wait: check_interval)
|
|
173
|
-
.perform_later(
|
|
174
|
-
@workflow.key,
|
|
175
|
-
wait_condition: condition
|
|
176
|
-
)
|
|
175
|
+
# Reschedule the poll (published after lock release).
|
|
176
|
+
enqueue_continuation(wait: check_interval, wait_condition: condition)
|
|
177
177
|
|
|
178
178
|
# Halt current execution
|
|
179
179
|
halt_execution!
|
|
@@ -48,38 +48,48 @@ module ChronoForge
|
|
|
48
48
|
# - Safe to call multiple times without side effects
|
|
49
49
|
#
|
|
50
50
|
def complete_workflow!
|
|
51
|
-
|
|
52
|
-
execution_log = find_or_create_execution_log!("$workflow_completion$") do |log|
|
|
53
|
-
log.started_at = Time.current
|
|
54
|
-
end
|
|
51
|
+
enforce_branch_joins!
|
|
55
52
|
|
|
53
|
+
# Completion is two writes with no external side effect between them: the
|
|
54
|
+
# workflow → :completed transition and the (born-completed) marker. Batch
|
|
55
|
+
# them in one transaction so a trivial child pays a single commit here,
|
|
56
|
+
# and write the marker in its terminal state in a single INSERT.
|
|
57
|
+
execution_log = nil
|
|
56
58
|
begin
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
# attempt-count basis, so there is no need for a separate pre-write.
|
|
63
|
-
execution_log.update!(
|
|
64
|
-
attempts: execution_log.attempts + 1,
|
|
65
|
-
last_executed_at: Time.current,
|
|
66
|
-
state: :completed,
|
|
67
|
-
completed_at: Time.current
|
|
68
|
-
)
|
|
59
|
+
ActiveRecord::Base.transaction do
|
|
60
|
+
workflow.completed_at = Time.current
|
|
61
|
+
workflow.completed!
|
|
62
|
+
execution_log = create_completed_execution_log!("$workflow_completion$")
|
|
63
|
+
end
|
|
69
64
|
|
|
70
65
|
# Return the execution log for tracking
|
|
71
66
|
execution_log
|
|
72
67
|
rescue => e
|
|
73
|
-
#
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
68
|
+
# The transaction rolled back (so the marker may be gone too). Re-find
|
|
69
|
+
# or recreate it and record the failure for observability, then re-raise.
|
|
70
|
+
# The workflow stays not-completed, so a resume retries completion.
|
|
71
|
+
log = find_or_create_execution_log!("$workflow_completion$") do |l|
|
|
72
|
+
l.started_at = Time.current
|
|
73
|
+
end
|
|
74
|
+
log.update!(state: :failed, error_message: e.message, error_class: e.class.name)
|
|
79
75
|
raise
|
|
80
76
|
end
|
|
81
77
|
end
|
|
82
78
|
|
|
79
|
+
# Every branch must be joined: automerge branches join inline at their
|
|
80
|
+
# block's close (removing themselves from @open_branches); explicitly
|
|
81
|
+
# awaited branches are removed by merge_branches. Anything still in
|
|
82
|
+
# @open_branches here was opened but never joined — fail fast.
|
|
83
|
+
def enforce_branch_joins!
|
|
84
|
+
leftover = (@open_branches || {}).keys
|
|
85
|
+
return if leftover.empty?
|
|
86
|
+
|
|
87
|
+
raise UnmergedBranchError,
|
|
88
|
+
"branch(es) #{leftover.join(", ")} were opened but never merged. " \
|
|
89
|
+
"Add `merge_branches #{leftover.map { |n| ":#{n}" }.join(", ")}` " \
|
|
90
|
+
"or open with `branch(..., automerge: true)`."
|
|
91
|
+
end
|
|
92
|
+
|
|
83
93
|
# Marks a workflow as failed due to an unrecoverable error.
|
|
84
94
|
#
|
|
85
95
|
# This method provides durable workflow failure tracking with proper state
|
|
@@ -137,36 +147,30 @@ module ChronoForge
|
|
|
137
147
|
# - Safe to call multiple times with same error_log
|
|
138
148
|
#
|
|
139
149
|
def fail_workflow!(error_log)
|
|
140
|
-
|
|
141
|
-
execution_log = find_or_create_execution_log!("$workflow_failure$#{error_log.id}") do |log|
|
|
142
|
-
log.started_at = Time.current
|
|
143
|
-
log.metadata = {
|
|
144
|
-
error_log_id: error_log.id
|
|
145
|
-
}
|
|
146
|
-
end
|
|
150
|
+
step_name = "$workflow_failure$#{error_log.id}"
|
|
147
151
|
|
|
152
|
+
# Mirror complete_workflow!: the workflow → :failed transition and the
|
|
153
|
+
# (born-completed) failure marker are batched in one transaction, and the
|
|
154
|
+
# marker is written in its terminal state in a single INSERT.
|
|
155
|
+
execution_log = nil
|
|
148
156
|
begin
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
attempts: execution_log.attempts + 1,
|
|
156
|
-
last_executed_at: Time.current,
|
|
157
|
-
state: :completed,
|
|
158
|
-
completed_at: Time.current
|
|
159
|
-
)
|
|
157
|
+
ActiveRecord::Base.transaction do
|
|
158
|
+
workflow.failed!
|
|
159
|
+
execution_log = create_completed_execution_log!(step_name) do |log|
|
|
160
|
+
log.metadata = {error_log_id: error_log.id}
|
|
161
|
+
end
|
|
162
|
+
end
|
|
160
163
|
|
|
161
164
|
# Return the execution log for tracking
|
|
162
165
|
execution_log
|
|
163
166
|
rescue => e
|
|
164
|
-
#
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
167
|
+
# The transaction rolled back; re-find/recreate the marker and record
|
|
168
|
+
# the failure for observability, then re-raise.
|
|
169
|
+
log = find_or_create_execution_log!(step_name) do |l|
|
|
170
|
+
l.started_at = Time.current
|
|
171
|
+
l.metadata = {error_log_id: error_log.id}
|
|
172
|
+
end
|
|
173
|
+
log.update!(state: :failed, error_message: e.message, error_class: e.class.name)
|
|
170
174
|
raise
|
|
171
175
|
end
|
|
172
176
|
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
module ChronoForge
|
|
2
|
+
module Executor
|
|
3
|
+
# A single, unified description of retry behavior shared by every retry site
|
|
4
|
+
# (workflow-level uncaught errors, durably_execute, durably_repeat, and
|
|
5
|
+
# wait_until's condition errors).
|
|
6
|
+
#
|
|
7
|
+
# It answers the only two questions a retry site ever asks:
|
|
8
|
+
# - retryable?(error, attempts) — should this failure be retried?
|
|
9
|
+
# - backoff_for(attempts) — how long until the next attempt?
|
|
10
|
+
#
|
|
11
|
+
# `attempts` is always the 1-based count of attempts made so far, *including*
|
|
12
|
+
# the one that just failed (matching ExecutionLog#attempts). So on the first
|
|
13
|
+
# failure `attempts == 1`.
|
|
14
|
+
class RetryPolicy
|
|
15
|
+
attr_reader :max_attempts, :base, :cap, :jitter, :retry_on
|
|
16
|
+
|
|
17
|
+
# @param max_attempts [Integer, nil] cap on total attempts; nil = no count
|
|
18
|
+
# cap (bounded elsewhere, e.g. wait_until's timeout)
|
|
19
|
+
# @param base [Numeric, ActiveSupport::Duration] delay of the first retry
|
|
20
|
+
# @param cap [Numeric, ActiveSupport::Duration] ceiling for a single delay
|
|
21
|
+
# @param jitter [Boolean] apply equal jitter to spread retries
|
|
22
|
+
# @param retry_on [Array<Class>, nil] nil = retry any StandardError;
|
|
23
|
+
# an array = retry only those classes (and subclasses); [] = retry nothing
|
|
24
|
+
def initialize(max_attempts: 3, base: 1, cap: 30, jitter: true, retry_on: nil)
|
|
25
|
+
@max_attempts = max_attempts
|
|
26
|
+
@base = base
|
|
27
|
+
@cap = cap
|
|
28
|
+
@jitter = jitter
|
|
29
|
+
@retry_on = retry_on
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def retryable?(error, attempts)
|
|
33
|
+
within_attempt_cap?(attempts) && retryable_error?(error)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Equal jitter: half the computed delay plus a random portion of the other
|
|
37
|
+
# half. Computed once at re-enqueue time and never persisted, so the
|
|
38
|
+
# randomness does not affect replay determinism.
|
|
39
|
+
def backoff_for(attempts)
|
|
40
|
+
exponent = [attempts - 1, 0].max
|
|
41
|
+
delay = [cap.to_f, base.to_f * (2**exponent)].min
|
|
42
|
+
delay = (delay / 2) + rand(0.0..(delay / 2)) if jitter
|
|
43
|
+
delay.seconds
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Public routing predicate: would this policy handle this error at all?
|
|
47
|
+
# (independent of the attempt cap). nil retry_on = any StandardError;
|
|
48
|
+
# [] = nothing; a list = those classes and their subclasses.
|
|
49
|
+
def matches?(error)
|
|
50
|
+
retryable_error?(error)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Single-call decision used by every retry site: the backoff Duration to
|
|
54
|
+
# retry, or nil to stop. A plain policy uses `attempts` and ignores any
|
|
55
|
+
# block (the block exists only so a CompositeRetryPolicy can supply a
|
|
56
|
+
# per-error count — see CompositeRetryPolicy#retry_backoff).
|
|
57
|
+
def retry_backoff(error, attempts:)
|
|
58
|
+
retryable?(error, attempts) ? backoff_for(attempts) : nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Stable per-policy identifier derived from the errors this policy
|
|
62
|
+
# *declares* (its retry_on), not the error thrown. Inside a composite this
|
|
63
|
+
# keys the policy's attempt budget, so the budget is shared across every
|
|
64
|
+
# class the policy lists (and their subclasses) and is independent of the
|
|
65
|
+
# policy's position — reordering the composite does not reset counts. A
|
|
66
|
+
# catch-all (retry_on: nil) keys "*".
|
|
67
|
+
def budget_key
|
|
68
|
+
retry_on.nil? ? "*" : retry_on.map(&:name).sort.join(",")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.step_default
|
|
72
|
+
new(max_attempts: 3, base: 1, cap: 30, jitter: true, retry_on: nil)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Workflow-level (uncaught) errors retry the whole workflow from the top
|
|
76
|
+
# (replaying completed steps). They cover two populations the default can't
|
|
77
|
+
# distinguish: transient infra blips — worth riding out — and deterministic
|
|
78
|
+
# bugs, where every replay is waste. 10 attempts gives a tolerant window of
|
|
79
|
+
# up to ~8.5 min (≈4 min typical, since equal jitter puts each wait in
|
|
80
|
+
# [d/2, d]) — enough for a DB failover or deploy restart — without dragging
|
|
81
|
+
# out the bug case; cap (600s / 10 min) bounds any single backoff and only
|
|
82
|
+
# binds if a caller configures more attempts.
|
|
83
|
+
def self.workflow_default
|
|
84
|
+
new(max_attempts: 10, base: 1, cap: 600, jitter: true, retry_on: nil)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.wait_default
|
|
88
|
+
new(max_attempts: nil, base: 1, cap: 30, jitter: true, retry_on: [])
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Build a composite policy from an ordered list of RetryPolicy objects.
|
|
92
|
+
def self.compose(*policies)
|
|
93
|
+
CompositeRetryPolicy.new(policies)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def within_attempt_cap?(attempts)
|
|
99
|
+
max_attempts.nil? || attempts < max_attempts
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def retryable_error?(error)
|
|
103
|
+
if retry_on.nil?
|
|
104
|
+
error.is_a?(StandardError)
|
|
105
|
+
else
|
|
106
|
+
retry_on.any? { |klass| error.is_a?(klass) }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|