chrono_forge 0.5.1 → 0.7.0.rc1
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/README.md +270 -14
- data/examples/continue_if_webhook_example.rb +134 -0
- data/lib/chrono_forge/executor/methods/continue_if.rb +159 -0
- data/lib/chrono_forge/executor/methods/durably_execute.rb +63 -10
- data/lib/chrono_forge/executor/methods/durably_repeat.rb +298 -0
- data/lib/chrono_forge/executor/methods/wait.rb +74 -3
- data/lib/chrono_forge/executor/methods/wait_until.rb +92 -18
- data/lib/chrono_forge/executor/methods/workflow_states.rb +161 -0
- data/lib/chrono_forge/executor/methods.rb +2 -0
- data/lib/chrono_forge/version.rb +1 -1
- data/lib/chrono_forge/workflow.rb +2 -2
- metadata +7 -6
- data/export.json +0 -118
- data/export.rb +0 -48
@@ -2,9 +2,66 @@ module ChronoForge
|
|
2
2
|
module Executor
|
3
3
|
module Methods
|
4
4
|
module DurablyExecute
|
5
|
-
|
6
|
-
|
7
|
-
|
5
|
+
# Executes a method with automatic retry logic and durable execution tracking.
|
6
|
+
#
|
7
|
+
# This method provides fault-tolerant execution of instance methods with automatic
|
8
|
+
# retry on failure using exponential backoff. Each execution is tracked with its own
|
9
|
+
# execution log, ensuring idempotent behavior during workflow replays.
|
10
|
+
#
|
11
|
+
# @param method [Symbol] The name of the instance method to execute
|
12
|
+
# @param max_attempts [Integer] Maximum retry attempts before failing (default: 3)
|
13
|
+
# @param name [String, nil] Custom name for the execution step. Defaults to method name.
|
14
|
+
# Used to create unique step names for execution logs.
|
15
|
+
#
|
16
|
+
# @return [nil]
|
17
|
+
#
|
18
|
+
# @raise [ExecutionFailedError] When the method fails after max_attempts
|
19
|
+
#
|
20
|
+
# @example Basic usage
|
21
|
+
# durably_execute :send_welcome_email
|
22
|
+
#
|
23
|
+
# @example With custom retry attempts
|
24
|
+
# durably_execute :critical_payment_processing, max_attempts: 5
|
25
|
+
#
|
26
|
+
# @example With custom name for tracking
|
27
|
+
# durably_execute :complex_calculation, name: "phase_1_calculation"
|
28
|
+
#
|
29
|
+
# @example Method that might fail temporarily
|
30
|
+
# def upload_to_s3
|
31
|
+
# # This might fail due to network issues, rate limits, etc.
|
32
|
+
# S3Client.upload(file_path, bucket: 'my-bucket')
|
33
|
+
# Rails.logger.info "Successfully uploaded file to S3"
|
34
|
+
# end
|
35
|
+
#
|
36
|
+
# durably_execute :upload_to_s3, max_attempts: 5
|
37
|
+
#
|
38
|
+
# == Behavior
|
39
|
+
#
|
40
|
+
# === Idempotency
|
41
|
+
# Each execution gets a unique step name ensuring that workflow replays don't
|
42
|
+
# create duplicate executions. If a workflow is replayed and this step has
|
43
|
+
# already completed, it will be skipped.
|
44
|
+
#
|
45
|
+
# === Retry Logic
|
46
|
+
# - Failed executions are automatically retried with exponential backoff
|
47
|
+
# - Backoff calculation: 2^attempt seconds (capped at 2^5 = 32 seconds)
|
48
|
+
# - After max_attempts, ExecutionFailedError is raised
|
49
|
+
#
|
50
|
+
# === Error Handling
|
51
|
+
# - All exceptions except HaltExecutionFlow are caught and handled
|
52
|
+
# - Errors are logged and tracked in the execution log
|
53
|
+
# - ExecutionFailedError is raised after exhausting all retry attempts
|
54
|
+
# - HaltExecutionFlow exceptions are re-raised to allow workflow control flow
|
55
|
+
#
|
56
|
+
# === Execution Logs
|
57
|
+
# Creates execution log with step name: `durably_execute$#{name || method}`
|
58
|
+
# - Tracks attempt count, execution times, and completion status
|
59
|
+
# - Stores error details when failures occur
|
60
|
+
# - Enables monitoring and debugging of execution history
|
61
|
+
#
|
62
|
+
def durably_execute(method, max_attempts: 3, name: nil)
|
63
|
+
step_name = "durably_execute$#{name || method}"
|
64
|
+
# Find or create execution log
|
8
65
|
execution_log = ExecutionLog.create_or_find_by!(
|
9
66
|
workflow: @workflow,
|
10
67
|
step_name: step_name
|
@@ -24,11 +81,7 @@ module ChronoForge
|
|
24
81
|
)
|
25
82
|
|
26
83
|
# Execute the method
|
27
|
-
|
28
|
-
send(method)
|
29
|
-
else
|
30
|
-
method.call(@context)
|
31
|
-
end
|
84
|
+
send(method)
|
32
85
|
|
33
86
|
# Complete the execution
|
34
87
|
execution_log.update!(
|
@@ -46,9 +99,9 @@ module ChronoForge
|
|
46
99
|
self.class::ExecutionTracker.track_error(workflow, e)
|
47
100
|
|
48
101
|
# Optional retry logic
|
49
|
-
if execution_log.attempts <
|
102
|
+
if execution_log.attempts < max_attempts
|
50
103
|
# Reschedule with exponential backoff
|
51
|
-
backoff = (2**[execution_log.attempts
|
104
|
+
backoff = (2**[execution_log.attempts, 5].min).seconds
|
52
105
|
|
53
106
|
self.class
|
54
107
|
.set(wait: backoff)
|
@@ -0,0 +1,298 @@
|
|
1
|
+
module ChronoForge
|
2
|
+
module Executor
|
3
|
+
module Methods
|
4
|
+
module DurablyRepeat
|
5
|
+
# Schedules a method to be called repeatedly at specified intervals until a condition is met.
|
6
|
+
#
|
7
|
+
# This method provides durable, idempotent periodic task execution with automatic catch-up
|
8
|
+
# for missed executions using timeout-based fast-forwarding. Each repetition gets its own
|
9
|
+
# execution log, ensuring proper tracking and retry behavior.
|
10
|
+
#
|
11
|
+
# @param method [Symbol] The name of the instance method to execute repeatedly.
|
12
|
+
# The method can optionally accept the scheduled execution time as its first argument.
|
13
|
+
# @param every [ActiveSupport::Duration] The interval between executions (e.g., 3.days, 1.hour)
|
14
|
+
# @param till [Symbol, Proc] The condition to check for stopping repetition. Should return
|
15
|
+
# true when repetition should stop. Can be a symbol for instance methods or a callable.
|
16
|
+
# @param start_at [Time, nil] When to start the periodic task. Defaults to coordination_log.created_at + every
|
17
|
+
# @param max_attempts [Integer] Maximum retry attempts per individual execution (default: 3)
|
18
|
+
# @param timeout [ActiveSupport::Duration] How long after scheduled time an execution is
|
19
|
+
# considered stale and skipped (default: 1.hour). This enables catch-up behavior.
|
20
|
+
# @param on_error [Symbol] How to handle repetition failures after max_attempts. Options:
|
21
|
+
# - :continue (default): Log failure and continue with next scheduled execution
|
22
|
+
# - :fail_workflow: Raise ExecutionFailedError to fail the entire workflow
|
23
|
+
# @param name [String, nil] Custom name for the periodic task. Defaults to method name.
|
24
|
+
# Used to create unique step names for execution logs.
|
25
|
+
#
|
26
|
+
# @return [nil]
|
27
|
+
#
|
28
|
+
# @example Basic usage
|
29
|
+
# durably_repeat :send_reminder_email, every: 3.days, till: :user_onboarded?
|
30
|
+
#
|
31
|
+
# @example Method with scheduled execution time parameter
|
32
|
+
# def send_reminder_email(next_execution_at)
|
33
|
+
# # Can access the scheduled execution time
|
34
|
+
# lateness = Time.current - next_execution_at
|
35
|
+
# Rails.logger.info "Email scheduled for #{next_execution_at}, running #{lateness.to_i}s late"
|
36
|
+
# UserMailer.reminder_email(user_id, scheduled_for: next_execution_at).deliver_now
|
37
|
+
# end
|
38
|
+
#
|
39
|
+
# durably_repeat :send_reminder_email, every: 3.days, till: :user_onboarded?
|
40
|
+
#
|
41
|
+
# @example Resilient background task (default)
|
42
|
+
# durably_repeat :cleanup_temp_files,
|
43
|
+
# every: 1.day,
|
44
|
+
# till: :cleanup_complete?,
|
45
|
+
# on_error: :continue
|
46
|
+
#
|
47
|
+
# @example Critical task that should fail workflow on error
|
48
|
+
# durably_repeat :process_payments,
|
49
|
+
# every: 1.hour,
|
50
|
+
# till: :all_payments_processed?,
|
51
|
+
# on_error: :fail_workflow
|
52
|
+
#
|
53
|
+
# @example Advanced usage with all options
|
54
|
+
# def generate_daily_report(scheduled_time)
|
55
|
+
# report_date = scheduled_time.to_date
|
56
|
+
# DailyReportService.new(date: report_date).generate
|
57
|
+
# end
|
58
|
+
#
|
59
|
+
# durably_repeat :generate_daily_report,
|
60
|
+
# every: 1.day,
|
61
|
+
# till: :reports_complete?,
|
62
|
+
# start_at: Date.tomorrow.beginning_of_day,
|
63
|
+
# max_attempts: 5,
|
64
|
+
# timeout: 2.hours,
|
65
|
+
# on_error: :fail_workflow,
|
66
|
+
# name: "daily_reports"
|
67
|
+
#
|
68
|
+
# == Behavior
|
69
|
+
#
|
70
|
+
# === Method Parameters
|
71
|
+
# Your periodic method can optionally receive the scheduled execution time:
|
72
|
+
# - Method with no parameters: `def my_task; end` - called as `my_task()`
|
73
|
+
# - Method with parameter: `def my_task(next_execution_at); end` - called as `my_task(scheduled_time)`
|
74
|
+
#
|
75
|
+
# This allows methods to:
|
76
|
+
# - Log lateness/timing information
|
77
|
+
# - Perform time-based calculations
|
78
|
+
# - Include scheduled time in notifications
|
79
|
+
# - Generate reports for specific time periods
|
80
|
+
#
|
81
|
+
# === Idempotency
|
82
|
+
# Each execution gets a unique step name based on the scheduled execution time, ensuring
|
83
|
+
# that workflow replays don't create duplicate tasks.
|
84
|
+
#
|
85
|
+
# === Catch-up Mechanism
|
86
|
+
# If a workflow is paused and resumes later, the timeout parameter handles catch-up:
|
87
|
+
# - Executions older than `timeout` are automatically skipped
|
88
|
+
# - The periodic schedule integrity is maintained
|
89
|
+
# - Eventually reaches current/future execution times
|
90
|
+
#
|
91
|
+
# === Error Handling
|
92
|
+
# - Individual execution failures are retried up to `max_attempts` with exponential backoff
|
93
|
+
# - After max attempts, behavior depends on `on_error` parameter:
|
94
|
+
# - `:continue`: Failed execution is logged, next execution is scheduled
|
95
|
+
# - `:fail_workflow`: ExecutionFailedError is raised, failing the entire workflow
|
96
|
+
# - Timeouts are not considered errors and always continue to the next execution
|
97
|
+
#
|
98
|
+
# === Execution Logs
|
99
|
+
# Creates two types of execution logs:
|
100
|
+
# - Coordination log: `durably_repeat$#{name}` - tracks overall periodic task state
|
101
|
+
# - Repetition logs: `durably_repeat$#{name}$#{timestamp}` - tracks individual executions
|
102
|
+
#
|
103
|
+
def durably_repeat(method, every:, till:, start_at: nil, max_attempts: 3, timeout: 1.hour, on_error: :continue, name: nil)
|
104
|
+
step_name = "durably_repeat$#{name || method}"
|
105
|
+
|
106
|
+
# Get or create the main coordination log for this periodic task
|
107
|
+
coordination_log = ExecutionLog.create_or_find_by!(
|
108
|
+
workflow: @workflow,
|
109
|
+
step_name: step_name
|
110
|
+
) do |log|
|
111
|
+
log.started_at = Time.current
|
112
|
+
log.metadata = {last_execution_at: nil}
|
113
|
+
end
|
114
|
+
|
115
|
+
# Return if already completed
|
116
|
+
return if coordination_log.completed?
|
117
|
+
|
118
|
+
# Update coordination log attempt tracking
|
119
|
+
coordination_log.update!(
|
120
|
+
attempts: coordination_log.attempts + 1,
|
121
|
+
last_executed_at: Time.current
|
122
|
+
)
|
123
|
+
|
124
|
+
# Check if we should stop repeating
|
125
|
+
condition_met = if till.is_a?(Symbol)
|
126
|
+
send(till)
|
127
|
+
else
|
128
|
+
till.call(context)
|
129
|
+
end
|
130
|
+
if condition_met
|
131
|
+
coordination_log.update!(
|
132
|
+
state: :completed,
|
133
|
+
completed_at: Time.current
|
134
|
+
)
|
135
|
+
return
|
136
|
+
end
|
137
|
+
|
138
|
+
# Calculate next execution time
|
139
|
+
metadata = coordination_log.metadata
|
140
|
+
last_execution_at = metadata["last_execution_at"] ? Time.parse(metadata["last_execution_at"]) : nil
|
141
|
+
|
142
|
+
next_execution_at = if last_execution_at
|
143
|
+
last_execution_at + every
|
144
|
+
elsif start_at
|
145
|
+
start_at
|
146
|
+
else
|
147
|
+
coordination_log.created_at + every
|
148
|
+
end
|
149
|
+
|
150
|
+
execute_or_schedule_repetition(method, coordination_log, next_execution_at, every, max_attempts, timeout, on_error)
|
151
|
+
nil
|
152
|
+
end
|
153
|
+
|
154
|
+
private
|
155
|
+
|
156
|
+
def execute_or_schedule_repetition(method, coordination_log, next_execution_at, every, max_attempts, timeout, on_error)
|
157
|
+
step_name = "#{coordination_log.step_name}$#{next_execution_at.to_i}"
|
158
|
+
|
159
|
+
# Create execution log for this specific repetition
|
160
|
+
repetition_log = ExecutionLog.create_or_find_by!(
|
161
|
+
workflow: @workflow,
|
162
|
+
step_name: step_name
|
163
|
+
) do |log|
|
164
|
+
log.started_at = Time.current
|
165
|
+
log.metadata = {
|
166
|
+
scheduled_for: next_execution_at,
|
167
|
+
timeout_at: next_execution_at + timeout,
|
168
|
+
parent_id: coordination_log.id
|
169
|
+
}
|
170
|
+
end
|
171
|
+
|
172
|
+
# Return if this repetition is already completed
|
173
|
+
return if repetition_log.completed?
|
174
|
+
|
175
|
+
# Update execution log with attempt
|
176
|
+
repetition_log.update!(
|
177
|
+
attempts: repetition_log.attempts + 1,
|
178
|
+
last_executed_at: Time.current
|
179
|
+
)
|
180
|
+
|
181
|
+
# Check if it's time to execute this repetition
|
182
|
+
if next_execution_at <= Time.current
|
183
|
+
execute_repetition_now(method, repetition_log, coordination_log, next_execution_at, every, max_attempts, timeout, on_error)
|
184
|
+
else
|
185
|
+
schedule_repetition_for_later(repetition_log, next_execution_at)
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
def schedule_repetition_for_later(repetition_log, next_execution_at)
|
190
|
+
# Calculate delay until execution time
|
191
|
+
delay = [next_execution_at - Time.current, 0].max.seconds
|
192
|
+
|
193
|
+
# Schedule the workflow to run at the specified time
|
194
|
+
self.class
|
195
|
+
.set(wait: delay)
|
196
|
+
.perform_later(@workflow.key)
|
197
|
+
|
198
|
+
# Halt current execution until scheduled time
|
199
|
+
halt_execution!
|
200
|
+
end
|
201
|
+
|
202
|
+
def execute_repetition_now(method, repetition_log, coordination_log, execution_time, every, max_attempts, timeout, on_error)
|
203
|
+
# Check for timeout
|
204
|
+
if Time.current > repetition_log.metadata["timeout_at"]
|
205
|
+
repetition_log.update!(
|
206
|
+
state: :failed,
|
207
|
+
error_message: "Execution timed out",
|
208
|
+
error_class: "TimeoutError"
|
209
|
+
)
|
210
|
+
|
211
|
+
# Timeouts are part of the catch-up mechanism, always continue to next execution
|
212
|
+
schedule_next_execution_after_completion(coordination_log, execution_time, every)
|
213
|
+
return
|
214
|
+
end
|
215
|
+
|
216
|
+
execute_periodic_method(method, execution_time)
|
217
|
+
repetition_log.update!(
|
218
|
+
state: :completed,
|
219
|
+
completed_at: Time.current
|
220
|
+
)
|
221
|
+
|
222
|
+
# Update coordination log with successful execution and schedule next
|
223
|
+
coordination_log.update!(
|
224
|
+
metadata: coordination_log.metadata.merge(
|
225
|
+
"last_execution_at" => execution_time.iso8601
|
226
|
+
),
|
227
|
+
last_executed_at: Time.current
|
228
|
+
)
|
229
|
+
|
230
|
+
schedule_next_execution_after_completion(coordination_log, execution_time, every)
|
231
|
+
rescue HaltExecutionFlow
|
232
|
+
raise
|
233
|
+
rescue => e
|
234
|
+
# Log the error
|
235
|
+
Rails.logger.error { "Error in periodic task #{method}: #{e.message}" }
|
236
|
+
self.class::ExecutionTracker.track_error(@workflow, e)
|
237
|
+
|
238
|
+
# Handle retry logic for this specific repetition
|
239
|
+
if repetition_log.attempts < max_attempts
|
240
|
+
# Reschedule this same repetition with exponential backoff
|
241
|
+
backoff = (2**[repetition_log.attempts, 5].min).seconds
|
242
|
+
|
243
|
+
self.class
|
244
|
+
.set(wait: backoff)
|
245
|
+
.perform_later(@workflow.key)
|
246
|
+
|
247
|
+
# Halt current execution
|
248
|
+
halt_execution!
|
249
|
+
else
|
250
|
+
# Max attempts reached for this repetition
|
251
|
+
repetition_log.update!(
|
252
|
+
state: :failed,
|
253
|
+
error_message: e.message,
|
254
|
+
error_class: e.class.name
|
255
|
+
)
|
256
|
+
|
257
|
+
# Handle failure based on on_error setting
|
258
|
+
if on_error == :fail_workflow
|
259
|
+
raise ExecutionFailedError, "Periodic task #{method} failed after #{max_attempts} attempts: #{e.message}"
|
260
|
+
else
|
261
|
+
# Continue with next execution despite this failure
|
262
|
+
schedule_next_execution_after_completion(coordination_log, execution_time, every)
|
263
|
+
end
|
264
|
+
end
|
265
|
+
end
|
266
|
+
|
267
|
+
def execute_periodic_method(method, next_execution_at)
|
268
|
+
# Check if the method accepts an argument by looking at its arity
|
269
|
+
method_obj = self.method(method)
|
270
|
+
|
271
|
+
if method_obj.arity != 0
|
272
|
+
# Method accepts arguments (either required or optional), pass next_execution_at
|
273
|
+
send(method, next_execution_at)
|
274
|
+
else
|
275
|
+
# Method takes no arguments
|
276
|
+
send(method)
|
277
|
+
end
|
278
|
+
end
|
279
|
+
|
280
|
+
def schedule_next_execution_after_completion(coordination_log, current_execution_time, every)
|
281
|
+
# Calculate next execution time
|
282
|
+
next_execution_time = current_execution_time + every
|
283
|
+
|
284
|
+
# Calculate delay until next execution
|
285
|
+
delay = [next_execution_time - Time.current, 0].max.seconds
|
286
|
+
|
287
|
+
# Schedule the workflow to run for the next periodic execution
|
288
|
+
self.class
|
289
|
+
.set(wait: delay)
|
290
|
+
.perform_later(@workflow.key)
|
291
|
+
|
292
|
+
# Halt current execution
|
293
|
+
halt_execution!
|
294
|
+
end
|
295
|
+
end
|
296
|
+
end
|
297
|
+
end
|
298
|
+
end
|
@@ -2,11 +2,82 @@ module ChronoForge
|
|
2
2
|
module Executor
|
3
3
|
module Methods
|
4
4
|
module Wait
|
5
|
-
|
6
|
-
|
5
|
+
# Pauses workflow execution for a specified duration.
|
6
|
+
#
|
7
|
+
# This method provides durable waiting that persists across workflow restarts and
|
8
|
+
# system interruptions. The wait duration and completion state are tracked in
|
9
|
+
# execution logs, ensuring that workflows can resume properly after delays.
|
10
|
+
#
|
11
|
+
# @param duration [ActiveSupport::Duration] How long to wait (e.g., 5.minutes, 2.hours, 1.day)
|
12
|
+
# @param name [String] A unique name for this wait step, used for tracking and idempotency
|
13
|
+
#
|
14
|
+
# @return [nil]
|
15
|
+
#
|
16
|
+
# @example Basic usage
|
17
|
+
# wait 30.minutes, "cool_down_period"
|
18
|
+
#
|
19
|
+
# @example Waiting between API calls
|
20
|
+
# wait 5.seconds, "rate_limit_delay"
|
21
|
+
#
|
22
|
+
# @example Daily processing delay
|
23
|
+
# wait 1.day, "daily_batch_interval"
|
24
|
+
#
|
25
|
+
# @example Workflow with multiple wait steps
|
26
|
+
# def process_user_onboarding
|
27
|
+
# send_welcome_email
|
28
|
+
# wait 1.hour, "welcome_email_delay"
|
29
|
+
#
|
30
|
+
# send_tutorial_email
|
31
|
+
# wait 1.day, "tutorial_followup_delay"
|
32
|
+
#
|
33
|
+
# send_feedback_request
|
34
|
+
# end
|
35
|
+
#
|
36
|
+
# @example Waiting for external system processing
|
37
|
+
# def handle_payment_processing
|
38
|
+
# initiate_payment_request
|
39
|
+
#
|
40
|
+
# # Give payment processor time to handle the request
|
41
|
+
# wait 10.minutes, "payment_processing_window"
|
42
|
+
#
|
43
|
+
# check_payment_status
|
44
|
+
# end
|
45
|
+
#
|
46
|
+
# == Behavior
|
47
|
+
#
|
48
|
+
# === Duration Handling
|
49
|
+
# - Accepts any ActiveSupport::Duration (seconds, minutes, hours, days, etc.)
|
50
|
+
# - Wait time is calculated from the first execution attempt
|
51
|
+
# - Completion is checked against the originally scheduled end time
|
52
|
+
#
|
53
|
+
# === Idempotency
|
54
|
+
# - Each wait step must have a unique name within the workflow
|
55
|
+
# - If workflow is replayed after the wait period has passed, the step is skipped
|
56
|
+
# - Wait periods are not recalculated on workflow restarts
|
57
|
+
#
|
58
|
+
# === Resumability
|
59
|
+
# - Wait state is persisted in execution logs with target end time
|
60
|
+
# - Workflows can be stopped and restarted without affecting wait behavior
|
61
|
+
# - System restarts don't reset or extend wait periods
|
62
|
+
# - Scheduled execution resumes automatically when wait period completes
|
63
|
+
#
|
64
|
+
# === Scheduling
|
65
|
+
# - Uses background job scheduling to resume workflow after wait period
|
66
|
+
# - Halts current workflow execution until scheduled time
|
67
|
+
# - Automatically reschedules if workflow is replayed before wait completion
|
68
|
+
#
|
69
|
+
# === Execution Logs
|
70
|
+
# Creates execution log with step name: `wait$#{name}`
|
71
|
+
# - Stores target end time in metadata as "wait_until"
|
72
|
+
# - Tracks attempt count and execution times
|
73
|
+
# - Marks as completed when wait period has elapsed
|
74
|
+
#
|
75
|
+
def wait(duration, name)
|
76
|
+
step_name = "wait$#{name}"
|
77
|
+
# Find or create execution log
|
7
78
|
execution_log = ExecutionLog.create_or_find_by!(
|
8
79
|
workflow: @workflow,
|
9
|
-
step_name:
|
80
|
+
step_name: step_name
|
10
81
|
) do |log|
|
11
82
|
log.started_at = Time.current
|
12
83
|
log.metadata = {
|
@@ -4,13 +4,92 @@ module ChronoForge
|
|
4
4
|
|
5
5
|
module Methods
|
6
6
|
module WaitUntil
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
7
|
+
# Waits until a specified condition becomes true, with configurable timeout and polling interval.
|
8
|
+
#
|
9
|
+
# This method provides durable waiting behavior that can survive workflow restarts and delays.
|
10
|
+
# It periodically checks a condition method until it returns true or a timeout is reached.
|
11
|
+
# The waiting state is persisted, making it resilient to system interruptions.
|
12
|
+
#
|
13
|
+
# @param condition [Symbol] The name of the instance method to evaluate as the condition.
|
14
|
+
# The method should return a truthy value when the condition is met.
|
15
|
+
# @param timeout [ActiveSupport::Duration] Maximum time to wait for condition (default: 1.hour)
|
16
|
+
# @param check_interval [ActiveSupport::Duration] Time between condition checks (default: 15.minutes)
|
17
|
+
# @param retry_on [Array<Class>] Exception classes that should trigger retries instead of failures
|
18
|
+
#
|
19
|
+
# @return [true] When the condition is met
|
20
|
+
#
|
21
|
+
# @raise [WaitConditionNotMet] When timeout is reached before condition is met
|
22
|
+
# @raise [ExecutionFailedError] When condition evaluation fails with non-retryable error
|
23
|
+
#
|
24
|
+
# @example Basic usage
|
25
|
+
# wait_until :payment_confirmed?
|
26
|
+
#
|
27
|
+
# @example With custom timeout and check interval
|
28
|
+
# wait_until :external_api_ready?, timeout: 30.minutes, check_interval: 1.minute
|
29
|
+
#
|
30
|
+
# @example With retry on specific errors
|
31
|
+
# wait_until :database_migration_complete?,
|
32
|
+
# timeout: 2.hours,
|
33
|
+
# check_interval: 30.seconds,
|
34
|
+
# retry_on: [ActiveRecord::ConnectionNotEstablished, Net::TimeoutError]
|
35
|
+
#
|
36
|
+
# @example Waiting for external system
|
37
|
+
# def third_party_service_ready?
|
38
|
+
# response = HTTParty.get("https://api.example.com/health")
|
39
|
+
# response.code == 200 && response.body.include?("healthy")
|
40
|
+
# rescue Net::TimeoutError
|
41
|
+
# false # Will be retried at next check interval
|
42
|
+
# end
|
43
|
+
#
|
44
|
+
# wait_until :third_party_service_ready?,
|
45
|
+
# timeout: 1.hour,
|
46
|
+
# check_interval: 2.minutes,
|
47
|
+
# retry_on: [Net::TimeoutError, Net::HTTPClientException]
|
48
|
+
#
|
49
|
+
# @example Waiting for file processing
|
50
|
+
# def file_processing_complete?
|
51
|
+
# job_status = ProcessingJobStatus.find_by(file_id: @file_id)
|
52
|
+
# job_status&.completed? || false
|
53
|
+
# end
|
54
|
+
#
|
55
|
+
# wait_until :file_processing_complete?,
|
56
|
+
# timeout: 45.minutes,
|
57
|
+
# check_interval: 30.seconds
|
58
|
+
#
|
59
|
+
# == Behavior
|
60
|
+
#
|
61
|
+
# === Condition Evaluation
|
62
|
+
# The condition method is called on each check interval:
|
63
|
+
# - Should return truthy value when condition is met
|
64
|
+
# - Should return falsy value when condition is not yet met
|
65
|
+
# - Can raise exceptions that will be handled based on retry_on parameter
|
66
|
+
#
|
67
|
+
# === Timeout Handling
|
68
|
+
# - Timeout is calculated from the first execution start time
|
69
|
+
# - When timeout is reached, WaitConditionNotMet exception is raised
|
70
|
+
# - Timeout checking happens before each condition evaluation
|
71
|
+
#
|
72
|
+
# === Error Handling
|
73
|
+
# - Exceptions during condition evaluation are caught and logged
|
74
|
+
# - If exception class is in retry_on array, it triggers retry with exponential backoff
|
75
|
+
# - Other exceptions cause immediate failure with ExecutionFailedError
|
76
|
+
# - Retry backoff: 2^attempt seconds (capped at 2^5 = 32 seconds)
|
77
|
+
#
|
78
|
+
# === Persistence and Resumability
|
79
|
+
# - Wait state is persisted in execution logs with metadata
|
80
|
+
# - Workflow can be stopped/restarted without losing wait progress
|
81
|
+
# - Timeout calculation persists across restarts
|
82
|
+
# - Check intervals are maintained even after system interruptions
|
83
|
+
#
|
84
|
+
# === Execution Logs
|
85
|
+
# Creates execution log with step name: `wait_until$#{condition}`
|
86
|
+
# - Stores timeout deadline and check interval in metadata
|
87
|
+
# - Tracks attempt count and execution times
|
88
|
+
# - Records final result (true for success, :timed_out for timeout)
|
89
|
+
#
|
90
|
+
def wait_until(condition, timeout: 1.hour, check_interval: 15.minutes, retry_on: [])
|
13
91
|
step_name = "wait_until$#{condition}"
|
92
|
+
# Find or create execution log
|
14
93
|
execution_log = ExecutionLog.create_or_find_by!(
|
15
94
|
workflow: @workflow,
|
16
95
|
step_name: step_name
|
@@ -18,8 +97,7 @@ module ChronoForge
|
|
18
97
|
log.started_at = Time.current
|
19
98
|
log.metadata = {
|
20
99
|
timeout_at: timeout.from_now,
|
21
|
-
check_interval: check_interval
|
22
|
-
condition: condition.to_s
|
100
|
+
check_interval: check_interval
|
23
101
|
}
|
24
102
|
end
|
25
103
|
|
@@ -35,13 +113,7 @@ module ChronoForge
|
|
35
113
|
last_executed_at: Time.current
|
36
114
|
)
|
37
115
|
|
38
|
-
condition_met =
|
39
|
-
condition.call(@context)
|
40
|
-
elsif condition.is_a?(Symbol)
|
41
|
-
send(condition)
|
42
|
-
else
|
43
|
-
raise ArgumentError, "Unsupported condition type"
|
44
|
-
end
|
116
|
+
condition_met = send(condition)
|
45
117
|
rescue HaltExecutionFlow
|
46
118
|
raise
|
47
119
|
rescue => e
|
@@ -50,9 +122,9 @@ module ChronoForge
|
|
50
122
|
self.class::ExecutionTracker.track_error(workflow, e)
|
51
123
|
|
52
124
|
# Optional retry logic
|
53
|
-
if
|
125
|
+
if retry_on.include?(e.class)
|
54
126
|
# Reschedule with exponential backoff
|
55
|
-
backoff = (2**[execution_log.attempts
|
127
|
+
backoff = (2**[execution_log.attempts, 5].min).seconds
|
56
128
|
|
57
129
|
self.class
|
58
130
|
.set(wait: backoff)
|
@@ -77,6 +149,8 @@ module ChronoForge
|
|
77
149
|
execution_log.update!(
|
78
150
|
state: :completed,
|
79
151
|
completed_at: Time.current,
|
152
|
+
error_message: "Execution timed out",
|
153
|
+
error_class: "TimeoutError",
|
80
154
|
metadata: execution_log.metadata.merge("result" => true)
|
81
155
|
)
|
82
156
|
return true
|
@@ -87,7 +161,7 @@ module ChronoForge
|
|
87
161
|
if Time.current > metadata["timeout_at"]
|
88
162
|
execution_log.update!(
|
89
163
|
state: :failed,
|
90
|
-
metadata: metadata.merge("result" =>
|
164
|
+
metadata: metadata.merge("result" => :timed_out)
|
91
165
|
)
|
92
166
|
Rails.logger.warn { "Timeout reached for condition '#{condition}'." }
|
93
167
|
raise WaitConditionNotMet, "Condition '#{condition}' not met within timeout period"
|