standard_procedure_operations 0.5.3 → 0.7.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 +163 -474
- data/app/jobs/operations/delete_old_task_job.rb +5 -0
- data/app/jobs/operations/wake_task_job.rb +5 -0
- data/app/models/concerns/operations/participant.rb +4 -9
- data/app/models/operations/task/index.rb +22 -0
- data/app/models/operations/task/plan/action_handler.rb +20 -0
- data/app/models/operations/task/plan/decision_handler.rb +40 -0
- data/app/models/operations/task/plan/interaction_handler.rb +20 -0
- data/app/models/operations/task/plan/result_handler.rb +9 -0
- data/app/models/operations/task/{state_management → plan}/wait_handler.rb +8 -6
- data/app/models/operations/task/plan.rb +70 -0
- data/app/models/operations/task/runner.rb +34 -0
- data/app/models/operations/task.rb +50 -68
- data/app/models/operations/task_participant.rb +2 -5
- data/db/migrate/20250701190516_rename_existing_operations_tables.rb +19 -0
- data/db/migrate/20250701190716_create_new_operations_tasks.rb +20 -0
- data/db/migrate/20250702113801_create_task_participants.rb +10 -0
- data/lib/operations/invalid_state.rb +2 -0
- data/lib/operations/version.rb +1 -1
- data/lib/operations.rb +2 -3
- data/lib/tasks/operations_tasks.rake +3 -3
- metadata +17 -20
- data/app/jobs/operations/task_runner_job.rb +0 -11
- data/app/models/operations/task/background.rb +0 -39
- data/app/models/operations/task/data_carrier.rb +0 -18
- data/app/models/operations/task/deletion.rb +0 -17
- data/app/models/operations/task/exports.rb +0 -45
- data/app/models/operations/task/input_validation.rb +0 -17
- data/app/models/operations/task/state_management/action_handler.rb +0 -17
- data/app/models/operations/task/state_management/completion_handler.rb +0 -14
- data/app/models/operations/task/state_management/decision_handler.rb +0 -49
- data/app/models/operations/task/state_management.rb +0 -39
- data/app/models/operations/task/testing.rb +0 -62
- data/db/migrate/20250127160616_create_operations_tasks.rb +0 -17
- data/db/migrate/20250309160616_create_operations_task_participants.rb +0 -15
- data/db/migrate/20250403075414_add_becomes_zombie_at_field.rb +0 -6
- data/lib/operations/cannot_wait_in_foreground.rb +0 -2
- data/lib/operations/exporters/svg.rb +0 -399
data/README.md
CHANGED
@@ -7,136 +7,89 @@ Most times when I'm adding a feature to a complex application, I tend to end up
|
|
7
7
|
|
8
8
|
In effect, that flowchart is a state machine - with "decision states" and "action states". And Operations is intended to be a way of designing your ruby class so that flowchart becomes easy to follow.
|
9
9
|
|
10
|
-
##
|
11
|
-
Here's a simplified example from [Collabor8Online](https://www.collabor8online.co.uk) - in C8O when you download a document, we need to check your access rights, as well as ensuring that the current user has not breached their monthly download limit. In addition, some accounts have a "filename scrambler" switched on - where the original filename is replaced (which is a feature used by some of our clients on their customers' trial accounts).
|
10
|
+
## Breaking Change
|
12
11
|
|
13
|
-
|
14
|
-
The flowchart, for this simplified example, is something like this:
|
12
|
+
Version 0.7.0 includes breaking changes. There are migrations which rename your existing `operations_tasks` and `operations_task_participants` tables (so the data is not deleted), then a new `operations_tasks` table is created with a simplified structure.
|
15
13
|
|
16
|
-
|
17
|
-
START -> CHECK AUTHORISATION
|
18
|
-
Is this user authorised?
|
19
|
-
NO -> FAIL
|
20
|
-
YES -> CHECK DOWNLOAD LIMITS
|
21
|
-
|
22
|
-
CHECK DOWNLOAD LIMITS
|
23
|
-
Is this user within their monthly download limit?
|
24
|
-
NO -> FAIL
|
25
|
-
YES -> CHECK FILENAME SCRAMBLER
|
26
|
-
|
27
|
-
CHECK FILENAME SCRAMBLER
|
28
|
-
Is the filename scrambler switched on for this account?
|
29
|
-
NO -> PREPARE DOWNLOAD
|
30
|
-
YES -> SCRAMBLE FILENAME
|
31
|
-
|
32
|
-
SCRAMBLE FILENAME
|
33
|
-
Replace the filename with a scrambled one
|
34
|
-
THEN -> PREPARE DOWNLOAD
|
35
|
-
|
36
|
-
PREPARE DOWNLOAD
|
37
|
-
Return the document's filename so it can be used when sending the document to the end user
|
38
|
-
DONE
|
39
|
-
```
|
14
|
+
## Usage
|
40
15
|
|
41
|
-
|
16
|
+
### Drawing up a plan
|
42
17
|
|
43
|
-
Here's
|
18
|
+
Here's a simple example for planning a party.
|
44
19
|
|
45
20
|
```ruby
|
46
|
-
class
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
condition {
|
21
|
+
class PlanAParty < Operations::Task
|
22
|
+
has_attribute :date
|
23
|
+
validates :date, presence: true
|
24
|
+
has_models :friends
|
25
|
+
has_model :food_shop
|
26
|
+
has_model :beer_shop
|
27
|
+
has_models :available_friends
|
28
|
+
starts_with :what_day_is_it?
|
29
|
+
|
30
|
+
decision :what_day_is_it? do
|
31
|
+
condition { date.wday == 6 }
|
32
|
+
go_to :buy_food
|
33
|
+
condition { date.wday == 0 }
|
34
|
+
go_to :relax
|
35
|
+
condition { date.wday.in? [1, 2, 3, 4, 5]}
|
36
|
+
go_to :go_to_work
|
37
|
+
end
|
59
38
|
|
60
|
-
|
61
|
-
|
39
|
+
action :buy_food do
|
40
|
+
food_shop.order_party_food
|
62
41
|
end
|
42
|
+
go_to :buy_beer
|
63
43
|
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
if_true :scramble_filename
|
68
|
-
if_false :return_filename
|
44
|
+
action :buy_beer do
|
45
|
+
beer_shop.order_drinks
|
69
46
|
end
|
47
|
+
go_to :invite_friends
|
70
48
|
|
71
|
-
action :
|
72
|
-
self.
|
49
|
+
action :invite_friends do
|
50
|
+
self.available_friends = friends.select { |friend| friend.available_on? date }
|
73
51
|
end
|
74
|
-
go_to :
|
52
|
+
go_to :party!
|
75
53
|
|
76
|
-
result :
|
77
|
-
|
78
|
-
|
54
|
+
result :party!
|
55
|
+
result :relax
|
56
|
+
result :go_to_work
|
79
57
|
end
|
80
|
-
|
81
|
-
task = PrepareDocumentForDownload.call user: @user, document: @document, use_filename_scrambler: @account.feature_flags[:use_filename_scramber]
|
82
|
-
puts task.results[:filename]
|
83
58
|
```
|
84
59
|
|
85
|
-
|
60
|
+
This task expects a date, a list of friends and a place to buy food and beer and consists of seven _states_ - `what_day_is_it?`, `buy_food`, `buy_beer`, `invite_fiends`, `party!`, `relax` and `go_to_work`.
|
86
61
|
|
87
|
-
|
88
|
-
|
89
|
-
### Decisions
|
90
|
-
A decision handler evaluates a condition, then changes state depending upon if the result is true or false.
|
62
|
+
We would start the task as follows:
|
91
63
|
|
92
64
|
```ruby
|
93
|
-
|
94
|
-
condition { Date.today.wday.in? [0, 6] }
|
65
|
+
task = PlanAParty.call date: Date.today, friends: @friends, food_shop: @food_shop, beer_shop: @beer_shop
|
95
66
|
|
96
|
-
|
97
|
-
|
98
|
-
|
67
|
+
expect(task).to be_completed
|
68
|
+
# If it's a weekday
|
69
|
+
expect(task).to be_in "go_to_work"
|
70
|
+
# If it's Sunday
|
71
|
+
expect(task).to be_in "relax"
|
72
|
+
# If it's Saturday
|
73
|
+
expect(task).to be_in "party!"
|
74
|
+
expect(task.available_friends).to_not be_empty
|
99
75
|
```
|
100
|
-
|
101
|
-
```ruby
|
102
|
-
decision :authorised? do
|
103
|
-
condition { user.administrator? }
|
76
|
+
We define the `attributes` that the task contains and its starting `state`.
|
104
77
|
|
105
|
-
|
106
|
-
if_false { fail_with "Unauthorised" }
|
107
|
-
end
|
108
|
-
```
|
109
|
-
(In theory the block used in the `fail_with` case can do anything within the [DataCarrier context](#data-and-results) - so you could set internal state or call methods on the containing task - but I've not tried this yet).
|
78
|
+
The initial state is `what_day_is_it?` which is a _decision_ that checks the date supplied and moves to a different state based upon the conditions defined. `buy_food`, `buy_drinks` and `invite_friends` are _actions_ which do things. Whereas `party!`, `relax` and `go_to_work` are _results_ which end the task.
|
110
79
|
|
111
|
-
|
80
|
+
When you `call` the task, it runs through the process immediately and either fails with an exception or completes immediately. You can test `completed?` or `failed?` and check the `current_state`.
|
112
81
|
|
113
|
-
|
114
|
-
decision :is_the_weather_good? do
|
115
|
-
condition { weather_forecast.sunny? }
|
116
|
-
go_to :the_beach
|
117
|
-
condition { weather_forecast.rainy? }
|
118
|
-
go_to :grab_an_umbrella
|
119
|
-
condition { weather_forecast.snowing? }
|
120
|
-
go_to :build_a_snowman
|
121
|
-
end
|
122
|
-
```
|
123
|
-
If no conditions are matched then the task fails with a `NoDecision` exception.
|
82
|
+
If you prefer, `call` is alised as `perform_now`.
|
124
83
|
|
125
|
-
|
126
|
-
```ruby
|
127
|
-
decision :authorised? do
|
128
|
-
inputs :user
|
129
|
-
optional :override
|
130
|
-
condition { override || user.administrator? }
|
84
|
+
### States
|
131
85
|
|
132
|
-
|
133
|
-
if_false { fail_with "Unauthorised" }
|
134
|
-
end
|
135
|
-
```
|
136
|
-
In this case, the task will fail (with an `ArgumentError`) if there is no `user` specified. However, `override` is optional (in fact the `optional` method does nothing and is just there for documentation purposes).
|
86
|
+
`States` are the heart of each task. Each `state` defines a `handler` which does something, then moves to another `state`.
|
137
87
|
|
138
|
-
|
139
|
-
|
88
|
+
You can test the current state of a task via its `current_state` attribute, or by the helper method `in? "some_state"`.
|
89
|
+
|
90
|
+
### Action Handlers
|
91
|
+
|
92
|
+
An action handler does some work, and then transitions to another state. Once the action is completed, the task moves to the next state, which is specified using the `go_to` method or with a `then` declaration.
|
140
93
|
|
141
94
|
```ruby
|
142
95
|
action :have_a_party do
|
@@ -146,25 +99,35 @@ action :have_a_party do
|
|
146
99
|
end
|
147
100
|
go_to :send_invitations
|
148
101
|
```
|
149
|
-
|
150
|
-
You can also specify the required and optional data for your action handler using parameters or within the block. `optional` is decorative and helps with documentation. When using the block form, ensure you call `inputs` at the start of the block so that the task fails before doing any meaningful work.
|
151
|
-
|
102
|
+
This is the same as:
|
152
103
|
```ruby
|
153
|
-
action :have_a_party do
|
154
|
-
inputs :number_of_guests
|
155
|
-
optional :music
|
104
|
+
action :have_a_party do
|
156
105
|
self.food = task.buy_some_food_for(number_of_guests)
|
157
106
|
self.beer = task.buy_some_beer_for(number_of_guests)
|
158
|
-
self.music
|
107
|
+
self.music = task.plan_a_party_playlist
|
108
|
+
end.then :send_invitations
|
109
|
+
```
|
110
|
+
|
111
|
+
[Example action handler](/spec/examples/single_action_spec.rb)
|
112
|
+
|
113
|
+
### Decision Handlers
|
114
|
+
|
115
|
+
A decision handler evaluates a condition, then changes state depending upon the result.
|
116
|
+
|
117
|
+
The simplest tests a boolean condition.
|
118
|
+
|
119
|
+
```ruby
|
120
|
+
decision :is_it_the_weekend? do
|
121
|
+
condition { Date.today.wday.in? [0, 6] }
|
122
|
+
if_true :have_a_party
|
123
|
+
if_false :go_to_work
|
159
124
|
end
|
160
|
-
go_to :send_invitations
|
161
125
|
```
|
162
126
|
|
163
|
-
|
164
|
-
Wait handlers are very similar to decision handlers but only work within [background tasks](#background-operations-and-pauses).
|
127
|
+
Alternatively, you can evaluate multiple conditions in your decision handler.
|
165
128
|
|
166
129
|
```ruby
|
167
|
-
|
130
|
+
decision :is_the_weather_good? do
|
168
131
|
condition { weather_forecast.sunny? }
|
169
132
|
go_to :the_beach
|
170
133
|
condition { weather_forecast.rainy? }
|
@@ -174,426 +137,150 @@ wait_until :weather_forecast_available? do
|
|
174
137
|
end
|
175
138
|
```
|
176
139
|
|
177
|
-
If no conditions are
|
140
|
+
If no conditions are matched then the task fails with a `NoDecision` exception.
|
178
141
|
|
179
|
-
|
180
|
-
A result handler marks the end of an operation, optionally returning some results. You need to copy your desired results from your [data](#data-and-results) to your results object. This is so only the information that matters to you is stored as the results.
|
142
|
+
As a convention, use a question to name your decision handlers.
|
181
143
|
|
182
|
-
|
183
|
-
action :send_invitations do
|
184
|
-
self.invited_friends = (0..number_of_guests).collect do |i|
|
185
|
-
friend = friends.pop
|
186
|
-
FriendsMailer.with(recipient: friend).party_invitation.deliver_later unless friend.nil?
|
187
|
-
friend
|
188
|
-
end.compact
|
189
|
-
|
190
|
-
go_to :ready_to_party
|
191
|
-
end
|
144
|
+
[Example decision handler](/spec/examples/conditional_action_spec.rb)
|
192
145
|
|
193
|
-
|
194
|
-
results.invited_friends = invited_friends
|
195
|
-
end
|
196
|
-
```
|
197
|
-
After this result handler has executed, the task will then be marked as `completed?`, the task's state will be `ready_to_party` and `results[:invited_friends]` will contain an array of the people you sent invitations to.
|
146
|
+
### Result Handlers
|
198
147
|
|
199
|
-
|
200
|
-
```ruby
|
201
|
-
result :go_to_work
|
202
|
-
```
|
203
|
-
In this case, the task will be marked as `completed?`, the task's state will be `go_to_work` and `results` will be empty.
|
148
|
+
A result handler marks the end of an operation. It's pretty simple.
|
204
149
|
|
205
|
-
You can also specify the required and optional data for your result handler within the block. `optional` is decorative and to help with your documentation. Ensure you call `inputs` at the start of the block.
|
206
150
|
```ruby
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
self.invited_friends = (0..number_of_guests).collect do |i|
|
211
|
-
friend = friends.pop
|
212
|
-
FriendsMailer.with(recipient: friend).party_invitation.deliver_later unless friend.nil?
|
213
|
-
friend
|
214
|
-
end.compact
|
151
|
+
result :done
|
152
|
+
```
|
215
153
|
|
216
|
-
|
217
|
-
end
|
154
|
+
After this result handler has executed, the task will then be marked as `completed?` and the task's `current_state` will be "done".
|
218
155
|
|
219
|
-
|
220
|
-
inputs :invited_friends
|
156
|
+
### Waiting and interactions
|
221
157
|
|
222
|
-
|
223
|
-
end
|
224
|
-
```
|
158
|
+
Many processes involve waiting for some external event to take place.
|
225
159
|
|
226
|
-
|
227
|
-
You would use the earlier [PrepareDocumentForDownload](spec/examples/prepare_document_for_download_spec.rb) operation in a controller like this:
|
160
|
+
A great example is user registration. The administrator sends an invitation email, the recipient clicks the link, enters their details, and once completed, the user record is created. This can be modelled as follows:
|
228
161
|
|
229
162
|
```ruby
|
230
|
-
class
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
163
|
+
class UserRegistrationExample < Operations::Task
|
164
|
+
has_attribute :email, :string
|
165
|
+
validates :email, presence: true
|
166
|
+
has_attribute :name, :string
|
167
|
+
has_model :user, "User"
|
168
|
+
delay 1.hour
|
169
|
+
timeout 7.days
|
170
|
+
starts_with :send_invitation
|
171
|
+
|
172
|
+
action :send_invitation do
|
173
|
+
UserMailer.with(email: email).invitation.deliver_later
|
239
174
|
end
|
240
|
-
|
241
|
-
```
|
242
|
-
|
243
|
-
OK - so that's a pretty longwinded way of performing a simple task.
|
244
|
-
|
245
|
-
But many operations end up as complex flows of conditionals and actions, often spread across multiple classes and objects. This means that someone trying to understand the rules for an operation can spend a lot of time tracing through code, understanding that flow.
|
246
|
-
|
247
|
-
In Collabor8Online, the actual operation for handling downloads has over twenty states, with half of them being decisions (there are a number of feature flags and per-account configuration options which need to be considered). Now they are defined as four ruby classes (using [composition](#sub-tasks) to split the workload) and the logic is extremely easy to follow.
|
248
|
-
|
249
|
-
### Data and results
|
250
|
-
Each operation carries its own, mutable, [data](/app/models/operations/task/data_carrier.rb) for the duration of the operation.
|
251
|
-
|
252
|
-
This is provided when you `call` the operation to start it and is passed through to each decision, action and result. If you modify the data then that modification is passed on to the next handler.
|
175
|
+
go_to :name_provided?
|
253
176
|
|
254
|
-
|
177
|
+
wait_until :name_provided? do
|
178
|
+
condition { name.present? }
|
179
|
+
go_to :create_user
|
180
|
+
end
|
255
181
|
|
256
|
-
|
257
|
-
|
258
|
-
|
259
|
-
starts_with :build_name
|
182
|
+
interaction :register! do |name|
|
183
|
+
self.name = name
|
184
|
+
end.when :name_provided?
|
260
185
|
|
261
|
-
action :
|
262
|
-
self.
|
263
|
-
go_to :done
|
186
|
+
action :create_user do
|
187
|
+
self.user = User.create! name: name
|
264
188
|
end
|
189
|
+
go_to :done
|
265
190
|
|
266
|
-
result :done
|
267
|
-
results.name = name
|
268
|
-
end
|
191
|
+
result :done
|
269
192
|
end
|
270
|
-
|
271
|
-
task = CombineNames.call first_name: "Alice", last_name: "Aardvark"
|
272
|
-
task.results[:name] # => Alice Aardvark
|
273
193
|
```
|
194
|
+
#### Wait handlers
|
274
195
|
|
275
|
-
|
276
|
-
|
277
|
-
Both your task's `data` and its final `results` are stored in the database, so they can be examined later. The `results` because that's what you're interested in, the `data` as it can be useful for debugging or auditing purposes.
|
196
|
+
The registration process performs an action, `send_invitation` and then waits until a `name_provided?`. A `wait handler` is similar to a `decision handler` but if the conditions are not met, instead of raising an error, the task goes to sleep. A background process (see bwlow) wakes the task periodically to reevaluate the condition. Or, an `interaction` can be triggered; this is similar to an action because it does something, but it also immediately reevaluates the current wait handler. So in this case, when the `register!` interaction completes, the `name_provided?` wait handler is reevaluated and, because the `name` has now been supplied, it can move on to the `create_user` state.
|
278
197
|
|
279
|
-
|
198
|
+
When a task reaches a wait handler, it goes to sleep and expects to be woken up at some point in the future. You can specify how often it is woken up by adding a `delay 10.minutes` declaration to your class. The default is `1.minute`. Likewise, if a task does not change state after a certain period it fails with an `Operations::Timeout` exception. You can set this timeout by declaring `timeout 48.hours` (the default is `24.hours`).
|
280
199
|
|
281
|
-
|
200
|
+
Like decisions, use a question as the name for your wait handlers.
|
282
201
|
|
283
|
-
|
202
|
+
#### Interactions
|
284
203
|
|
285
|
-
Also note that the
|
204
|
+
Interactions are defined with the `interaction` declaration and they always wake the task. The handler adds a new method to the task object - so in this case you would call `@user_registration.register! "Alice"` - this would wake the task, call the `register!` interaction handler, which in turn sets the name to `Alice`. The wait handler would then be evaluated and the "create_user" and "done" states would be executed. Also note that the `register!` interaction can only be called when the state is `name_provided?`. This means that, if Alice registers, then someone hacks her email and uses the same invitation again, when the `register!` method is called, it will fail with an `Operations::InvalidState` exception - because Alice has already registered, the current state is "done" meaning this interaction cannot be called.
|
286
205
|
|
287
|
-
|
206
|
+
As a convention, use an exclamation mark to name your interaction handlers.
|
288
207
|
|
289
|
-
|
208
|
+
#### Background processor
|
290
209
|
|
291
|
-
|
292
|
-
|
293
|
-
For example, you create your task as:
|
294
|
-
```ruby
|
295
|
-
@alice = User.find 123
|
296
|
-
@task = DoSomethingImportant.call user: @alice
|
297
|
-
```
|
298
|
-
There will be a `TaskParticipant` record with a `context` of "data", `role` of "user" and `participant` of `@alice`.
|
299
|
-
|
300
|
-
Likewise, you can see all the tasks that Alice was involved with using:
|
301
|
-
```ruby
|
302
|
-
@alice.involved_in_operations_as("user") # => collection of tasks where Alice was a "user" in the "data" collection
|
303
|
-
@alice.involved_in_operations_as("user", context: "results") # => collection of tasks where Alice was a "user" in the "results" collection
|
304
|
-
```
|
305
|
-
|
306
|
-
### Failures and exceptions
|
307
|
-
If any handlers raise an exception, the task will be terminated. It will be marked as `failed?` and the `results` hash will contain `results[:failure_message]`, `results[:exception_class]` and `results[:exception_backtrace]` for the exception's message, class name and backtrace respectively.
|
308
|
-
|
309
|
-
You can also stop a task at any point by calling `fail_with message`. This will raise an `Operations::Failure` exception, marking the task as `failed?` and the `results` has will contain `results[:failure_message]`.
|
310
|
-
|
311
|
-
### Task life-cycle and the database
|
312
|
-
There is an ActiveRecord migration that creates the `operations_tasks` table. Use `bin/rails operations:install:migrations` to copy it to your application, then run `bin/rails db:migrate` to add the table to your application's database.
|
210
|
+
In order for `wait handlers` and `interactions` to work, you need to wake up the sleeping tasks by calling `Operations::Task.wake_sleeping`. You can add this to a rake task that is triggered by a cron job, or if you use SolidQueue you can add it to your `recurring.yml`. Alternatively, you can run `Operations::Task::Runner.start` - this is a long running process that wakes sleeping tasks every 30 seconds (and deletes old tasks).
|
313
211
|
|
314
|
-
|
212
|
+
#### Starting tasks in the background
|
315
213
|
|
316
|
-
|
317
|
-
- you can access the results (or error state) of a task after it has completed
|
318
|
-
- you can use [TurboStream broadcasts](https://turbo.hotwired.dev/handbook/streams) to update your user-interface as the state changes - see "[status messages](#status-messages)" below
|
319
|
-
- tasks can run in the background (using ActiveJob) and other parts of your code can interact with them whilst they are in progress - see "[background operations](#background-operations-and-pauses)" below
|
320
|
-
- the tasks table acts as an audit trail or activity log for your application
|
214
|
+
When a task is started, it runs in the current thread - so if you start the task within a controller, it will run in the context of your web request. When it reaches a wait handler, the execution stops and control returns to the caller. The background processor then uses ActiveJob to wake the task at regular intervals, evaluating the wait handler and either progressing if a condition is met or going back to sleep if the conditions are not met. Because tasks go to sleep and the job that is processing it then ends, you should be able to create hundreds of tasks at any one time without starving your application of ActiveJob workers (although there may be delays when processing if your queues are full).
|
321
215
|
|
322
|
-
|
216
|
+
If you want the task to be run completely in the background (so it sleeps immediately and then starts when the background processor wakes it), you can call `MyTask.later(...)` (which is also aliased as `perform_later`).
|
323
217
|
|
324
|
-
|
325
|
-
Documentation coming soon.
|
218
|
+
[Example wait and interaction handlers](spec/examples/waiting_and_interactions_spec.rb)
|
326
219
|
|
327
220
|
### Sub tasks
|
328
|
-
Any operation can be composed out of other operations and can therefore call other subtasks.
|
329
221
|
|
330
|
-
|
331
|
-
class PrepareDownload < Operations::Task
|
332
|
-
inputs :user, :document
|
333
|
-
starts_with :get_authorisation
|
334
|
-
|
335
|
-
action :get_authorisation do
|
336
|
-
inputs :user, :document
|
337
|
-
|
338
|
-
results = call GetAuthorisation, user: user, document: document
|
339
|
-
self.authorised = results[:authorised]
|
340
|
-
end
|
341
|
-
go_to :whatever_happens_next
|
342
|
-
end
|
343
|
-
```
|
344
|
-
If the sub-task succeeds, `call` returns the results from the sub-task. If it fails, then any exceptions are re-raised.
|
222
|
+
If your task needs to start sub-tasks, it can use the `start` method, passing the sub-task class and arguments.
|
345
223
|
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
inputs :user, :document
|
350
|
-
starts_with :get_authorisation
|
351
|
-
|
352
|
-
action :get_authorisation do
|
353
|
-
inputs :user, :document
|
354
|
-
|
355
|
-
call GetAuthorisation, user: user, document: document do |results|
|
356
|
-
self.authorised = results[:authorised]
|
357
|
-
end
|
358
|
-
end
|
359
|
-
go_to :whatever_happens_next
|
224
|
+
```ruby
|
225
|
+
action :start_sub_tasks do
|
226
|
+
3.times { |i| start OtherThingTask, number: i }
|
360
227
|
end
|
361
228
|
```
|
362
|
-
|
363
|
-
If you want the sub-task to be a [background task](#background-operations-and-pauses), use `start` instead of `call`. This will return the newly created sub-task immediately. As the sub-task will not have completed, you cannot access its results unless you [wait](#waiting-for-sub-tasks-to-complete) (which is only possible if the parent task is a background task as well).
|
364
|
-
|
365
|
-
### Background operations and pauses
|
366
|
-
If you have ActiveJob configured, you can run your operations in the background.
|
367
|
-
|
368
|
-
Instead of using `call`, use `start` to initiate the operation. This takes the same data parameters and returns a task object that you can refer back to. But it will be `waiting?` instead of `in_progress?` or `completed?`. An `Operations::TaskRunnerJob` will be queued and it will mark the task as `in_progress?`, then call a _single_ state handler. Instead of handling the next state immediately another `Operations::TaskRunnerJob` is queued. And so on, until the task either fails or is completed.
|
369
|
-
|
370
|
-
By itself, this is not particularly useful - it just makes your operation take even longer to complete.
|
371
|
-
|
372
|
-
But if your operation may need to wait for something else to happen, background tasks are perfect.
|
373
|
-
|
374
|
-
#### Waiting for user input
|
375
|
-
For example, maybe you're handling a user registration process and you need to wait until the verification link has been clicked. The verification link goes to another controller and updates the user record in question. Once clicked, you can then notify the administrator.
|
229
|
+
Sub-tasks are always started in the background so they do not block the progress of their parent task. You can then track those sub-tasks using the `sub_tasks`, `active_sub_tasks`, `completed_sub_tasks` and `failed_sub_tasks` associations in a wait handler.
|
376
230
|
|
377
231
|
```ruby
|
378
|
-
|
379
|
-
|
380
|
-
|
381
|
-
|
382
|
-
|
383
|
-
self.user = User.create! email: email
|
384
|
-
end
|
385
|
-
go_to :send_verification_email
|
386
|
-
|
387
|
-
action :send_verification_email do
|
388
|
-
UserMailer.with(user: user).verification_email.deliver_later
|
389
|
-
end
|
390
|
-
go_to :verified?
|
391
|
-
|
392
|
-
wait_until :verified? do
|
393
|
-
condition { user.verified? }
|
394
|
-
go_to :notify_administrator
|
395
|
-
end
|
396
|
-
|
397
|
-
action :notify_administrator do
|
398
|
-
AdminMailer.with(user: user).verification_completed.deliver_later
|
399
|
-
end
|
232
|
+
wait_until :sub_tasks_have_completed? do
|
233
|
+
condition { sub_tasks.all? { |st| st.completed? } }
|
234
|
+
go_to :all_sub_tasks_completed
|
235
|
+
condition { sub_tasks.any? { |st| st.failed? } }
|
236
|
+
go_to :some_sub_tasks_failed
|
400
237
|
end
|
401
|
-
|
402
|
-
@task = UserRegistration.start email: "someone@example.com"
|
403
238
|
```
|
404
|
-
Because background tasks use ActiveJobs, every time the `verified?` condition is evaluated, the task object (and hence its data) will be reloaded from the database. So the `user.verified?` property will be refreshed on each evaluation.
|
405
|
-
|
406
|
-
#### Waiting for sub-tasks to complete
|
407
|
-
Alternatively, you may have a number of sub-tasks that you want to run in parallel then continue once they have all completed. This allows you to spread their execution across multiple processes or even servers (depending upon how your job queue processes are configured).
|
408
|
-
|
409
|
-
```ruby
|
410
|
-
class ParallelTasks < Operations::Task
|
411
|
-
inputs :number_of_sub_tasks
|
412
|
-
starts_with :start_sub_tasks
|
413
|
-
|
414
|
-
action :start_sub_tasks do
|
415
|
-
self.sub_tasks = (1..number_of_sub_tasks).collect { |i| start LongRunningTask, number: i }
|
416
|
-
end
|
417
|
-
go_to :do_something_else
|
418
|
-
|
419
|
-
action :do_something_else do
|
420
|
-
# do something else while the sub-tasks do their thing
|
421
|
-
end
|
422
|
-
go_to :sub_tasks_completed?
|
423
|
-
|
424
|
-
wait_until :sub_tasks_completed? do
|
425
|
-
condition { sub_tasks.all? { |t| t.completed? } }
|
426
|
-
go_to :done
|
427
|
-
end
|
428
239
|
|
429
|
-
|
430
|
-
end
|
431
|
-
|
432
|
-
@task = ParallelTasks.start number_of_sub_tasks: 5
|
433
|
-
```
|
434
|
-
The principle is the same as above; we store the newly created sub-tasks in our own `data` property. As they are ActiveRecord models, they get reloaded each time `sub_tasks_completed?` is evaluated - and we check to see that they have all completed before moving on.
|
240
|
+
#### Indexing data and results
|
435
241
|
|
436
|
-
|
437
|
-
When you run an operation in the background, it schedules an [ActiveJob](app/jobs/operations/task_runner_job.rb) which performs the individual state handler, scheduling a follow-up job for subsequent states. By default, these jobs are scheduled to run after 1 second - so a five state operation will take a minimum of five seconds to complete (depending upon the backlog in your job queues). This is to prevent a single operation from starving the job process of resources and allow other ActiveJobs the time to execute.
|
242
|
+
If your task references other ActiveRecord models, you may need to find which tasks your models were involved in. For example, if you want to see which tasks a particular user initiated. You can declare an `index` on any `has_model` or `has_models` definitions and the task will automatically create a polymorphic join table that can be searched. You can then `include Operations::Participant` into your model to find which tasks it was involved in (and which attribute it was stored under).
|
438
243
|
|
439
|
-
|
244
|
+
For example:
|
440
245
|
|
441
246
|
```ruby
|
442
|
-
class
|
443
|
-
|
444
|
-
|
247
|
+
class IndexesModelsTask < Operations::Task
|
248
|
+
has_model :user, "User"
|
249
|
+
validates :user, presence: true
|
250
|
+
has_models :documents, "Document"
|
251
|
+
has_attribute :count, :integer, default: 0
|
252
|
+
index :user, :documents
|
253
|
+
...
|
445
254
|
end
|
446
|
-
```
|
447
|
-
|
448
|
-
Likewise, it's possible for a background task to get stuck. In the sub-tasks example above, if one of the sub-tasks fails, waiting for them _all_ to complete will never happen. Every operation has a default timeout of 5 minutes - if the operation has not completed or failed 5 minutes after it was initially started, it will fail with an `Operations::Timeout` exception.
|
449
255
|
|
450
|
-
|
256
|
+
@task = IndexesModelsTask.call user: @user, documents: [@document1, @document2]
|
451
257
|
|
452
|
-
|
453
|
-
|
454
|
-
|
455
|
-
delay 15.minutes
|
456
|
-
...
|
457
|
-
end
|
258
|
+
@user.operations.include?(@task) # => true
|
259
|
+
@user.operations_as(:user).include?(@task) # => true
|
260
|
+
@user.operations_as(:documents).include?(@task) # => false - the user is stored in the user attribute, not the documents attribute
|
458
261
|
```
|
459
262
|
|
460
|
-
|
263
|
+
### Failures and exceptions
|
461
264
|
|
462
|
-
|
463
|
-
class WaitForSomething < Operations::Task
|
464
|
-
timeout 10.minutes
|
465
|
-
delay 1.minute
|
265
|
+
If any handlers raise an exception, the task will be terminated. It will be marked as `failed?` and the details of the exception will be stored in `exception_class`, `exception_message` and `exception_backtrace`.
|
466
266
|
|
467
|
-
|
468
|
-
Notifier.send_timeout_notification
|
469
|
-
end
|
470
|
-
end
|
471
|
-
```
|
267
|
+
### Task life-cycle and the database
|
472
268
|
|
473
|
-
|
269
|
+
There is an ActiveRecord migration that creates the `operations_tasks` table. Use `bin/rails operations:install:migrations` to copy it to your application, then run `bin/rails db:migrate` to add the table to your application's database.
|
474
270
|
|
475
|
-
|
271
|
+
When you `call` a task, it is written to the database. Then whenever a state transition occurs, the task record is updated.
|
476
272
|
|
477
|
-
|
273
|
+
This gives you a number of possibilities:
|
274
|
+
- you can access the data (or error state) of a task after it has completed
|
275
|
+
- you can use [TurboStream broadcasts](https://turbo.hotwired.dev/handbook/streams) to update your user-interface as the state changes
|
276
|
+
- tasks can wait until an external event of some kind
|
277
|
+
- the tasks table acts as an audit trail or activity log for your application
|
478
278
|
|
479
|
-
|
480
|
-
- Manually; add a user interface listing your tasks with a "Restart" button. The "Restart" button calls `restart` on the task (which internally schedules a new task runner job).
|
481
|
-
- Automatically; set up a cron job which calls the `operations:restart_zombie_tasks` rake task. This rake task searches for zombie jobs and calls `restart` on them. Note that cron jobs have a minimum resolution of 1 minute so this will cause pauses in tasks with a delay measured in seconds. Also be aware that a cron job that calls a rake task will load the entire Rails stack as a new process, so be sure that your server has sufficient memory to cope. If you're using [SolidQueue](https://github.com/rails/solid_queue/), the job runner already sets up a separate "supervisor" process and allows you to define [recurring jobs](https://github.com/rails/solid_queue/#recurring-tasks) with a resolution of 1 second. This may be a suitable solution, but I've not tried it yet.
|
279
|
+
However, it also means that your database table could fill up with junk that you're no longer interested in. Therefore you can specify the maximum age of a task and, periodically, clean old tasks away. Every task has a `delete_at` field that, by default, is set to `90.days.from_now`. This can be changed by declaring `delete_after 7.days` - which will then mark the `delete_at` field for instances of that particular class to seven days. To actually delete those records you should set a cron job or recurring task that calls `Operations::Task.delete_old`. If you use the `Operations::Task::Runner`, it does this automatically.
|
482
280
|
|
483
281
|
## Testing
|
484
|
-
Because operations are intended to model long, complex, flowcharts of decisions and actions, it can be a pain coming up with the combinations of inputs to test every path through the sequence.
|
485
|
-
|
486
|
-
Instead, you can test each state handler _in isolation_.
|
487
|
-
|
488
|
-
As the handlers are stateless, we can call one without hitting the database; instead creating a dummy task object and then triggering the handler with the correct parameters.
|
489
|
-
|
490
|
-
This is done by calling `handling`, which yields a `test` object that we can inspect.
|
491
|
-
|
492
|
-
### Testing state transitions
|
493
|
-
To test if we have moved on to another state (for actions or decisions):
|
494
|
-
```ruby
|
495
|
-
MyOperation.handling(:an_action_or_decision, some: "data") do |test|
|
496
|
-
assert_equal test.next_state, "new_state"
|
497
|
-
# or
|
498
|
-
expect(test).to have_moved_to "new_state"
|
499
|
-
end
|
500
|
-
```
|
501
|
-
|
502
|
-
### Testing data modifications
|
503
|
-
To test if some data has been set or modified (for actions):
|
504
|
-
```ruby
|
505
|
-
MyOperation.handling(:an_action, existing_data: "some_value") do |test|
|
506
|
-
# has an existing data value been modified?
|
507
|
-
assert_equal test.existing_data, "some_other_value"
|
508
|
-
# or
|
509
|
-
expect(test.existing_data).to eq "some_other_value"
|
510
|
-
# has a new data value been added?
|
511
|
-
assert_equal test.new_data, "new_value"
|
512
|
-
# or
|
513
|
-
expect(test.new_data).to eq "new_value"
|
514
|
-
end
|
515
|
-
```
|
516
|
-
|
517
|
-
### Testing results
|
518
|
-
To test the results from a result handler:
|
519
|
-
```ruby
|
520
|
-
MyOperation.handling(:a_result, some: "data") do |test|
|
521
|
-
assert_equal test.outcome, "everything is as expected"
|
522
|
-
# or
|
523
|
-
assert_equal test[:outcome], "everything is as expected"
|
524
|
-
# or
|
525
|
-
expect(test.outcome).to eq "everything is as expected"
|
526
|
-
# or
|
527
|
-
expect(test[:outcome]).to eq "everything is as expected"
|
528
|
-
end
|
529
|
-
```
|
530
|
-
(Note - although results are stored in the database as a Hash, within your test, the results object is still carried as an OpenStruct, so you can access it using either notation).
|
531
|
-
|
532
|
-
### Testing sub-tasks
|
533
|
-
```ruby
|
534
|
-
MyOperation.handling(:a_sub_task, some: "data") do |test|
|
535
|
-
# Test which sub-tasks were called
|
536
|
-
assert_includes test.sub_tasks.keys, MySubTask
|
537
|
-
# or
|
538
|
-
expect(test.sub_tasks).to include MySubTask
|
539
|
-
end
|
540
|
-
```
|
541
|
-
TODO: I'm still figuring out how to test the data passed to sub-tasks. And calling a sub-task will actually execute that sub-task, so you need to stub `MySubTask.call` if it's an expensive operation.
|
542
|
-
|
543
|
-
```ruby
|
544
|
-
# Sorry, don't know the Minitest syntax for this
|
545
|
-
@sub_task = double "Operations::Task", results: { some: "answers" }
|
546
|
-
allow(MySubTask).to receive(:call).and_return(@sub_task)
|
547
|
-
|
548
|
-
MyOperation.handling(:a_sub_task, some: "data") do |test|
|
549
|
-
expect(test.sub_tasks).to include MySubTask
|
550
|
-
end
|
551
|
-
```
|
552
|
-
### Testing failures
|
553
|
-
To test if a handler has failed:
|
554
|
-
```ruby
|
555
|
-
|
556
|
-
expect { MyOperation.handling(:a_failure, some: "data") }.to raise_error(SomeException)
|
557
|
-
```
|
558
|
-
|
559
|
-
If you are using RSpec, you must `require "operations/matchers"` to make the matchers available to your specs.
|
560
|
-
|
561
|
-
## Visualization
|
562
|
-
|
563
|
-
Operations tasks can be visualized as flowcharts using the built-in SVG exporter. This helps you understand the flow of your operations and can be useful for documentation.
|
564
|
-
|
565
|
-
```ruby
|
566
|
-
# Export a task to SVG
|
567
|
-
exporter = Operations::Exporters::SVG.new(MyTask)
|
568
|
-
|
569
|
-
# Save as SVG
|
570
|
-
exporter.save("my_task_flow.svg")
|
571
|
-
|
572
|
-
# Get SVG content directly
|
573
|
-
svg_string = exporter.to_svg
|
574
|
-
```
|
575
|
-
|
576
|
-
### Custom Condition Labels
|
577
|
-
|
578
|
-
By default, condition transitions in the visualization are labeled based on the state they lead to. For more clarity, you can provide custom labels when defining conditions:
|
579
|
-
|
580
|
-
```ruby
|
581
|
-
wait_until :document_status do
|
582
|
-
condition(:ready_for_download, label: "Document processed successfully") { document.processed? }
|
583
|
-
condition(:processing_failed, label: "Processing error occurred") { document.error? }
|
584
|
-
end
|
585
|
-
|
586
|
-
decision :user_access_level do
|
587
|
-
condition(:allow_full_access, label: "User is an admin") { user.admin? }
|
588
|
-
condition(:provide_limited_access, label: "User is a regular member") { user.member? }
|
589
|
-
condition(:deny_access, label: "User has no permissions") { !user.member? }
|
590
|
-
end
|
591
|
-
```
|
592
282
|
|
593
|
-
|
594
|
-
- Color-coded nodes by state type (decisions, actions, wait states, results)
|
595
|
-
- Transition conditions between states with custom labels when provided
|
596
|
-
- Special handling for custom transition blocks
|
283
|
+
TBD
|
597
284
|
|
598
285
|
## Installation
|
599
286
|
Step 1: Add the gem to your Rails application's Gemfile:
|
@@ -638,4 +325,6 @@ The gem is available as open source under the terms of the [LGPL License](/LICEN
|
|
638
325
|
- [x] Add pause/resume capabilities (for example, when a task needs to wait for user input)
|
639
326
|
- [x] Add wait for sub-tasks capabilities
|
640
327
|
- [x] Add visualization export for task flows
|
641
|
-
- [ ]
|
328
|
+
- [ ] Replace ActiveJob with a background process
|
329
|
+
- [ ] Rename StateManagent with Plan
|
330
|
+
- [ ] Add interactions
|