acidic_job 1.0.0.rc2 → 1.0.0.rc4

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.
@@ -8,14 +8,31 @@ module AcidicJob
8
8
 
9
9
  def set(hash)
10
10
  AcidicJob.instrument(:set_context, **hash) do
11
- AcidicJob::Value.upsert_all(
12
- hash.map do |key, value|
13
- { execution_id: @execution.id,
14
- key: key,
15
- value: value }
16
- end,
17
- unique_by: %i[execution_id key]
18
- )
11
+ records = hash.map do |key, value|
12
+ {
13
+ execution_id: @execution.id,
14
+ key: key,
15
+ value: value,
16
+ }
17
+ end
18
+
19
+ case AcidicJob::Value.connection.adapter_name.downcase.to_sym
20
+ when :postgresql, :sqlite
21
+ AcidicJob::Value.upsert_all(records, unique_by: [:execution_id, :key])
22
+ when :mysql2, :mysql, :trilogy
23
+ AcidicJob::Value.upsert_all(records)
24
+ else
25
+ # Fallback for other adapters - try with unique_by first, fall back without
26
+ begin
27
+ AcidicJob::Value.upsert_all(records, unique_by: [:execution_id, :key])
28
+ rescue ArgumentError => e
29
+ if e.message.include?('does not support :unique_by')
30
+ AcidicJob::Value.upsert_all(records)
31
+ else
32
+ raise
33
+ end
34
+ end
35
+ end
19
36
  end
20
37
  end
21
38
 
@@ -25,22 +42,21 @@ module AcidicJob
25
42
  end
26
43
  end
27
44
 
28
- # TODO: deprecate these methods
45
+ def fetch(key, default = nil)
46
+ result = get(key).first
47
+ return result if result
48
+
49
+ fallback = default || yield(key)
50
+ set(key => fallback)
51
+ fallback
52
+ end
53
+
29
54
  def []=(key, value)
30
- AcidicJob.instrument(:set_context, key: key, value: value) do
31
- AcidicJob::Value.upsert(
32
- { execution_id: @execution.id,
33
- key: key,
34
- value: value },
35
- unique_by: %i[execution_id key]
36
- )
37
- end
55
+ set(key => value)
38
56
  end
39
57
 
40
58
  def [](key)
41
- AcidicJob.instrument(:get_context, key: key) do
42
- @execution.values.select(:value).find_by(key: key)&.value
43
- end
59
+ get(key).first
44
60
  end
45
61
  end
46
62
  end
@@ -28,7 +28,6 @@ module AcidicJob
28
28
  end
29
29
  end
30
30
 
31
- # rubocop:disable Lint/MissingSuper
32
31
  class ArgumentMismatchError < Error
33
32
  def initialize(expected, existing)
34
33
  @expected = expected
@@ -98,5 +97,27 @@ module AcidicJob
98
97
  "step method cannot expect arguments: #{@step.inspect}"
99
98
  end
100
99
  end
100
+
101
+ class DoublePluginCallError < Error
102
+ def initialize(plugin, step)
103
+ @plugin_name = (Module === plugin) ? plugin.name : plugin.class.name
104
+ @step = step
105
+ end
106
+
107
+ def message
108
+ "plugin `#{@plugin_name}` attempted to call step multiple times: #{@step.inspect}"
109
+ end
110
+ end
111
+
112
+ class MissingPluginCallError < Error
113
+ def initialize(plugin, step)
114
+ @plugin_name = (Module === plugin) ? plugin.name : plugin.class.name
115
+ @step = step
116
+ end
117
+
118
+ def message
119
+ "plugin `#{@plugin_name}` failed to call step: #{@step.inspect}"
120
+ end
121
+ end
101
122
  # rubocop:enable Lint/MissingSuper
102
123
  end
@@ -5,45 +5,43 @@ require "active_support/log_subscriber"
5
5
  module AcidicJob
6
6
  class LogSubscriber < ActiveSupport::LogSubscriber
7
7
  def define_workflow(event)
8
- debug formatted_event(event, action: "Define workflow", **event.payload.slice("job_class", "job_id"))
8
+ debug formatted_event(event, title: "Define workflow", **event.payload.slice(:job_class, :job_id))
9
9
  end
10
10
 
11
11
  def initialize_workflow(event)
12
- debug formatted_event(event, action: "Initialize workflow", **event.payload.slice("steps"))
12
+ debug formatted_event(event, title: "Initialize workflow", **event.payload.slice(:steps))
13
13
  end
14
14
 
15
15
  def process_workflow(event)
16
- debug formatted_event(event, action: "Process workflow", **event.payload["execution"].slice("id", "recover_to"))
16
+ debug formatted_event(event, title: "Process workflow", **event.payload[:execution].slice(:id, :recover_to))
17
17
  end
18
18
 
19
19
  def process_step(event)
20
- debug formatted_event(event, action: "Process step", **event.payload)
20
+ debug formatted_event(event, title: "Process step", **event.payload)
21
21
  end
22
22
 
23
23
  def perform_step(event)
24
- debug formatted_event(event, action: "Perform step", **event.payload)
24
+ debug formatted_event(event, title: "Perform step", **event.payload)
25
25
  end
26
26
 
27
27
  def record_entry(event)
28
- debug formatted_event(event, action: "Record entry", **event.payload.slice(:step, :action, :timestamp))
28
+ debug formatted_event(event, title: "Record entry", **event.payload.slice(:step, :action, :timestamp))
29
29
  end
30
30
 
31
- private
32
-
33
- def formatted_event(event, action:, **attributes)
34
- "AcidicJob-#{AcidicJob::VERSION} #{action} (#{event.duration.round(1)}ms) #{formatted_attributes(**attributes)}"
31
+ private def formatted_event(event, title:, **attributes)
32
+ "AcidicJob-#{AcidicJob::VERSION} #{title} (#{event.duration.round(1)}ms) #{formatted_attributes(**attributes)}"
35
33
  end
36
34
 
37
- def formatted_attributes(**attributes)
35
+ private def formatted_attributes(**attributes)
38
36
  attributes.map { |attr, value| "#{attr}: #{value.inspect}" }.join(", ")
39
37
  end
40
38
 
41
- def formatted_error(error)
39
+ private def formatted_error(error)
42
40
  [error.class, error.message].compact.join(" ")
43
41
  end
44
42
 
45
43
  # Use the logger configured for AcidicJob
46
- def logger
44
+ private def logger
47
45
  AcidicJob.logger
48
46
  end
49
47
  end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AcidicJob
4
+ class PluginContext
5
+ PLUGIN_INACTIVE = :__ACIDIC_JOB_PLUGIN_INACTIVE__
6
+
7
+ def initialize(plugin, job, execution, context, step_definition)
8
+ @plugin = plugin
9
+ @job = job
10
+ @execution = execution
11
+ @context = context
12
+ @step_definition = step_definition
13
+ end
14
+
15
+ def set(hash)
16
+ @context.set(hash)
17
+ end
18
+
19
+ def get(*keys)
20
+ @context.get(*keys)
21
+ end
22
+
23
+ def definition
24
+ @step_definition.fetch(@plugin.keyword.to_s, PLUGIN_INACTIVE)
25
+ end
26
+
27
+ def current_step
28
+ @step_definition["does"]
29
+ end
30
+
31
+ def inactive?
32
+ definition == PLUGIN_INACTIVE
33
+ end
34
+
35
+ def entries_for_action(action)
36
+ @execution.entries.for_action(plugin_action(action))
37
+ end
38
+
39
+ def record!(step:, action:, timestamp:, **kwargs)
40
+ @execution.record!(
41
+ step: step,
42
+ action: plugin_action(action),
43
+ timestamp: timestamp,
44
+ **kwargs
45
+ )
46
+ end
47
+
48
+ def enqueue_job(...)
49
+ @job.enqueue(...)
50
+ end
51
+
52
+ def halt_workflow!
53
+ @job.halt_workflow!
54
+ end
55
+
56
+ def repeat_step!
57
+ @job.repeat_step!
58
+ end
59
+
60
+ def resolve_method(method_name)
61
+ begin
62
+ method_obj = @job.method(method_name)
63
+ rescue NameError
64
+ raise UndefinedMethodError.new(method_name)
65
+ end
66
+
67
+ method_obj
68
+ end
69
+
70
+ def plugin_action(action)
71
+ "#{@plugin.keyword}/#{action}"
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AcidicJob
4
+ module Plugins
5
+ module TransactionalStep
6
+ extend self
7
+
8
+ def keyword
9
+ :transactional
10
+ end
11
+
12
+ # transactional: true
13
+ # transactional: false
14
+ # transactional: { on: Model }
15
+ def validate(input)
16
+ return input if input in true | false
17
+
18
+ raise ArgumentError.new("argument must be boolean or hash") unless input in Hash
19
+ raise ArgumentError.new("argument hash must have `on` key") unless input in Hash[on:]
20
+ raise ArgumentError.new("`on` key must have module value") unless input in Hash[on: Module]
21
+
22
+ input
23
+ end
24
+
25
+ def around_step(context, &block)
26
+ return yield if context.definition == false
27
+
28
+ model = if context.definition == true
29
+ AcidicJob::Execution
30
+ else
31
+ context.definition["on"].constantize
32
+ end
33
+
34
+ model.transaction(&block)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "arguments"
5
+
6
+ module AcidicJob
7
+ # Used for `serialize` method in ActiveRecord
8
+ module Serializer
9
+ extend self
10
+
11
+ def load(json)
12
+ return if json.nil? || json.empty?
13
+
14
+ data = JSON.parse json
15
+
16
+ Arguments.__send__ :deserialize_argument, data
17
+ end
18
+
19
+ def dump(obj)
20
+ data = Arguments.__send__ :serialize_argument, obj
21
+
22
+ JSON.generate data, strict: true
23
+ rescue ActiveJob::SerializationError => e
24
+ e.message << " (`#{obj.inspect}`)"
25
+ raise e
26
+ end
27
+ end
28
+ end
@@ -15,7 +15,7 @@ module AcidicJob
15
15
 
16
16
  def deserialize(hash)
17
17
  job = ::ActiveJob::Base.deserialize(hash)
18
- job.send(:deserialize_arguments_if_needed)
18
+ job.__send__(:deserialize_arguments_if_needed)
19
19
  job
20
20
  end
21
21
 
@@ -17,9 +17,7 @@ module AcidicJob
17
17
  klass.new(*Arguments.deserialize(hash.values_at(*KEYS)))
18
18
  end
19
19
 
20
- private
21
-
22
- def klass
20
+ private def klass
23
21
  ::Range
24
22
  end
25
23
  end
@@ -22,25 +22,23 @@ module AcidicJob
22
22
  ::DatabaseCleaner.cleaners = @original_cleaners
23
23
  end
24
24
 
25
- private
26
-
27
25
  # Ensure that the system's original DatabaseCleaner configuration is maintained, options included,
28
26
  # except that any `transaction` strategies for any ORMs are replaced with a `deletion` strategy.
29
- def transaction_free_cleaners_for(original_cleaners)
27
+ private def transaction_free_cleaners_for(original_cleaners)
30
28
  non_transaction_cleaners = original_cleaners.dup.to_h do |(orm, opts), cleaner|
31
29
  [[orm, opts], ensure_no_transaction_strategies_for(cleaner)]
32
30
  end
33
31
  ::DatabaseCleaner::Cleaners.new(non_transaction_cleaners)
34
32
  end
35
33
 
36
- def ensure_no_transaction_strategies_for(cleaner)
34
+ private def ensure_no_transaction_strategies_for(cleaner)
37
35
  return cleaner unless strategy_name_for(cleaner) == "transaction"
38
36
 
39
37
  cleaner.strategy = deletion_strategy_for(cleaner)
40
38
  cleaner
41
39
  end
42
40
 
43
- def strategy_name_for(cleaner)
41
+ private def strategy_name_for(cleaner)
44
42
  cleaner # <DatabaseCleaner::Cleaner>
45
43
  .strategy # <DatabaseCleaner::ActiveRecord::Truncation>
46
44
  .class # DatabaseCleaner::ActiveRecord::Truncation
@@ -50,19 +48,19 @@ module AcidicJob
50
48
  .downcase # "truncation"
51
49
  end
52
50
 
53
- def deletion_strategy_for(cleaner)
51
+ private def deletion_strategy_for(cleaner)
54
52
  strategy = cleaner.strategy
55
- strategy_namespace = strategy # <DatabaseCleaner::ActiveRecord::Truncation>
56
- .class # DatabaseCleaner::ActiveRecord::Truncation
57
- .name # "DatabaseCleaner::ActiveRecord::Truncation"
58
- .rpartition("::") # ["DatabaseCleaner::ActiveRecord", "::", "Truncation"]
59
- .first # "DatabaseCleaner::ActiveRecord"
53
+ strategy_namespace = strategy # <DatabaseCleaner::ActiveRecord::Truncation>
54
+ .class # DatabaseCleaner::ActiveRecord::Truncation
55
+ .name # "DatabaseCleaner::ActiveRecord::Truncation"
56
+ .rpartition("::") # ["DatabaseCleaner::ActiveRecord", "::", "Truncation"]
57
+ .first # "DatabaseCleaner::ActiveRecord"
60
58
  deletion_strategy_class_name = [strategy_namespace, "::", "Deletion"].join
61
59
  deletion_strategy_class = deletion_strategy_class_name.constantize
62
60
  instance_variable_hash = strategy.instance_variables.to_h do |var|
63
61
  [
64
62
  var.to_s.remove("@"),
65
- strategy.instance_variable_get(var)
63
+ strategy.instance_variable_get(var),
66
64
  ]
67
65
  end
68
66
  options = instance_variable_hash.except("db", "connection_class")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AcidicJob
4
- VERSION = "1.0.0.rc2"
4
+ VERSION = "1.0.0.rc4"
5
5
  end
@@ -4,43 +4,41 @@ require "active_job"
4
4
 
5
5
  module AcidicJob
6
6
  module Workflow
7
- NO_OP_WRAPPER = proc { |&block| block.call }
8
- REPEAT_STEP = :REPEAT_STEP
9
- HALT_STEP = :HALT_STEP
10
- private_constant :NO_OP_WRAPPER, :REPEAT_STEP, :HALT_STEP
7
+ REPEAT_STEP = :__ACIDIC_JOB_REPEAT_STEP_SIGNAL__
8
+ HALT_STEP = :__ACIDIC_JOB_HALT_STEP_SIGNAL__
9
+ private_constant :REPEAT_STEP, :HALT_STEP
11
10
 
12
- attr_reader :execution, :ctx
13
-
14
- def execute_workflow(unique_by:, &block)
11
+ def execute_workflow(unique_by:, with: AcidicJob.plugins, &block)
12
+ @__acidic_job_plugins__ = with
15
13
  serialized_job = serialize
16
14
 
17
15
  workflow_definition = AcidicJob.instrument(:define_workflow, **serialized_job) do
18
- raise RedefiningWorkflowError if defined? @_builder
16
+ raise RedefiningWorkflowError if defined? @__acidic_job_builder__
19
17
 
20
- @_builder = Builder.new
18
+ @__acidic_job_builder__ = Builder.new(@__acidic_job_plugins__)
21
19
 
22
20
  raise UndefinedWorkflowBlockError unless block_given?
23
21
  raise InvalidWorkflowBlockError if block.arity != 1
24
22
 
25
- block.call @_builder
23
+ block.call @__acidic_job_builder__
26
24
 
27
- raise MissingStepsError if @_builder.steps.empty?
25
+ raise MissingStepsError if @__acidic_job_builder__.steps.empty?
28
26
 
29
27
  # convert the array of steps into a hash of recovery_points and next steps
30
- @_builder.define_workflow
28
+ @__acidic_job_builder__.define_workflow
31
29
  end
32
30
 
33
- AcidicJob.instrument(:initialize_workflow, "definition" => workflow_definition) do
31
+ AcidicJob.instrument(:initialize_workflow, definition: workflow_definition) do
34
32
  transaction_args = case ::ActiveRecord::Base.connection.adapter_name.downcase.to_sym
35
- # SQLite doesn't support `serializable` transactions
36
- when :sqlite
37
- {}
38
- else
39
- { isolation: :serializable }
40
- end
41
- idempotency_key = Digest::SHA256.hexdigest(JSON.fast_generate([self.class.name, unique_by], strict: true))
42
-
43
- @execution = ::ActiveRecord::Base.transaction(**transaction_args) do
33
+ # SQLite doesn't support `serializable` transactions
34
+ when :sqlite
35
+ {}
36
+ else
37
+ { isolation: :serializable }
38
+ end
39
+ idempotency_key = Digest::SHA256.hexdigest(JSON.generate([self.class.name, unique_by], strict: true))
40
+
41
+ @__acidic_job_execution__ = ::ActiveRecord::Base.transaction(**transaction_args) do
44
42
  record = Execution.find_by(idempotency_key: idempotency_key)
45
43
 
46
44
  if record.present?
@@ -62,41 +60,51 @@ module AcidicJob
62
60
  last_run_at: Time.current
63
61
  )
64
62
  else
63
+ starting_point = if workflow_definition.key?("steps")
64
+ workflow_definition["steps"].keys.first
65
+ else
66
+ # TODO: add deprecation warning
67
+ workflow_definition.keys.first
68
+ end
69
+
65
70
  record = Execution.create!(
66
71
  idempotency_key: idempotency_key,
67
72
  serialized_job: serialized_job,
68
73
  definition: workflow_definition,
69
- recover_to: workflow_definition.keys.first
74
+ recover_to: starting_point
70
75
  )
71
76
  end
72
77
 
73
78
  record
74
79
  end
75
80
  end
76
- @ctx ||= Context.new(@execution)
81
+ @__acidic_job_context__ ||= Context.new(@__acidic_job_execution__)
77
82
 
78
- AcidicJob.instrument(:process_workflow, execution: @execution.attributes) do
83
+ AcidicJob.instrument(:process_workflow, execution: @__acidic_job_execution__.attributes) do
79
84
  # if the workflow record is already marked as finished, immediately return its result
80
- return true if @execution.finished?
85
+ return true if @__acidic_job_execution__.finished?
81
86
 
82
87
  loop do
83
- break if @execution.finished?
88
+ break if @__acidic_job_execution__.finished?
84
89
 
85
- current_step = @execution.recover_to
90
+ current_step = @__acidic_job_execution__.recover_to
86
91
 
87
- if not @execution.definition.key?(current_step) # rubocop:disable Style/Not
92
+ if not @__acidic_job_execution__.defined?(current_step)
88
93
  raise UndefinedStepError.new(current_step)
89
94
  end
90
95
 
91
- step_definition = @execution.definition[current_step]
96
+ step_definition = @__acidic_job_execution__.definition_for(current_step)
92
97
  AcidicJob.instrument(:process_step, **step_definition) do
93
98
  recover_to = catch(:halt) { take_step(step_definition) }
94
99
  case recover_to
95
100
  when HALT_STEP
96
- @execution.record!(step: step_definition.fetch("does"), action: :halted, timestamp: Time.now)
101
+ @__acidic_job_execution__.record!(
102
+ step: step_definition.fetch("does"),
103
+ action: :halted,
104
+ )
97
105
  return true
98
106
  else
99
- @execution.update!(recover_to: recover_to)
107
+ @__acidic_job_execution__.update_column(:recover_to, recover_to)
100
108
  end
101
109
  end
102
110
  end
@@ -107,31 +115,42 @@ module AcidicJob
107
115
  throw :repeat, REPEAT_STEP
108
116
  end
109
117
 
110
- def halt_step!
118
+ def halt_workflow!
111
119
  throw :halt, HALT_STEP
112
120
  end
113
121
 
122
+ def halt_step!
123
+ # TODO add deprecation warning
124
+ halt_workflow!
125
+ end
126
+
114
127
  def step_retrying?
115
128
  step_name = caller_locations.first.label
116
129
 
117
- if not @execution.definition.key?(step_name) # rubocop:disable Style/IfUnlessModifier, Style/Not
130
+ if not @__acidic_job_execution__.defined?(step_name)
118
131
  raise UndefinedStepError.new(step_name)
119
132
  end
120
133
 
121
- @execution.entries.where(step: step_name, action: "started").count > 1
134
+ @__acidic_job_execution__.entries.where(step: step_name, action: "started").count > 1
122
135
  end
123
136
 
124
- private
137
+ def execution
138
+ @__acidic_job_execution__
139
+ end
140
+
141
+ def ctx
142
+ @__acidic_job_context__
143
+ end
125
144
 
126
- def take_step(step_definition)
145
+ private def take_step(step_definition)
127
146
  curr_step = step_definition.fetch("does")
128
147
  next_step = step_definition.fetch("then")
129
148
 
130
- return next_step if @execution.entries.exists?(step: curr_step, action: :succeeded)
149
+ return next_step if @__acidic_job_execution__.entries.exists?(step: curr_step, action: :succeeded)
131
150
 
132
151
  rescued_error = nil
133
152
  begin
134
- @execution.record!(step: curr_step, action: :started, timestamp: Time.now)
153
+ @__acidic_job_execution__.record!(step: curr_step, action: :started)
135
154
  result = AcidicJob.instrument(:perform_step, **step_definition) do
136
155
  perform_step_for(step_definition)
137
156
  end
@@ -139,44 +158,77 @@ module AcidicJob
139
158
  when REPEAT_STEP
140
159
  curr_step
141
160
  else
142
- @execution.record!(step: curr_step, action: :succeeded, timestamp: Time.now, result: result)
161
+ @__acidic_job_execution__.record!(
162
+ step: curr_step,
163
+ action: :succeeded,
164
+ ignored: {
165
+ result: result,
166
+ }
167
+ )
143
168
  next_step
144
169
  end
145
- rescue StandardError => e
170
+ rescue => e
146
171
  rescued_error = e
147
172
  raise e
148
173
  ensure
149
174
  if rescued_error
150
175
  begin
151
- @execution.record!(
176
+ @__acidic_job_execution__.record!(
152
177
  step: curr_step,
153
178
  action: :errored,
154
- timestamp: Time.now,
155
179
  exception_class: rescued_error.class.name,
156
180
  message: rescued_error.message
157
181
  )
158
- rescue StandardError => e
182
+ rescue => e
159
183
  # We're already inside an error condition, so swallow any additional
160
184
  # errors from here and just send them to logs.
161
185
  logger.error(
162
- "Failed to store exception at step #{curr_step} for execution ##{@execution.id} because of #{e}."
186
+ "Failed to store exception at step #{curr_step} for execution ##{@__acidic_job_execution__.id}: #{e}."
163
187
  )
164
188
  end
165
189
  end
166
190
  end
167
191
  end
168
192
 
169
- def perform_step_for(step_definition)
193
+ private def perform_step_for(step_definition)
170
194
  step_name = step_definition.fetch("does")
171
- step_method = method(step_name)
195
+ begin
196
+ step_method = method(step_name)
197
+ rescue NameError
198
+ raise UndefinedMethodError.new(step_name)
199
+ end
200
+
201
+ # raise InvalidMethodError.new(step_name) unless step_method.arity.zero?
202
+
203
+ plugin_pipeline_callable = @__acidic_job_plugins__.reverse.reduce(step_method) do |callable, plugin|
204
+ context = PluginContext.new(plugin, self, @__acidic_job_execution__, @__acidic_job_context__, step_definition)
205
+
206
+ if context.inactive?
207
+ callable
208
+ else
209
+ proc do
210
+ called = false
211
+
212
+ result = plugin.around_step(context) do |*args, **kwargs|
213
+ raise DoublePluginCallError.new(plugin, step_name) if called
172
214
 
173
- raise InvalidMethodError.new(step_name) unless step_method.arity.zero?
215
+ called = true
174
216
 
175
- wrapper = step_definition["transactional"] ? @execution.method(:with_lock) : NO_OP_WRAPPER
217
+ if callable.arity.zero?
218
+ callable.call
219
+ else
220
+ callable.call(*args, **kwargs)
221
+ end
222
+ end
223
+
224
+ # raise MissingPluginCallError.new(plugin, step_name) unless called
225
+
226
+ result
227
+ end
228
+ end
229
+ end
176
230
 
177
- catch(:repeat) { wrapper.call { step_method.call } }
178
- rescue NameError
179
- raise UndefinedMethodError.new(step_name)
231
+ catch(:repeat) { plugin_pipeline_callable.call }
180
232
  end
181
233
  end
182
234
  end