Module: ChronoForge::Executor::Methods::WaitUntil

Included in:
ChronoForge::Executor::Methods
Defined in:
lib/chrono_forge/executor/methods/wait_until.rb

Instance Method Summary collapse

Instance Method Details

#wait_until(condition, timeout: 1.hour, check_interval: 15.minutes, retry_on: []) ⇒ true

Waits until a specified condition becomes true, with configurable timeout and polling interval.

This method provides durable waiting behavior that can survive workflow restarts and delays. It periodically checks a condition method until it returns true or a timeout is reached. The waiting state is persisted, making it resilient to system interruptions.

Behavior

Condition Evaluation

The condition method is called on each check interval:

  • Should return truthy value when condition is met

  • Should return falsy value when condition is not yet met

  • Can raise exceptions that will be handled based on retry_on parameter

Timeout Handling

  • Timeout is calculated from the first execution start time

  • When timeout is reached, WaitConditionNotMet exception is raised

  • Timeout checking happens before each condition evaluation

Error Handling

  • Exceptions during condition evaluation are caught and logged

  • If exception class is in retry_on array, it triggers retry with exponential backoff

  • Other exceptions cause immediate failure with ExecutionFailedError

  • Retry backoff: 2^attempt seconds (capped at 2^5 = 32 seconds)

Persistence and Resumability

  • Wait state is persisted in execution logs with metadata

  • Workflow can be stopped/restarted without losing wait progress

  • Timeout calculation persists across restarts

  • Check intervals are maintained even after system interruptions

Execution Logs

Creates execution log with step name: ‘wait_until$#condition`

  • Stores timeout deadline and check interval in metadata

  • Tracks attempt count and execution times

  • Records final result (true for success, :timed_out for timeout)

Examples:

Basic usage

wait_until :payment_confirmed?

With custom timeout and check interval

wait_until :external_api_ready?, timeout: 30.minutes, check_interval: 1.minute

With retry on specific errors

wait_until :database_migration_complete?,
  timeout: 2.hours,
  check_interval: 30.seconds,
  retry_on: [ActiveRecord::ConnectionNotEstablished, Net::TimeoutError]

Waiting for external system

def third_party_service_ready?
  response = HTTParty.get("https://api.example.com/health")
  response.code == 200 && response.body.include?("healthy")
end

wait_until :third_party_service_ready?,
  timeout: 1.hour,
  check_interval: 2.minutes,
  retry_on: [Net::TimeoutError, Net::HTTPClientException]

Waiting for file processing

def file_processing_complete?
  job_status = ProcessingJobStatus.find_by(file_id: @file_id)
  job_status&.completed? || false
end

wait_until :file_processing_complete?,
  timeout: 45.minutes,
  check_interval: 30.seconds

Parameters:

  • condition (Symbol)

    The name of the instance method to evaluate as the condition. The method should return a truthy value when the condition is met.

  • timeout (ActiveSupport::Duration) (defaults to: 1.hour)

    Maximum time to wait for condition (default: 1.hour)

  • check_interval (ActiveSupport::Duration) (defaults to: 15.minutes)

    Time between condition checks (default: 15.minutes)

  • retry_on (Array<Class>) (defaults to: [])

    Exception classes that should trigger retries instead of failures

Returns:

  • (true)

    When the condition is met

Raises:



88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
# File 'lib/chrono_forge/executor/methods/wait_until.rb', line 88

def wait_until(condition, timeout: 1.hour, check_interval: 15.minutes, retry_on: [])
  step_name = "wait_until$#{condition}"
  # Find or create execution log
  execution_log = ExecutionLog.create_or_find_by!(
    workflow: @workflow,
    step_name: step_name
  ) do |log|
    log.started_at = Time.current
    log. = {
      timeout_at: timeout.from_now,
      check_interval: check_interval
    }
  end

  # Return if already completed
  if execution_log.completed?
    return execution_log.["result"]
  end

  # Evaluate condition
  begin
    execution_log.update!(
      attempts: execution_log.attempts + 1,
      last_executed_at: Time.current
    )

    condition_met = send(condition)
  rescue HaltExecutionFlow
    raise
  rescue => e
    # Log the error
    Rails.logger.error { "Error evaluating condition #{condition}: #{e.message}" }
    self.class::ExecutionTracker.track_error(workflow, e)

    # Optional retry logic
    if retry_on.include?(e.class)
      # Reschedule with exponential backoff
      backoff = (2**[execution_log.attempts, 5].min).seconds

      self.class
        .set(wait: backoff)
        .perform_later(
          @workflow.key
        )

      # Halt current execution
      halt_execution!
    else
      execution_log.update!(
        state: :failed,
        error_message: e.message,
        error_class: e.class.name
      )
      raise ExecutionFailedError, "#{step_name} failed with an error: #{e.message}"
    end
  end

  # Handle condition met
  if condition_met
    execution_log.update!(
      state: :completed,
      completed_at: Time.current,
      error_message: "Execution timed out",
      error_class: "TimeoutError",
      metadata: execution_log..merge("result" => true)
    )
    return true
  end

  # Check for timeout
   = execution_log.
  if Time.current > ["timeout_at"]
    execution_log.update!(
      state: :failed,
      metadata: .merge("result" => :timed_out)
    )
    Rails.logger.warn { "Timeout reached for condition '#{condition}'." }
    raise WaitConditionNotMet, "Condition '#{condition}' not met within timeout period"
  end

  # Reschedule with delay
  self.class
    .set(wait: check_interval)
    .perform_later(
      @workflow.key,
      wait_condition: condition
    )

  # Halt current execution
  halt_execution!
end