stepped 0.1.0 → 1.0.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 138ce466c572a466794672101dcfeb6b42d8ac5d8e58b549692e0465fd5ea6ac
4
- data.tar.gz: 75da7a33df486ea1bf72b468c81f2525a6ab9ea43d7bd2e3f7efc6bdfef2a561
3
+ metadata.gz: ff94819a694c7f49934e378c8952edbe7383ddcf53f9ee9da4ee6c6669e5c483
4
+ data.tar.gz: 4feb8d60c62b314bbace0c33bfa78a7850c52adbef3add0c9cc19794bff627a1
5
5
  SHA512:
6
- metadata.gz: eb388ce39925a70731058f168a48f89a35565df3fa6c4368b89f4223c1ca1a709e56f86488c07a55d4d04f6078024f011db0a6a36276bee696f7dfefe655ddb6
7
- data.tar.gz: ea2c14f6535dfe84f20fedbe887f27a0eede205e0547a92c6fecd79a8f461c6df365be43253ed27394c54c00b08e5e26d85587bcb60d01a6dc8ad205d20b51fa
6
+ metadata.gz: 6f751bf74a6a16454a87d3f96b229f6562ca40fd4f9909414091c8fb04338222f3d5920737b3505954d8d741a6dab6261bbc748ca4416093105b63dae80550a2
7
+ data.tar.gz: 532a6ff7f121a7426d02dbe6a9afb2a6c3ebe09cf0da39500949684656c81c13705a8b04e8baa8bb3d89d2e8ee1202aa542b65e7cc89ffa7fced4b579283297f
data/README.md CHANGED
@@ -1 +1,320 @@
1
1
  # Stepped Actions
2
+
3
+ Stepped is a Rails engine for orchestrating complex workflows as a tree of actions. Each action is persisted, runs through Active Job, and can fan out into more actions while keeping the parent action moving step-by-step as dependencies complete.
4
+
5
+ <img width="1024" height="1024" alt="stepped-actions" src="https://github.com/user-attachments/assets/32577a1e-1240-44ec-af0a-493a48ec70ef" />
6
+
7
+ Stepped was extracted out of [Envirobly](https://klevo.sk/projects/envirobly-efficient-application-hosting-platform/) where it powers tasks like application deployment, that involve complex, out-of-the-band tasks like DNS provisioning, retries, waiting for instances to boot, running health checks and all the fun of a highly distributed networked system.
8
+
9
+ ## Concepts
10
+
11
+ - **Action trees**: define a root action with multiple steps; each step can enqueue more actions and the step completes only once all the actions within it complete.
12
+ - **Models are the Actors**: in Rails, your business logic usually centers around database-persisted models. Stepped takes advantage of this and allows you to define and run actions on all your models, out of the box.
13
+ - **Concurrency lanes**: actions with the same `concurrency_key` share a `Stepped::Performance`, so only one runs at a time while others queue up (with automatic superseding of older queued work).
14
+ - **Reuse**: optional `checksum` lets Stepped skip work that is already achieved, or share a currently-performing action with multiple parents. Imagine you need to launch multiple workflows with different outcomes, that all depend on the outcome of the same action, somewhere in the action tree. Stepped makes this easy and efficient.
15
+ - **Outbound completion**: actions can be marked outbound (or implemented as a job) and completed later by an external event.
16
+
17
+ ## Installation
18
+
19
+ Add Stepped to your application (Rails `>= 8.1.1`):
20
+
21
+ ```ruby
22
+ gem "stepped"
23
+ ```
24
+
25
+ Then install and run the migrations:
26
+
27
+ ```bash
28
+ bundle install
29
+ bin/rails stepped:install
30
+ bin/rails db:migrate
31
+ ```
32
+
33
+ ## Quick start
34
+
35
+ Stepped hooks into Active Record automatically, so any model can declare actions.
36
+
37
+ If you define an action without steps, Stepped generates a single step that calls the instance method with the same name:
38
+
39
+ ```ruby
40
+ class Car < ApplicationRecord
41
+ stepped_action :drive
42
+
43
+ def drive(miles)
44
+ update!(mileage: mileage + miles)
45
+ end
46
+ end
47
+
48
+ car = Car.find(1)
49
+ car.drive_later(5) # enqueues Stepped::ActionJob
50
+ car.drive_now(5) # runs synchronously (still uses the Stepped state machine)
51
+ ```
52
+
53
+ Calling `*_now`/`*_later` creates a `Stepped::Action` and a `Stepped::Step` record behind the scenes. If the action finishes immediately, the associated `Stepped::Performance` (the concurrency “lane”) is created and destroyed within the same run. If the action is short-circuited (for example, cancelled/completed in `before`, or skipped due to a matching achievement), Stepped returns an action instance but does not create any database rows.
54
+
55
+ ## Concepts
56
+
57
+ An action is represented by `Stepped::Action` (statuses include `pending`, `performing`, `succeeded`, `failed`, `cancelled`, `superseded`, `timed_out`, and `deadlocked`). Each action executes one step at a time; steps are stored in `Stepped::Step` and complete when all of their dependencies finish.
58
+
59
+ Actions that share a `concurrency_key` are grouped under a `Stepped::Performance`. A performance behaves like a single-file queue: the current action performs, later actions wait as `pending`, and when the current action completes the performance advances to the next incomplete action.
60
+
61
+ If you opt into reuse, successful actions write a `Stepped::Achievement` keyed by `checksum`. When an action is invoked again with the same `checksum`, Stepped can skip the work entirely.
62
+
63
+ ## Defining actions
64
+
65
+ Define an action on an Active Record model with `stepped_action`. The block is a small DSL that lets you specify steps, hooks, and keys:
66
+
67
+ ```ruby
68
+ class Car < ApplicationRecord
69
+ stepped_action :visit do
70
+ step do |step, location|
71
+ step.do :change_location, location
72
+ end
73
+
74
+ succeeded do
75
+ update!(last_visited_at: Time.current)
76
+ end
77
+ end
78
+
79
+ def change_location(location)
80
+ update!(location:)
81
+ end
82
+ end
83
+ ```
84
+
85
+ ### Steps and action trees
86
+
87
+ Each `step` block runs in the actor’s context (`self` is the model instance) and receives `(step, *arguments)`. Inside a step you typically enqueue more actions:
88
+
89
+ ```ruby
90
+ stepped_action :park do
91
+ step do
92
+ honk
93
+ end
94
+
95
+ step do |step, miles|
96
+ step.do :honk
97
+ step.on [self, nil], :drive, miles
98
+ end
99
+ end
100
+ ```
101
+
102
+ `step.do` is shorthand for “run another action on the same actor”. `step.on` accepts a single actor or an array of actors; `nil` values are ignored. If a step enqueues work, the parent action will remain `performing` until those child actions finish and report back.
103
+
104
+ To deliberately fail a step without raising, set `step.status = :failed` inside the step body.
105
+
106
+ The code within the `step` block runs within the model instance context. Therefore you have flexibility to write any model code within this block, not just invoking actions.
107
+
108
+ ### Waiting
109
+
110
+ Steps can also enqueue a timed wait:
111
+
112
+ ```ruby
113
+ stepped_action :stopover do
114
+ step { |step| step.wait 5.seconds }
115
+ step { honk }
116
+ end
117
+ ```
118
+
119
+ ### Before hooks and argument mutation
120
+
121
+ `before` runs once, before any steps are performed. It can mutate `action.arguments`, or cancel/complete the action early:
122
+
123
+ ```ruby
124
+ stepped_action :multiplied_drive do
125
+ before do |action, distance|
126
+ action.arguments = [distance * 2]
127
+ end
128
+
129
+ step do |step, distance|
130
+ step.do :drive, distance
131
+ end
132
+ end
133
+ ```
134
+
135
+ The checksum (if you define one) is computed after `before`, so it sees the updated arguments.
136
+
137
+ ### After callbacks
138
+
139
+ After callbacks run when the action is completed. You can attach them inline (`succeeded`, `failed`, `cancelled`, `timed_out`) or later from elsewhere with `after_stepped_action`:
140
+
141
+ ```ruby
142
+ Car.stepped_action :drive, outbound: true do
143
+ after :cancelled, :failed, :timed_out do
144
+ honk
145
+ end
146
+ end
147
+
148
+ Car.after_stepped_action :drive, :succeeded do |action, miles|
149
+ Rails.logger.info("Drove #{miles} miles")
150
+ end
151
+ ```
152
+
153
+ If an after callback raises and you’ve configured Stepped to handle that exception class, the action status is preserved but the callback is counted as failed and the action will not grant an achievement.
154
+
155
+ ## Concurrency, queueing, and superseding
156
+
157
+ Every action runs under a `concurrency_key`. Actions with the same key share a performance and therefore run one-at-a-time, in order.
158
+
159
+ By default, the key is scoped to the actor and action name (for example `Car/123/visit`). You can override it with `concurrency_key` to coordinate across records or across different actions:
160
+
161
+ ```ruby
162
+ stepped_action :recycle, outbound: true do
163
+ concurrency_key { "Car/maintenance" }
164
+ end
165
+
166
+ stepped_action :paint, outbound: true do
167
+ concurrency_key { "Car/maintenance" }
168
+ end
169
+ ```
170
+
171
+ While one action is `performing`, later actions with the same key are queued as `pending`. If multiple pending actions build up, Stepped supersedes older pending actions in favor of the newest one, and transfers any parent-step dependencies to the newest action so waiting steps don’t get stuck.
172
+
173
+ Stepped also protects you from deadlocks: if a descendant action tries to join the same `concurrency_key` as one of its ancestors, it is marked `deadlocked` and its parent step will fail.
174
+
175
+ ## Checksums and reuse (Achievements)
176
+
177
+ Reuse is opt-in per action via `checksum`. When a checksum is present, Stepped stores the last successful checksum in `Stepped::Achievement` under `checksum_key` (which defaults to the action’s tenancy key).
178
+
179
+ ```ruby
180
+ stepped_action :visit do
181
+ checksum { |location| location }
182
+
183
+ step do |step, location|
184
+ step.do :change_location, location
185
+ end
186
+ end
187
+ ```
188
+
189
+ With a checksum in place:
190
+
191
+ 1. If you invoke an action while an identical checksum is already performing under the same concurrency lane, Stepped returns the existing performing action and attaches the new parent step to it.
192
+ 2. If an identical checksum has already succeeded (an achievement exists), Stepped returns a `succeeded` action immediately without creating new records.
193
+ 3. If the checksum changes, Stepped performs the action and updates the stored achievement to the new checksum.
194
+
195
+ Use `checksum_key` to control the scope of reuse. Returning an array joins parts with `/`:
196
+
197
+ ```ruby
198
+ checksum_key { ["Car", "visit"] } # shared across all cars
199
+ ```
200
+
201
+ ## Outbound actions and external completion
202
+
203
+ An outbound action runs its steps but does not complete automatically when the final step finishes. It stays `performing` until you explicitly complete it (for example, from a webhook handler or another system):
204
+
205
+ ```ruby
206
+ stepped_action :charge_card, outbound: true do
207
+ step do |step, amount_cents|
208
+ # enqueue calls to external systems here
209
+ end
210
+ end
211
+
212
+ user.charge_card_later(1500)
213
+ user.complete_stepped_action_later(:charge_card, :succeeded)
214
+ ```
215
+
216
+ Under the hood, completion forwards to the current outbound action for that actor+name and advances its performance queue.
217
+
218
+ ## Job-backed actions
219
+
220
+ This is especially useful if you'd like to have (delayed) retries on certain errors, that ActiveJob supports out of the box.
221
+
222
+ You can declare it with `job:`. Job-backed actions are treated as outbound and are expected to call `action.complete!` when finished. The action instance is passed as the first and only argument. To work with the action arguments, use the familiar `action.arguments`:
223
+
224
+ ```ruby
225
+ class TowJob < ActiveJob::Base
226
+ def perform(action)
227
+ car = action.actor
228
+ location = action.arguments.first
229
+ car.update!(location:)
230
+
231
+ action.complete!
232
+ end
233
+ end
234
+
235
+ class Car < ApplicationRecord
236
+ stepped_action :tow, job: TowJob
237
+ end
238
+ ```
239
+
240
+ You can extend existing actions (including job-backed ones) by prepending steps:
241
+
242
+ ```ruby
243
+ Car.prepend_stepped_action_step :tow do
244
+ honk
245
+ end
246
+ ```
247
+
248
+ ## Timeouts
249
+
250
+ Set `timeout:` to enqueue a `Stepped::TimeoutJob` when the action starts. If the action is still `performing` after the timeout elapses, it completes as `timed_out`:
251
+
252
+ ```ruby
253
+ stepped_action :change_location, outbound: true, timeout: 5.seconds
254
+ ```
255
+
256
+ Timeouts propagate through the tree: a timed-out nested action fails its parent step, which fails the parent action.
257
+
258
+ ## Exception handling
259
+
260
+ Stepped can either raise exceptions (letting your job backend retry) or treat specific exception classes as handled and turn them into action failure.
261
+
262
+ Configure the handled exception classes in your application:
263
+
264
+ ```ruby
265
+ # config/initializers/stepped.rb (or an environment file)
266
+ Stepped::Engine.config.stepped_actions.handle_exceptions = [StandardError]
267
+ ```
268
+
269
+ When an exception is handled, Stepped reports it via `Rails.error.report` and marks the action/step as `failed` instead of raising.
270
+
271
+ ## Testing
272
+
273
+ Stepped ships with `Stepped::TestHelper` (require `"stepped/test_helper"`) which builds on Active Job’s test helpers to make it easy to drain the full action tree.
274
+
275
+ ```ruby
276
+ # test/test_helper.rb
277
+ require "stepped/test_helper"
278
+
279
+ class ActiveSupport::TestCase
280
+ include ActiveJob::TestHelper
281
+ include Stepped::TestHelper
282
+
283
+ # If your workflows include outbound actions, complete them here so
284
+ # `perform_stepped_actions` can fully drain the tree.
285
+ def complete_stepped_outbound_performances
286
+ Stepped::Performance.outbounds.includes(:action).find_each do |performance|
287
+ action = performance.action
288
+ Stepped::Performance.outbound_complete(action.actor, action.name, :succeeded)
289
+ end
290
+ end
291
+ end
292
+ ```
293
+
294
+ In a test, you can perform Stepped jobs recursively:
295
+
296
+ ```ruby
297
+ car.visit_later("London")
298
+ perform_stepped_actions
299
+ ```
300
+
301
+ To test failure behavior without bubbling exceptions, you can temporarily mark exception classes as handled:
302
+
303
+ ```ruby
304
+ handle_stepped_action_exceptions(only: [StandardError]) do
305
+ car.visit_now("London")
306
+ end
307
+ ```
308
+
309
+ ## Development
310
+
311
+ Run the test suite:
312
+
313
+ ```sh
314
+ bin/rails db:test:prepare
315
+ bin/rails test
316
+ ```
317
+
318
+ ## License
319
+
320
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"
@@ -0,0 +1,10 @@
1
+ class Stepped::ActionJob < ActiveJob::Base
2
+ queue_as :default
3
+
4
+ def perform(actor, name, *arguments, parent_step: nil)
5
+ root = parent_step.nil?
6
+ action = Stepped::Action.new(actor:, name:, arguments:, root:)
7
+ action.parent_steps << parent_step if parent_step.present?
8
+ action.obtain_lock_and_perform
9
+ end
10
+ end
@@ -0,0 +1,7 @@
1
+ class Stepped::CompleteActionJob < ActiveJob::Base
2
+ queue_as :default
3
+
4
+ def perform(actor, name, status = :succeeded)
5
+ Stepped::Performance.outbound_complete(actor, name, status)
6
+ end
7
+ end
@@ -0,0 +1,11 @@
1
+ class Stepped::TimeoutJob < ActiveJob::Base
2
+ queue_as :default
3
+
4
+ def perform(action)
5
+ return unless action.performing?
6
+
7
+ if action.started_at < action.timeout.ago
8
+ action.complete!(:timed_out)
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,7 @@
1
+ class Stepped::WaitJob < ActiveJob::Base
2
+ queue_as :default
3
+
4
+ def perform(step)
5
+ step.conclude_job
6
+ end
7
+ end
@@ -0,0 +1,37 @@
1
+ module Stepped::Actionable
2
+ extend ActiveSupport::Concern
3
+
4
+ class_methods do
5
+ def stepped_action(name, outbound: false, timeout: nil, job: nil, &block)
6
+ Stepped::Registry.add(self, name, outbound:, timeout:, job:, &block)
7
+
8
+ define_method "#{name}_now" do |*arguments|
9
+ Stepped::ActionJob.perform_now(self, name, *arguments)
10
+ end
11
+
12
+ define_method "#{name}_later" do |*arguments|
13
+ Stepped::ActionJob.perform_later(self, name, *arguments)
14
+ end
15
+ end
16
+
17
+ def prepend_stepped_action_step(name, &step_block)
18
+ Stepped::Registry.prepend_step(self, name, &step_block)
19
+ end
20
+
21
+ def after_stepped_action(action_name, *statuses, &block)
22
+ Stepped::Registry.append_after_callback(self, action_name, *statuses, &block)
23
+ end
24
+ end
25
+
26
+ def stepped_action_tenancy_key(action_name)
27
+ [ self.class.name, id, action_name ].join("/")
28
+ end
29
+
30
+ def complete_stepped_action_now(name, status = :succeeded)
31
+ Stepped::CompleteActionJob.perform_now self, name, status
32
+ end
33
+
34
+ def complete_stepped_action_later(name, status = :succeeded)
35
+ Stepped::CompleteActionJob.perform_later self, name, status
36
+ end
37
+ end
@@ -0,0 +1,25 @@
1
+ class Stepped::Achievement < ActiveRecord::Base
2
+ class << self
3
+ def exists_for?(action)
4
+ return false if action.checksum.nil?
5
+
6
+ exists? action.attributes.slice("checksum_key", "checksum")
7
+ end
8
+
9
+ def raise_if_exists_for?(action)
10
+ if exists_for?(action)
11
+ raise ExistsError
12
+ end
13
+ end
14
+
15
+ def grand_to(action)
16
+ create! action.attributes.slice("checksum_key", "checksum")
17
+ end
18
+
19
+ def erase_of(action)
20
+ where(action.attributes.slice("checksum_key")).destroy_all
21
+ end
22
+ end
23
+
24
+ class ExistsError < StandardError; end
25
+ end