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.
@@ -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
@@ -0,0 +1,2 @@
1
+ Rails.application.routes.draw do
2
+ end