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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 138ce466c572a466794672101dcfeb6b42d8ac5d8e58b549692e0465fd5ea6ac
4
- data.tar.gz: 75da7a33df486ea1bf72b468c81f2525a6ab9ea43d7bd2e3f7efc6bdfef2a561
3
+ metadata.gz: '0687ebc619500dd18bf435c8fb6a67a5523590b811bb51c6f8532f1411ebd93a'
4
+ data.tar.gz: 68d54113a3ef201aad85b083378848bfa633c5f5bbe41cdcd52e2a63a6b10f03
5
5
  SHA512:
6
- metadata.gz: eb388ce39925a70731058f168a48f89a35565df3fa6c4368b89f4223c1ca1a709e56f86488c07a55d4d04f6078024f011db0a6a36276bee696f7dfefe655ddb6
7
- data.tar.gz: ea2c14f6535dfe84f20fedbe887f27a0eede205e0547a92c6fecd79a8f461c6df365be43253ed27394c54c00b08e5e26d85587bcb60d01a6dc8ad205d20b51fa
6
+ metadata.gz: f975911ec89661747af26a32e98063e0e7c567a18c0d08e9f7113f7623cdba777a32a9e98af656612418a2e7640a9bbbe8bef3e415eb3f0189cd97bfbc54175e
7
+ data.tar.gz: 90064479821a90ff7d2c13c2925f6903e962987092b25ae23985d525e1e79acd2b63a4a28a12b5b9024913e1898918ade9f0c2c2c8870c6ec768e45e43dbd5e7
data/README.md CHANGED
@@ -1 +1,313 @@
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
+ The core ideas are:
6
+
7
+ - **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.
8
+ - **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).
9
+ - **Reuse**: optional `checksum` lets Stepped skip work that is already achieved, or share a currently-performing action with multiple parents.
10
+ - **Outbound completion**: actions can be marked outbound (or implemented as a job) and completed later by an external event.
11
+
12
+ ## Installation
13
+
14
+ Add Stepped to your application (Rails `>= 8.1.1`):
15
+
16
+ ```ruby
17
+ gem "stepped"
18
+ ```
19
+
20
+ Then install and run the migrations:
21
+
22
+ ```bash
23
+ bundle install
24
+ bin/rails stepped:install
25
+ bin/rails db:migrate
26
+ ```
27
+
28
+ ## Quick start
29
+
30
+ Stepped hooks into Active Record automatically, so any model can declare actions.
31
+
32
+ If you define an action without steps, Stepped generates a single step that calls the instance method with the same name:
33
+
34
+ ```ruby
35
+ class Car < ApplicationRecord
36
+ stepped_action :drive
37
+
38
+ def drive(miles)
39
+ update!(mileage: mileage + miles)
40
+ end
41
+ end
42
+
43
+ car = Car.find(1)
44
+ car.drive_later(5) # enqueues Stepped::ActionJob
45
+ car.drive_now(5) # runs synchronously (still uses the Stepped state machine)
46
+ ```
47
+
48
+ 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.
49
+
50
+ ## Concepts
51
+
52
+ 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.
53
+
54
+ 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.
55
+
56
+ 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.
57
+
58
+ ## Defining actions
59
+
60
+ 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:
61
+
62
+ ```ruby
63
+ class Car < ApplicationRecord
64
+ stepped_action :visit do
65
+ step do |step, location|
66
+ step.do :change_location, location
67
+ end
68
+
69
+ succeeded do
70
+ update!(last_visited_at: Time.current)
71
+ end
72
+ end
73
+
74
+ def change_location(location)
75
+ update!(location:)
76
+ end
77
+ end
78
+ ```
79
+
80
+ ### Steps and action trees
81
+
82
+ 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:
83
+
84
+ ```ruby
85
+ stepped_action :park do
86
+ step do
87
+ honk
88
+ end
89
+
90
+ step do |step, miles|
91
+ step.do :honk
92
+ step.on [self, nil], :drive, miles
93
+ end
94
+ end
95
+ ```
96
+
97
+ `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.
98
+
99
+ To deliberately fail a step without raising, set `step.status = :failed` inside the step body.
100
+
101
+ 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.
102
+
103
+ ### Waiting
104
+
105
+ Steps can also enqueue a timed wait:
106
+
107
+ ```ruby
108
+ stepped_action :stopover do
109
+ step { |step| step.wait 5.seconds }
110
+ step { honk }
111
+ end
112
+ ```
113
+
114
+ ### Before hooks and argument mutation
115
+
116
+ `before` runs once, before any steps are performed. It can mutate `action.arguments`, or cancel/complete the action early:
117
+
118
+ ```ruby
119
+ stepped_action :multiplied_drive do
120
+ before do |action, distance|
121
+ action.arguments = [distance * 2]
122
+ end
123
+
124
+ step do |step, distance|
125
+ step.do :drive, distance
126
+ end
127
+ end
128
+ ```
129
+
130
+ The checksum (if you define one) is computed after `before`, so it sees the updated arguments.
131
+
132
+ ### After callbacks
133
+
134
+ 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`:
135
+
136
+ ```ruby
137
+ Car.stepped_action :drive, outbound: true do
138
+ after :cancelled, :failed, :timed_out do
139
+ honk
140
+ end
141
+ end
142
+
143
+ Car.after_stepped_action :drive, :succeeded do |action, miles|
144
+ Rails.logger.info("Drove #{miles} miles")
145
+ end
146
+ ```
147
+
148
+ 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.
149
+
150
+ ## Concurrency, queueing, and superseding
151
+
152
+ Every action runs under a `concurrency_key`. Actions with the same key share a performance and therefore run one-at-a-time, in order.
153
+
154
+ 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:
155
+
156
+ ```ruby
157
+ stepped_action :recycle, outbound: true do
158
+ concurrency_key { "Car/maintenance" }
159
+ end
160
+
161
+ stepped_action :paint, outbound: true do
162
+ concurrency_key { "Car/maintenance" }
163
+ end
164
+ ```
165
+
166
+ 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.
167
+
168
+ 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.
169
+
170
+ ## Checksums and reuse (Achievements)
171
+
172
+ 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).
173
+
174
+ ```ruby
175
+ stepped_action :visit do
176
+ checksum { |location| location }
177
+
178
+ step do |step, location|
179
+ step.do :change_location, location
180
+ end
181
+ end
182
+ ```
183
+
184
+ With a checksum in place:
185
+
186
+ 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.
187
+ 2. If an identical checksum has already succeeded (an achievement exists), Stepped returns a `succeeded` action immediately without creating new records.
188
+ 3. If the checksum changes, Stepped performs the action and updates the stored achievement to the new checksum.
189
+
190
+ Use `checksum_key` to control the scope of reuse. Returning an array joins parts with `/`:
191
+
192
+ ```ruby
193
+ checksum_key { ["Car", "visit"] } # shared across all cars
194
+ ```
195
+
196
+ ## Outbound actions and external completion
197
+
198
+ 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):
199
+
200
+ ```ruby
201
+ stepped_action :charge_card, outbound: true do
202
+ step do |step, amount_cents|
203
+ # enqueue calls to external systems here
204
+ end
205
+ end
206
+
207
+ user.charge_card_later(1500)
208
+ user.complete_stepped_action_later(:charge_card, :succeeded)
209
+ ```
210
+
211
+ Under the hood, completion forwards to the current outbound action for that actor+name and advances its performance queue.
212
+
213
+ ## Job-backed actions
214
+
215
+ If you prefer to implement an action as an Active Job, declare it with `job:`. Job-backed actions are treated as outbound and are expected to call `action.complete!` when finished:
216
+
217
+ ```ruby
218
+ class TowJob < ActiveJob::Base
219
+ def perform(action)
220
+ car = action.actor
221
+ location = action.arguments.first
222
+ car.update!(location:)
223
+
224
+ action.complete!
225
+ end
226
+ end
227
+
228
+ class Car < ApplicationRecord
229
+ stepped_action :tow, job: TowJob
230
+ end
231
+ ```
232
+
233
+ You can extend existing actions (including job-backed ones) by prepending steps:
234
+
235
+ ```ruby
236
+ Car.prepend_stepped_action_step :tow do
237
+ honk
238
+ end
239
+ ```
240
+
241
+ ## Timeouts
242
+
243
+ 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`:
244
+
245
+ ```ruby
246
+ stepped_action :change_location, outbound: true, timeout: 5.seconds
247
+ ```
248
+
249
+ Timeouts propagate through the tree: a timed-out nested action fails its parent step, which fails the parent action.
250
+
251
+ ## Exception handling
252
+
253
+ Stepped can either raise exceptions (letting your job backend retry) or treat specific exception classes as handled and turn them into action failure.
254
+
255
+ Configure the handled exception classes in your application:
256
+
257
+ ```ruby
258
+ # config/initializers/stepped.rb (or an environment file)
259
+ Stepped::Engine.config.stepped_actions.handle_exceptions = [StandardError]
260
+ ```
261
+
262
+ When an exception is handled, Stepped reports it via `Rails.error.report` and marks the action/step as `failed` instead of raising.
263
+
264
+ ## Testing
265
+
266
+ 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.
267
+
268
+ ```ruby
269
+ # test/test_helper.rb
270
+ require "stepped/test_helper"
271
+
272
+ class ActiveSupport::TestCase
273
+ include ActiveJob::TestHelper
274
+ include Stepped::TestHelper
275
+
276
+ # If your workflows include outbound actions, complete them here so
277
+ # `perform_stepped_actions` can fully drain the tree.
278
+ def complete_stepped_outbound_performances
279
+ Stepped::Performance.outbounds.includes(:action).find_each do |performance|
280
+ action = performance.action
281
+ Stepped::Performance.outbound_complete(action.actor, action.name, :succeeded)
282
+ end
283
+ end
284
+ end
285
+ ```
286
+
287
+ In a test, you can perform Stepped jobs recursively:
288
+
289
+ ```ruby
290
+ car.visit_later("London")
291
+ perform_stepped_actions
292
+ ```
293
+
294
+ To test failure behavior without bubbling exceptions, you can temporarily mark exception classes as handled:
295
+
296
+ ```ruby
297
+ handle_stepped_action_exceptions(only: [StandardError]) do
298
+ car.visit_now("London")
299
+ end
300
+ ```
301
+
302
+ ## Development
303
+
304
+ Run the test suite:
305
+
306
+ ```sh
307
+ bin/rails db:test:prepare
308
+ bin/rails test
309
+ ```
310
+
311
+ ## License
312
+
313
+ 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