stepped 0.1.0 → 1.0.0
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.
- checksums.yaml +4 -4
- data/README.md +312 -0
- data/Rakefile +6 -0
- data/app/jobs/stepped/action_job.rb +10 -0
- data/app/jobs/stepped/complete_action_job.rb +7 -0
- data/app/jobs/stepped/timeout_job.rb +11 -0
- data/app/jobs/stepped/wait_job.rb +7 -0
- data/app/models/concerns/stepped/actionable.rb +37 -0
- data/app/models/stepped/achievement.rb +25 -0
- data/app/models/stepped/action.rb +293 -0
- data/app/models/stepped/arguments.rb +15 -0
- data/app/models/stepped/definition.rb +109 -0
- data/app/models/stepped/performance.rb +76 -0
- data/app/models/stepped/registry.rb +66 -0
- data/app/models/stepped/step.rb +98 -0
- data/config/routes.rb +2 -0
- data/db/migrate/20251214104829_create_stepped_tables_if_missing.rb +77 -0
- data/lib/stepped/engine.rb +8 -6
- data/lib/stepped/test_helper.rb +76 -0
- data/lib/stepped/version.rb +1 -3
- data/lib/stepped.rb +22 -9
- data/lib/tasks/stepped_tasks.rake +12 -0
- metadata +30 -40
- data/lib/stepped/active_record_extension.rb +0 -9
- /data/{LICENSE → MIT-LICENSE} +0 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
class Stepped::Action < ActiveRecord::Base
|
|
2
|
+
self.filter_attributes = []
|
|
3
|
+
|
|
4
|
+
STATUSES = %w[
|
|
5
|
+
pending
|
|
6
|
+
performing
|
|
7
|
+
succeeded
|
|
8
|
+
superseded
|
|
9
|
+
cancelled
|
|
10
|
+
failed
|
|
11
|
+
timed_out
|
|
12
|
+
deadlocked
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
enum :status, STATUSES.index_by(&:itself)
|
|
16
|
+
|
|
17
|
+
serialize :arguments, coder: Stepped::Arguments
|
|
18
|
+
|
|
19
|
+
belongs_to :actor, polymorphic: true
|
|
20
|
+
belongs_to :performance, optional: true
|
|
21
|
+
|
|
22
|
+
has_many :steps, -> { order(id: :asc) }, dependent: :destroy
|
|
23
|
+
|
|
24
|
+
has_and_belongs_to_many :parent_steps, class_name: "Stepped::Step",
|
|
25
|
+
join_table: :stepped_actions_steps, foreign_key: :step_id, association_foreign_key: :action_id,
|
|
26
|
+
inverse_of: :actions
|
|
27
|
+
|
|
28
|
+
scope :roots, -> { where(root: true) }
|
|
29
|
+
scope :outbounds, -> { where(outbound: true) }
|
|
30
|
+
scope :incomplete, -> { where(status: %i[ pending performing ]) }
|
|
31
|
+
|
|
32
|
+
KEYS_JOINER = "/"
|
|
33
|
+
|
|
34
|
+
def obtain_lock_and_perform
|
|
35
|
+
apply_definition
|
|
36
|
+
run_before_chain
|
|
37
|
+
|
|
38
|
+
if completed?
|
|
39
|
+
propagate_completion_to_parent_steps
|
|
40
|
+
return self
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
set_checksum
|
|
44
|
+
|
|
45
|
+
Stepped::Achievement.raise_if_exists_for?(self)
|
|
46
|
+
Stepped::Performance.obtain_for(self)
|
|
47
|
+
rescue Stepped::Achievement::ExistsError
|
|
48
|
+
self.status = :succeeded
|
|
49
|
+
propagate_completion_to_parent_steps
|
|
50
|
+
self
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def update_performance(performance)
|
|
54
|
+
self.performance = performance
|
|
55
|
+
perform if pending? && (performance.action == self)
|
|
56
|
+
save!
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def perform
|
|
60
|
+
update! status: :performing, started_at: Time.zone.now
|
|
61
|
+
|
|
62
|
+
Stepped::Achievement.erase_of self
|
|
63
|
+
|
|
64
|
+
ActiveRecord.after_all_transactions_commit do
|
|
65
|
+
perform_current_step
|
|
66
|
+
Stepped::TimeoutJob.set(wait: timeout).perform_later(self) if timeout?
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def definition
|
|
71
|
+
@definition = Stepped::Registry.find_or_add actor.class, name
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def cancel
|
|
75
|
+
self.status = :cancelled
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def complete
|
|
79
|
+
self.status = :succeeded
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def supersede_with(action)
|
|
83
|
+
update! completed_at: Time.zone.now, status: :superseded
|
|
84
|
+
copy_parent_steps_to action
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def achieves?(action)
|
|
88
|
+
checksum_key == action.checksum_key && checksum == action.checksum
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def copy_parent_steps_to(action)
|
|
92
|
+
raise ArgumentError, "Can't copy_parent_steps_to self" if action == self
|
|
93
|
+
|
|
94
|
+
parent_steps.each do |step|
|
|
95
|
+
transaction(requires_new: true) do
|
|
96
|
+
action.parent_steps << step
|
|
97
|
+
end
|
|
98
|
+
rescue ActiveRecord::RecordNotUnique
|
|
99
|
+
action.reload
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def perform_current_step
|
|
104
|
+
steps.create!(
|
|
105
|
+
definition_index: current_step_index,
|
|
106
|
+
started_at: Time.zone.now,
|
|
107
|
+
status: :performing
|
|
108
|
+
).perform
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def compute_concurrency_key
|
|
112
|
+
run_definition_block :concurrency_key_block
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def compute_checksum_key
|
|
116
|
+
run_definition_block :checksum_key_block
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def outbound_complete_key
|
|
120
|
+
outbound? ? tenancy_key : nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def accomplished(step)
|
|
124
|
+
if step.failed?
|
|
125
|
+
complete! :failed
|
|
126
|
+
elsif more_steps_to_do?
|
|
127
|
+
increment :current_step_index
|
|
128
|
+
save!
|
|
129
|
+
perform_current_step
|
|
130
|
+
elsif !outbound?
|
|
131
|
+
complete!
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def safe_actor
|
|
136
|
+
actor
|
|
137
|
+
rescue ActiveRecord::SubclassNotFound
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def actor_becomes_base
|
|
141
|
+
safe_actor&.becomes actor_type.constantize
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def short_checksum
|
|
145
|
+
checksum.to_s[0..7]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def timeout?
|
|
149
|
+
timeout_seconds.present?
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def compute_timeout
|
|
153
|
+
if definition.timeout.is_a?(Symbol)
|
|
154
|
+
actor.send(definition.timeout)
|
|
155
|
+
else
|
|
156
|
+
definition.timeout
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def cancellable?
|
|
161
|
+
pending? || performing?
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def complete!(status = :succeeded)
|
|
165
|
+
Stepped::Performance.complete_action self, status
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def finalize_complete(status)
|
|
169
|
+
self.status = status
|
|
170
|
+
execute_after_complete_callbacks
|
|
171
|
+
update!(completed_at: Time.zone.now, performance: nil)
|
|
172
|
+
Stepped::Achievement.grand_to(self) if succeeded_including_callbacks? && checksum.present?
|
|
173
|
+
|
|
174
|
+
propagate_completion_to_parent_steps
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def deadlock!
|
|
178
|
+
e = Deadlock.new "#{name} on #{actor.class.name}/#{actor.id}"
|
|
179
|
+
handled = Stepped.handled_exception_classes.any? { e.class <= _1 }
|
|
180
|
+
raise e unless handled
|
|
181
|
+
|
|
182
|
+
Rails.error.report(e, handled:)
|
|
183
|
+
self.status = :deadlocked
|
|
184
|
+
propagate_completion_to_parent_steps
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def descendant_of?(action)
|
|
188
|
+
parent_steps.any? do |step|
|
|
189
|
+
return true if step.action_id == action.id
|
|
190
|
+
step.action.descendant_of?(action)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def completed?
|
|
195
|
+
cancelled? || succeeded? || superseded? || failed? || timed_out? || deadlocked?
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def propagated_touch
|
|
199
|
+
touch
|
|
200
|
+
parent_steps.each { _1.action.propagated_touch }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def apply_definition
|
|
204
|
+
return if definition.nil?
|
|
205
|
+
self.outbound = definition.outbound
|
|
206
|
+
self.concurrency_key = compute_concurrency_key
|
|
207
|
+
self.checksum_key = compute_checksum_key
|
|
208
|
+
self.job = definition.job
|
|
209
|
+
self.timeout_seconds = compute_timeout
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def timeout
|
|
213
|
+
timeout_seconds.seconds
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def propagate_completion_to_parent_steps
|
|
217
|
+
ActiveRecord.after_all_transactions_commit do
|
|
218
|
+
parent_steps.each do |step|
|
|
219
|
+
step.conclude_job(succeeded_including_callbacks?)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def succeeded_including_callbacks?
|
|
225
|
+
succeeded? && after_callbacks_failed_count.nil?
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
class Deadlock < StandardError; end
|
|
229
|
+
|
|
230
|
+
private
|
|
231
|
+
def tenancy_key
|
|
232
|
+
actor.stepped_action_tenancy_key name
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def run_before_chain
|
|
236
|
+
return if failed?
|
|
237
|
+
|
|
238
|
+
if before_block = definition.before_block
|
|
239
|
+
fail_on_exception do
|
|
240
|
+
actor.instance_exec self, *arguments, &before_block
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def fail_on_exception(&block)
|
|
246
|
+
unless Stepped.handle_exception(context: { block: }, &block)
|
|
247
|
+
self.status = :failed
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def run_definition_block(method)
|
|
252
|
+
if block = definition.public_send(method)
|
|
253
|
+
result = actor.instance_exec(*arguments, &block)
|
|
254
|
+
|
|
255
|
+
return tenancy_key if result.blank?
|
|
256
|
+
|
|
257
|
+
result.is_a?(Array) ? result.join(KEYS_JOINER) : result.to_s
|
|
258
|
+
else
|
|
259
|
+
tenancy_key
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def set_checksum
|
|
264
|
+
if block = definition.checksum_block
|
|
265
|
+
value = actor.instance_exec(*arguments, &block)
|
|
266
|
+
self.checksum = Stepped.checksum value
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def execute_after_complete_callbacks
|
|
271
|
+
return true if definition.nil?
|
|
272
|
+
|
|
273
|
+
definition.after_callbacks.each do |callback|
|
|
274
|
+
next true unless callback.fetch(:name).in?([ status.to_sym, :all ])
|
|
275
|
+
|
|
276
|
+
context = { action: to_global_id, callback: callback.inspect }
|
|
277
|
+
|
|
278
|
+
succeeded = Stepped.handle_exception(context:) do
|
|
279
|
+
actor.instance_exec self, *arguments, &callback.fetch(:block)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
if succeeded
|
|
283
|
+
increment :after_callbacks_succeeded_count
|
|
284
|
+
else
|
|
285
|
+
increment :after_callbacks_failed_count
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def more_steps_to_do?
|
|
291
|
+
definition.steps.size > (current_step_index + 1)
|
|
292
|
+
end
|
|
293
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
require "active_job/arguments"
|
|
2
|
+
|
|
3
|
+
class Stepped::Arguments
|
|
4
|
+
class << self
|
|
5
|
+
def load(serialized_arguments)
|
|
6
|
+
return if serialized_arguments.nil?
|
|
7
|
+
|
|
8
|
+
ActiveJob::Arguments.deserialize serialized_arguments
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def dump(arguments)
|
|
12
|
+
ActiveJob::Arguments.serialize arguments
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
class Stepped::Definition
|
|
2
|
+
attr_reader :actor_class, :action_name, :block,
|
|
3
|
+
:outbound, :timeout, :job,
|
|
4
|
+
:concurrency_key_block, :before_block,
|
|
5
|
+
:checksum_block, :checksum_key_block,
|
|
6
|
+
:steps, :after_callbacks
|
|
7
|
+
|
|
8
|
+
AFTER_CALLBACKS = %i[
|
|
9
|
+
cancelled
|
|
10
|
+
timed_out
|
|
11
|
+
succeeded
|
|
12
|
+
failed
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
def initialize(actor_class:, action_name:, outbound: false, timeout: nil, job: nil, block: nil)
|
|
16
|
+
@actor_class = actor_class
|
|
17
|
+
@action_name = action_name.to_s
|
|
18
|
+
@outbound = outbound || job.present?
|
|
19
|
+
@timeout = timeout
|
|
20
|
+
@job = job
|
|
21
|
+
@after_callbacks = []
|
|
22
|
+
@steps = []
|
|
23
|
+
@block = block
|
|
24
|
+
|
|
25
|
+
instance_exec &block if block
|
|
26
|
+
|
|
27
|
+
if @steps.empty?
|
|
28
|
+
@steps.append generate_step
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def duplicate_as(actor_class)
|
|
33
|
+
self.class.new(actor_class:, action_name:, outbound:, timeout:, job:, block:)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def before(&block)
|
|
37
|
+
@before_block = block
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def concurrency_key(method = nil, &block)
|
|
41
|
+
@concurrency_key_block = procify method, &block
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def checksum(method = nil, &block)
|
|
45
|
+
@checksum_block = procify method, &block
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def checksum_key(method = nil, &block)
|
|
49
|
+
@checksum_key_block = procify method, &block
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def step(&block)
|
|
53
|
+
@steps.append block
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def prepend_step(&block)
|
|
57
|
+
@steps.prepend block
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
AFTER_CALLBACKS.each do |name|
|
|
61
|
+
define_method name do |&block|
|
|
62
|
+
after_callbacks << { name:, block: }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def after(*statuses, &block)
|
|
67
|
+
statuses = [ :all ] if statuses.empty?
|
|
68
|
+
statuses.each do |status|
|
|
69
|
+
status = status.to_sym
|
|
70
|
+
unless status == :all || AFTER_CALLBACKS.include?(status)
|
|
71
|
+
raise ArgumentError, "'#{status}' must be one of #{AFTER_CALLBACKS}"
|
|
72
|
+
end
|
|
73
|
+
after_callbacks << { name: status, block: }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
def procify(method, &block)
|
|
79
|
+
if method.is_a?(Symbol)
|
|
80
|
+
proc do
|
|
81
|
+
send method
|
|
82
|
+
end
|
|
83
|
+
elsif block_given?
|
|
84
|
+
block
|
|
85
|
+
else
|
|
86
|
+
raise ArgumentError, "Symbol referring to a method to call or a block required"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def method_call_step_block(method_name)
|
|
91
|
+
proc do |step|
|
|
92
|
+
send method_name, *step.action.arguments
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def job_step_block(job)
|
|
97
|
+
proc do |step|
|
|
98
|
+
job.perform_later step.action
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def generate_step
|
|
103
|
+
if job
|
|
104
|
+
job_step_block job
|
|
105
|
+
else
|
|
106
|
+
method_call_step_block action_name
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
class Stepped::Performance < ActiveRecord::Base
|
|
2
|
+
self.filter_attributes = []
|
|
3
|
+
|
|
4
|
+
belongs_to :action
|
|
5
|
+
has_many :actions, -> { order(:id) }, dependent: :nullify
|
|
6
|
+
|
|
7
|
+
scope :outbounds, -> { joins(:action).where(action: { outbound: true }) }
|
|
8
|
+
|
|
9
|
+
before_save -> { self.outbound_complete_key = action.outbound_complete_key }
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def obtain_for(action)
|
|
13
|
+
transaction(requires_new: true) do
|
|
14
|
+
lock.
|
|
15
|
+
create_with(action:).
|
|
16
|
+
find_or_create_by!(concurrency_key: action.concurrency_key).
|
|
17
|
+
share_with(action)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def outbound_complete(actor, name, status = :succeeded)
|
|
22
|
+
outbound_complete_key = actor.stepped_action_tenancy_key name
|
|
23
|
+
|
|
24
|
+
transaction(requires_new: true) do
|
|
25
|
+
lock.find_by(outbound_complete_key:)&.forward(status:)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def complete_action(action, status)
|
|
30
|
+
transaction(requires_new: true) do
|
|
31
|
+
lock.find_by(concurrency_key: action.concurrency_key)&.forward(action, status:)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def forward(completing_action = self.action, status: :succeeded)
|
|
37
|
+
completing_action.finalize_complete status
|
|
38
|
+
|
|
39
|
+
return unless completing_action == action
|
|
40
|
+
|
|
41
|
+
if next_action = actions.incomplete.first
|
|
42
|
+
update!(action: next_action)
|
|
43
|
+
next_action.perform if next_action.pending?
|
|
44
|
+
else
|
|
45
|
+
destroy!
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def share_with(candidate)
|
|
50
|
+
# Secondary check of this kind here within a performance lock
|
|
51
|
+
# prevents race conditions between the first check and obtaining the lock,
|
|
52
|
+
# while the first check in Action#obtain_lock_and_perform speeds things up.
|
|
53
|
+
Stepped::Achievement.raise_if_exists_for?(candidate)
|
|
54
|
+
|
|
55
|
+
if candidate.descendant_of?(action)
|
|
56
|
+
return candidate.tap(&:deadlock!)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if candidate.checksum.present?
|
|
60
|
+
actions.excluding(candidate).each do |action|
|
|
61
|
+
if action.achieves?(candidate)
|
|
62
|
+
candidate.copy_parent_steps_to action
|
|
63
|
+
return action
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
other_pending_actions.each { _1.supersede_with(candidate) }
|
|
69
|
+
candidate.tap { _1.update_performance(self) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
def other_pending_actions
|
|
74
|
+
actions.pending.excluding(action)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
class Stepped::Registry
|
|
2
|
+
@job_classes = Concurrent::Array.new
|
|
3
|
+
@definitions = Concurrent::Hash.new
|
|
4
|
+
|
|
5
|
+
class << self
|
|
6
|
+
attr_reader :job_classes, :definitions
|
|
7
|
+
|
|
8
|
+
def key(klass)
|
|
9
|
+
"#{klass.name}/#{klass.object_id}"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def add(actor_class, action_name, outbound: false, timeout: nil, job: nil, &block)
|
|
13
|
+
if job && @job_classes.exclude?(job)
|
|
14
|
+
@job_classes.push job
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
add_definition actor_class, action_name, Stepped::Definition.new(
|
|
18
|
+
actor_class:,
|
|
19
|
+
action_name:,
|
|
20
|
+
outbound:,
|
|
21
|
+
timeout:,
|
|
22
|
+
job:,
|
|
23
|
+
block:
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def add_definition(actor_class, action_name, definition)
|
|
28
|
+
class_key = key actor_class
|
|
29
|
+
@definitions[class_key] ||= Concurrent::Hash.new
|
|
30
|
+
@definitions[class_key][action_name.to_s] = definition
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def prepend_step(actor_class, action_name, &step_block)
|
|
34
|
+
definition = find_or_add actor_class, action_name
|
|
35
|
+
|
|
36
|
+
unless definition.actor_class == actor_class
|
|
37
|
+
definition = add_definition actor_class, action_name, definition.duplicate_as(actor_class)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
definition.prepend_step(&step_block)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def append_after_callback(actor_class, action_name, *statuses, &block)
|
|
44
|
+
definition = find_or_add actor_class, action_name
|
|
45
|
+
|
|
46
|
+
unless definition.actor_class == actor_class
|
|
47
|
+
definition = add_definition actor_class, action_name, definition.duplicate_as(actor_class)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
definition.after(*statuses, &block)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def find(actor_class, action_name)
|
|
54
|
+
actor_class.ancestors.each do |ancestor|
|
|
55
|
+
definition = @definitions.dig key(ancestor), action_name.to_s
|
|
56
|
+
return definition if definition
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def find_or_add(actor_class, action_name)
|
|
63
|
+
find(actor_class, action_name) || add(actor_class, action_name)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
class Stepped::Step < ActiveRecord::Base
|
|
2
|
+
STATUSES = %w[
|
|
3
|
+
pending
|
|
4
|
+
performing
|
|
5
|
+
succeeded
|
|
6
|
+
failed
|
|
7
|
+
].freeze
|
|
8
|
+
|
|
9
|
+
enum :status, STATUSES.index_by(&:itself)
|
|
10
|
+
|
|
11
|
+
belongs_to :action
|
|
12
|
+
|
|
13
|
+
has_and_belongs_to_many :actions, -> { order(id: :asc) }, class_name: "Stepped::Action",
|
|
14
|
+
join_table: :stepped_actions_steps, foreign_key: :action_id, association_foreign_key: :step_id,
|
|
15
|
+
inverse_of: :parent_steps
|
|
16
|
+
|
|
17
|
+
scope :incomplete, -> { where(status: %i[ pending performing ]) }
|
|
18
|
+
|
|
19
|
+
def perform
|
|
20
|
+
@jobs = []
|
|
21
|
+
if execute_block
|
|
22
|
+
ActiveJob.perform_all_later @jobs
|
|
23
|
+
else
|
|
24
|
+
self.pending_actions_count = 0
|
|
25
|
+
self.status = :failed
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
complete! if pending_actions_count.zero?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def do(action_name, *args)
|
|
32
|
+
on action.actor, action_name, *args
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def on(actors, action_name, *args)
|
|
36
|
+
Array(actors).compact.each do |actor|
|
|
37
|
+
increment :pending_actions_count
|
|
38
|
+
@jobs << Stepped::ActionJob.new(actor, action_name, *args, parent_step: self)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
save!
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def wait(duration)
|
|
45
|
+
increment! :pending_actions_count
|
|
46
|
+
@jobs << Stepped::WaitJob.new(self).set(wait: duration)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def conclude_job(succeeded = true)
|
|
50
|
+
with_lock do
|
|
51
|
+
raise NoPendingActionsError unless pending_actions_count > 0
|
|
52
|
+
|
|
53
|
+
decrement :pending_actions_count
|
|
54
|
+
increment :unsuccessful_actions_count unless succeeded
|
|
55
|
+
|
|
56
|
+
if pending_actions_count.zero?
|
|
57
|
+
assign_attributes(completed_at: Time.zone.now, status: determine_status)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
save!
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
action.accomplished(self) if pending_actions_count.zero?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def display_position
|
|
67
|
+
definition_index + 1
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
def complete!(status = determine_status)
|
|
72
|
+
update!(completed_at: Time.zone.now, status:)
|
|
73
|
+
action.accomplished self
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def block
|
|
77
|
+
action.definition.steps.fetch(definition_index)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def execute_block
|
|
81
|
+
context = {
|
|
82
|
+
step_id: id,
|
|
83
|
+
parent_action_id: action_id,
|
|
84
|
+
step_no: definition_index,
|
|
85
|
+
block:
|
|
86
|
+
}
|
|
87
|
+
Stepped.handle_exception(context:) do
|
|
88
|
+
action.actor.instance_exec self, *action.arguments, &block
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def determine_status
|
|
93
|
+
return status unless performing?
|
|
94
|
+
unsuccessful_actions_count > 0 ? :failed : :succeeded
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
class NoPendingActionsError < StandardError; end
|
|
98
|
+
end
|
data/config/routes.rb
ADDED