chrono_forge 0.6.0 → 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.
@@ -2,9 +2,66 @@ module ChronoForge
2
2
  module Executor
3
3
  module Methods
4
4
  module DurablyExecute
5
- def durably_execute(method, **options)
6
- # Create execution log
7
- step_name = "durably_execute$#{method}"
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
- if method.is_a?(Symbol)
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 < (options[:max_attempts] || 3)
102
+ if execution_log.attempts < max_attempts
50
103
  # Reschedule with exponential backoff
51
- backoff = (2**[execution_log.attempts || 1, 5].min).seconds
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
- def wait(duration, name, **options)
6
- # Create execution log
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: "wait$#{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
- def wait_until(condition, **options)
8
- # Default timeout and check interval
9
- timeout = options[:timeout] || 1.hour
10
- check_interval = options[:check_interval] || 15.minutes
11
-
12
- # Find or create execution log
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 = if condition.is_a?(Proc)
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 (options[:retry_on] || []).include?(e.class)
125
+ if retry_on.include?(e.class)
54
126
  # Reschedule with exponential backoff
55
- backoff = (2**[execution_log.attempts || 1, 5].min).seconds
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" => nil)
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"