module ForemanTasks module Concerns module ActionTriggering extend ActiveSupport::Concern included do after_create :plan_create_action after_update :plan_update_action after_destroy :plan_destroy_action end # @override def create_action end # @override def update_action end # @override def destroy_action end def save(*args) dynflow_task_wrap(:save) { super(*args) } end def save!(*args) dynflow_task_wrap(:save) { super(*args) } end def destroy dynflow_task_wrap(:destroy) { super } end def update_attributes(*args) dynflow_task_wrap(:save) { super(*args) } end def update_attributes!(*args) dynflow_task_wrap(:save) { super(*args) } end protected def sync_action_flag_reset! @dynflow_sync_action = false end # to make the triggered action synchronous def sync_action! @dynflow_sync_action = true end def plan_create_action plan_action(create_action, self) if create_action return true end def plan_update_action plan_action(update_action, self) if update_action return true end def plan_destroy_action plan_action(destroy_action, self) if destroy_action return true end # Perform planning phase of the action tied with the model event. # We do it separately from the execution phase, because the transaction # of planning phase is expected to be commited when execution occurs. Also # we want to be able to rollback the whole db operation when planning fails. def plan_action(action_class, *args) @execution_plan = ::ForemanTasks.dynflow.world.plan(action_class, *args) raise @execution_plan.errors.first if @execution_plan.error? end # Makes sure the execution plan is executed AFTER the transaction is commited. # We can't user after_commit filters because they don't allow to raise # exceptions in there, so we would not be able to report that something # went wrong when running a sync_task.: # # http://guides.rubyonrails.org/v3.2.14/active_record_validations_callbacks.html#transaction-callbacks # # That's why we need to override save and destroy methods instead. # Another reason why one should avoid callbacks for orchestration. # # Also, it makes sure the save is not run inside other transaction because # we would start the execution phase inside this transaction which would lead # to unexpected results. def dynflow_task_wrap(method) return yield if @_dynflow_task_wrapped @_dynflow_task_wrapped = true action = case method when :save self.new_record? ? create_action : update_action when :destroy destroy_action else raise 'unexpected method' end if action ensure_not_in_transaction! yield.tap do |result| execute_planned_action if result sync_action_flag_reset! end else yield end ensure @_dynflow_task_wrapped = false end # we don't want to start executing the task calling to external services # when inside some other transaction. Might lead to unexpected results def ensure_not_in_transaction! if self.class.connection.open_transactions > 0 raise 'Executing dynflow action inside a transaction is not a good idea' end end # Execute the prepared execution plan after the db transaction was commited def execute_planned_action if @execution_plan run = ::ForemanTasks.dynflow.world.execute(@execution_plan.id) if @dynflow_sync_action run.wait if run.value.error? task = ForemanTasks::Task::DynflowTask.find_by_external_id!(@execution_plan.id) raise ForemanTasks::TaskError.new(task) end end end return true end end end end