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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '0687ebc619500dd18bf435c8fb6a67a5523590b811bb51c6f8532f1411ebd93a'
|
|
4
|
+
data.tar.gz: 68d54113a3ef201aad85b083378848bfa633c5f5bbe41cdcd52e2a63a6b10f03
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,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,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
|