Class: Tasker::StateMachine::StepStateMachine
- Inherits:
-
Object
- Object
- Tasker::StateMachine::StepStateMachine
- Extended by:
- Concerns::EventPublisher
- Includes:
- Statesman::Machine
- Defined in:
- lib/tasker/state_machine/step_state_machine.rb
Overview
StepStateMachine defines state transitions for workflow steps using Statesman
This state machine manages workflow step lifecycle states and integrates with the existing event system to provide declarative state management.
Constant Summary collapse
- TRANSITION_EVENT_MAP =
Frozen constant mapping state transitions to event names This provides O(1) lookup performance and ensures consistency
{ # Initial state transitions (from nil/initial) [nil, Constants::WorkflowStepStatuses::PENDING] => Constants::StepEvents::INITIALIZE_REQUESTED, [nil, Constants::WorkflowStepStatuses::IN_PROGRESS] => Constants::StepEvents::EXECUTION_REQUESTED, [nil, Constants::WorkflowStepStatuses::COMPLETE] => Constants::StepEvents::COMPLETED, [nil, Constants::WorkflowStepStatuses::ERROR] => Constants::StepEvents::FAILED, [nil, Constants::WorkflowStepStatuses::CANCELLED] => Constants::StepEvents::CANCELLED, [nil, Constants::WorkflowStepStatuses::RESOLVED_MANUALLY] => Constants::StepEvents::RESOLVED_MANUALLY, # Normal state transitions [Constants::WorkflowStepStatuses::PENDING, Constants::WorkflowStepStatuses::IN_PROGRESS] => Constants::StepEvents::EXECUTION_REQUESTED, [Constants::WorkflowStepStatuses::PENDING, Constants::WorkflowStepStatuses::ERROR] => Constants::StepEvents::FAILED, [Constants::WorkflowStepStatuses::PENDING, Constants::WorkflowStepStatuses::CANCELLED] => Constants::StepEvents::CANCELLED, [Constants::WorkflowStepStatuses::PENDING, Constants::WorkflowStepStatuses::RESOLVED_MANUALLY] => Constants::StepEvents::RESOLVED_MANUALLY, [Constants::WorkflowStepStatuses::IN_PROGRESS, Constants::WorkflowStepStatuses::COMPLETE] => Constants::StepEvents::COMPLETED, [Constants::WorkflowStepStatuses::IN_PROGRESS, Constants::WorkflowStepStatuses::ERROR] => Constants::StepEvents::FAILED, [Constants::WorkflowStepStatuses::IN_PROGRESS, Constants::WorkflowStepStatuses::CANCELLED] => Constants::StepEvents::CANCELLED, [Constants::WorkflowStepStatuses::ERROR, Constants::WorkflowStepStatuses::PENDING] => Constants::StepEvents::RETRY_REQUESTED, [Constants::WorkflowStepStatuses::ERROR, Constants::WorkflowStepStatuses::RESOLVED_MANUALLY] => Constants::StepEvents::RESOLVED_MANUALLY }.freeze
Class Method Summary collapse
-
.build_standardized_payload(_event_name, context) ⇒ Hash
Build standardized event payload with all expected keys (legacy fallback).
-
.determine_event_type_from_name(event_name) ⇒ Symbol
Determine event type from event name for EventPayloadBuilder.
-
.determine_transition_event_name(from_state, to_state) ⇒ String?
Determine the appropriate event name for a state transition using constant lookup.
-
.effective_current_state(step) ⇒ String
Get the effective current state, handling blank/empty states.
-
.extract_step_from_context(context) ⇒ WorkflowStep?
Extract step object from context for EventPayloadBuilder.
-
.idempotent_transition?(step, target_state) ⇒ Boolean
Check if a transition is idempotent (current state == target state).
-
.log_dependencies_not_met(step, target_state) ⇒ Object
Log when dependencies are not met.
-
.log_invalid_from_state(step, current_state, target_state, reason) ⇒ Object
Log an invalid from-state transition.
-
.log_transition_result(step, target_state, result, reason) ⇒ Object
Log the result of a transition check.
-
.safe_fire_event(event_name, context = {}) ⇒ void
Safely fire a lifecycle event using dry-events bus.
-
.step_dependencies_met?(step) ⇒ Boolean
Check if step dependencies are met.
Instance Method Summary collapse
-
#create_transition(from_state, to_state, metadata = {}) ⇒ Object
Override Statesman's transition building to ensure proper from_state handling This is called by Statesman when creating new transitions.
-
#current_state ⇒ Object
Override current_state to work with custom transition model Since WorkflowStepTransition doesn't include Statesman::Adapters::ActiveRecordTransition, we need to implement our own current_state logic using the most_recent column.
-
#initialize_state_machine! ⇒ Object
Initialize the state machine with the initial state This ensures the state machine is properly initialized when called explicitly DEFENSIVE: Only creates transitions when explicitly needed.
-
#next_sort_key_value ⇒ Object
Get the next sort key for transitions.
Methods included from Concerns::EventPublisher
infer_step_event_type_from_state, publish_custom_event, publish_no_viable_steps, publish_step_backoff, publish_step_before_handle, publish_step_cancelled, publish_step_completed, publish_step_event_for_context, publish_step_failed, publish_step_retry_requested, publish_step_started, publish_steps_execution_completed, publish_steps_execution_started, publish_task_completed, publish_task_enqueue, publish_task_failed, publish_task_finalization_completed, publish_task_finalization_started, publish_task_pending_transition, publish_task_reenqueue_delayed, publish_task_reenqueue_failed, publish_task_reenqueue_requested, publish_task_reenqueue_started, publish_task_retry_requested, publish_task_started, publish_viable_steps_discovered, publish_workflow_state_unclear, publish_workflow_step_completed, publish_workflow_task_started
Class Method Details
.build_standardized_payload(_event_name, context) ⇒ Hash
Build standardized event payload with all expected keys (legacy fallback)
430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 |
# File 'lib/tasker/state_machine/step_state_machine.rb', line 430 def build_standardized_payload(_event_name, context) # Base payload with core identifiers enhanced_context = { # Core identifiers (always present) task_id: context[:task_id], step_id: context[:step_id], step_name: context[:step_name], # State transition information from_state: context[:from_state], to_state: context[:to_state], # Timing information (provide defaults for missing keys) started_at: context[:started_at] || context[:transitioned_at], completed_at: context[:completed_at] || context[:transitioned_at], execution_duration: context[:execution_duration] || 0.0, # Error information (for error events) error_message: context[:error_message] || context[:error] || 'Unknown error', exception_class: context[:exception_class] || 'StandardError', attempt_number: context[:attempt_number] || 1, # Additional context transitioned_at: context[:transitioned_at] || Time.zone.now } # Merge in any additional context provided enhanced_context.merge!(context.except( :task_id, :step_id, :step_name, :from_state, :to_state, :started_at, :completed_at, :execution_duration, :error_message, :exception_class, :attempt_number, :transitioned_at )) enhanced_context end |
.determine_event_type_from_name(event_name) ⇒ Symbol
Determine event type from event name for EventPayloadBuilder
408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 |
# File 'lib/tasker/state_machine/step_state_machine.rb', line 408 def determine_event_type_from_name(event_name) case event_name when /completed/i :completed when /failed/i, /error/i :failed when /execution_requested/i, /started/i :started when /retry/i :retry when /backoff/i :backoff else :unknown end end |
.determine_transition_event_name(from_state, to_state) ⇒ String?
Determine the appropriate event name for a state transition using constant lookup
471 472 473 474 475 476 477 478 479 480 481 482 483 484 |
# File 'lib/tasker/state_machine/step_state_machine.rb', line 471 def determine_transition_event_name(from_state, to_state) transition_key = [from_state, to_state] event_name = TRANSITION_EVENT_MAP[transition_key] if event_name.nil? # For unexpected transitions, log a warning and return nil to skip event firing Rails.logger.warn do "Unexpected step state transition: #{from_state || 'initial'} → #{to_state}. " \ 'No event will be fired for this transition.' end end event_name end |
.effective_current_state(step) ⇒ String
Get the effective current state, handling blank/empty states
268 269 270 271 |
# File 'lib/tasker/state_machine/step_state_machine.rb', line 268 def effective_current_state(step) current_state = step.state_machine.current_state current_state.presence || Constants::WorkflowStepStatuses::PENDING end |
.extract_step_from_context(context) ⇒ WorkflowStep?
Extract step object from context for EventPayloadBuilder
392 393 394 395 396 397 398 399 400 401 402 |
# File 'lib/tasker/state_machine/step_state_machine.rb', line 392 def extract_step_from_context(context) step_id = context[:step_id] return nil unless step_id # Try to find the step - handle both string and numeric IDs Tasker::WorkflowStep.find_by(workflow_step_id: step_id) || Tasker::WorkflowStep.find_by(id: step_id) rescue StandardError => e Rails.logger.warn { "Could not find step with ID #{step_id}: #{e.}" } nil end |
.idempotent_transition?(step, target_state) ⇒ Boolean
Check if a transition is idempotent (current state == target state)
250 251 252 253 254 255 256 257 258 259 260 261 262 |
# File 'lib/tasker/state_machine/step_state_machine.rb', line 250 def idempotent_transition?(step, target_state) current_state = step.state_machine.current_state effective_current_state = current_state.presence || Constants::WorkflowStepStatuses::PENDING is_idempotent = effective_current_state == target_state if is_idempotent Rails.logger.debug do "StepStateMachine: Allowing idempotent transition to #{target_state} for step #{step.workflow_step_id}" end end is_idempotent end |
.log_dependencies_not_met(step, target_state) ⇒ Object
Log when dependencies are not met
290 291 292 293 294 295 |
# File 'lib/tasker/state_machine/step_state_machine.rb', line 290 def log_dependencies_not_met(step, target_state) Rails.logger.debug do "StepStateMachine: Cannot transition step #{step.workflow_step_id} to #{target_state} - " \ 'dependencies not satisfied. Check parent step completion status.' end end |
.log_invalid_from_state(step, current_state, target_state, reason) ⇒ Object
Log an invalid from-state transition
279 280 281 282 283 284 |
# File 'lib/tasker/state_machine/step_state_machine.rb', line 279 def log_invalid_from_state(step, current_state, target_state, reason) Rails.logger.debug do "StepStateMachine: Cannot transition to #{target_state} from '#{current_state}' " \ "(step #{step.workflow_step_id}). #{reason}." end end |
.log_transition_result(step, target_state, result, reason) ⇒ Object
Log the result of a transition check
303 304 305 306 307 308 309 310 311 312 313 |
# File 'lib/tasker/state_machine/step_state_machine.rb', line 303 def log_transition_result(step, target_state, result, reason) if result Rails.logger.debug do "StepStateMachine: Allowing transition to #{target_state} for step #{step.workflow_step_id} (#{reason})" end else Rails.logger.debug do "StepStateMachine: Blocking transition to #{target_state} for step #{step.workflow_step_id} (#{reason} failed)" end end end |
.safe_fire_event(event_name, context = {}) ⇒ void
This method returns an undefined value.
Safely fire a lifecycle event using dry-events bus
364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 |
# File 'lib/tasker/state_machine/step_state_machine.rb', line 364 def safe_fire_event(event_name, context = {}) # Use EventPayloadBuilder for consistent payload structure step = extract_step_from_context(context) task = step&.task if step && task # Determine event type from event name event_type = determine_event_type_from_name(event_name) # Use EventPayloadBuilder for standardized payload enhanced_context = Tasker::Events::EventPayloadBuilder.build_step_payload( step, task, event_type: event_type, additional_context: context ) else # Fallback to enhanced context if step/task not available enhanced_context = build_standardized_payload(event_name, context) end publish_event(event_name, enhanced_context) end |
.step_dependencies_met?(step) ⇒ Boolean
Check if step dependencies are met
319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 |
# File 'lib/tasker/state_machine/step_state_machine.rb', line 319 def step_dependencies_met?(step) # Handle cases where step doesn't have parents association or it's not loaded # If step doesn't respond to parents, assume no dependencies return true unless step.respond_to?(:parents) # If parents association exists but is empty, no dependencies to check parents = step.parents return true if parents.blank? # Check if all parent steps are complete parents.all? do |parent| completion_states = [ Constants::WorkflowStepStatuses::COMPLETE, Constants::WorkflowStepStatuses::RESOLVED_MANUALLY ] # Use state_machine.current_state to avoid circular reference with parent.status current_state = parent.state_machine.current_state parent_status = current_state.presence || Constants::WorkflowStepStatuses::PENDING is_complete = completion_states.include?(parent_status) unless is_complete Rails.logger.debug do "StepStateMachine: Step #{step.workflow_step_id} dependency not met - " \ "parent step #{parent.workflow_step_id} is '#{parent_status}', needs to be complete" end end is_complete end rescue StandardError => e # If there's an error checking dependencies, log it and assume dependencies are met # This prevents dependency checking from blocking execution in edge cases Rails.logger.warn do "StepStateMachine: Error checking dependencies for step #{step.workflow_step_id}: #{e.}. " \ 'Assuming dependencies are met.' end true end |
Instance Method Details
#create_transition(from_state, to_state, metadata = {}) ⇒ Object
Override Statesman's transition building to ensure proper from_state handling This is called by Statesman when creating new transitions
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 179 180 181 182 |
# File 'lib/tasker/state_machine/step_state_machine.rb', line 143 def create_transition(from_state, to_state, = {}) # Ensure from_state is properly set - never allow empty strings effective_from_state = case from_state when nil, '' # For initial transitions or empty strings, use nil nil else # For existing states, ensure it's a valid state from_state.presence end # Log transition creation for debugging Rails.logger.debug do "StepStateMachine: Creating transition for step #{object.workflow_step_id}: " \ "'#{effective_from_state}' → '#{to_state}'" end # Get the next sort key next_sort_key = next_sort_key_value # Create the transition with proper from_state handling transition = Tasker::WorkflowStepTransition.create!( workflow_step_id: object.workflow_step_id, to_state: to_state, from_state: effective_from_state, # Use nil instead of empty string most_recent: true, sort_key: next_sort_key, metadata: || {}, created_at: Time.current, updated_at: Time.current ) # Update previous transitions to not be most recent object.workflow_step_transitions .where(most_recent: true) .where.not(id: transition.id) .update_all(most_recent: false) transition end |
#current_state ⇒ Object
Override current_state to work with custom transition model Since WorkflowStepTransition doesn't include Statesman::Adapters::ActiveRecordTransition, we need to implement our own current_state logic using the most_recent column
128 129 130 131 132 133 134 135 136 137 138 139 |
# File 'lib/tasker/state_machine/step_state_machine.rb', line 128 def current_state most_recent_transition = object.workflow_step_transitions.where(most_recent: true).first if most_recent_transition # Ensure we never return empty strings or nil - always return a valid state state = most_recent_transition.to_state state.presence || Constants::WorkflowStepStatuses::PENDING else # Return initial state if no transitions exist Constants::WorkflowStepStatuses::PENDING end end |
#initialize_state_machine! ⇒ Object
Initialize the state machine with the initial state This ensures the state machine is properly initialized when called explicitly DEFENSIVE: Only creates transitions when explicitly needed
193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 |
# File 'lib/tasker/state_machine/step_state_machine.rb', line 193 def initialize_state_machine! # Check if state machine is already initialized return current_state if Tasker::WorkflowStepTransition.exists?(workflow_step_id: object.workflow_step_id) # DEFENSIVE: Use a rescue block instead of transaction to handle race conditions gracefully begin # Create the initial transition only if none exists initial_transition = Tasker::WorkflowStepTransition.create!( workflow_step_id: object.workflow_step_id, to_state: Constants::WorkflowStepStatuses::PENDING, from_state: nil, # Explicitly set to nil for initial transition most_recent: true, sort_key: 0, metadata: { initialized_by: 'state_machine' }, created_at: Time.current, updated_at: Time.current ) Rails.logger.debug do "StepStateMachine: Initialized state machine for step #{object.workflow_step_id} with initial transition to PENDING" end initial_transition.to_state rescue ActiveRecord::RecordNotUnique => e # Handle duplicate key violations gracefully - another thread may have initialized the state machine Rails.logger.debug do "StepStateMachine: State machine for step #{object.workflow_step_id} already initialized by another process: #{e.}" end # Return the current state since we know it's initialized current_state rescue ActiveRecord::StatementInvalid => e # Handle transaction issues gracefully Rails.logger.warn do "StepStateMachine: Transaction issue initializing state machine for step #{object.workflow_step_id}: #{e.}" end # Check if the step actually has transitions now (another process may have created them) if Tasker::WorkflowStepTransition.exists?(workflow_step_id: object.workflow_step_id) current_state else # If still no transitions, return the default state without creating a transition Constants::WorkflowStepStatuses::PENDING end end end |
#next_sort_key_value ⇒ Object
Get the next sort key for transitions
185 186 187 188 |
# File 'lib/tasker/state_machine/step_state_machine.rb', line 185 def next_sort_key_value max_sort_key = object.workflow_step_transitions.maximum(:sort_key) || -1 max_sort_key + 10 # Use increments of 10 for flexibility end |