Class: Tasker::StateMachine::StepStateMachine

Inherits:
Object
  • Object
show all
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

Instance Method Summary collapse

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)

Parameters:

  • event_name (String)

    The event name

  • context (Hash)

    The base context

Returns:

  • (Hash)

    Enhanced context with standardized payload structure



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

Parameters:

  • event_name (String)

    The event name

Returns:

  • (Symbol)

    The event type



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

Parameters:

  • from_state (String, nil)

    The source state

  • to_state (String)

    The target state

Returns:

  • (String, nil)

    The event name or nil if no mapping exists



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

Parameters:

Returns:

  • (String)

    The effective current state (blank states become PENDING)



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

Parameters:

  • context (Hash)

    The event context

Returns:



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.message}" }
  nil
end

.idempotent_transition?(step, target_state) ⇒ Boolean

Check if a transition is idempotent (current state == target state)

Parameters:

  • step (WorkflowStep)

    The step to check

  • target_state (String)

    The target state

Returns:

  • (Boolean)

    True if this is an idempotent transition



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

Parameters:

  • step (WorkflowStep)

    The step

  • target_state (String)

    The target state



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

Parameters:

  • step (WorkflowStep)

    The step

  • current_state (String)

    The current state

  • target_state (String)

    The target state

  • reason (String)

    The reason for the restriction



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

Parameters:

  • step (WorkflowStep)

    The step

  • target_state (String)

    The target state

  • result (Boolean)

    Whether the transition is allowed

  • reason (String)

    The reason for the result



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

Parameters:

  • event_name (String)

    The event name

  • context (Hash) (defaults to: {})

    The event context



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

Parameters:

Returns:

  • (Boolean)

    True if all dependencies are satisfied



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.message}. " \
      '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_stateObject

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.message}"
    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.message}"
    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_valueObject

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