standard_procedure_operations 0.5.3 → 0.6.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 +111 -350
- data/app/jobs/operations/agent/find_timeouts_job.rb +5 -0
- data/app/jobs/operations/agent/runner_job.rb +5 -0
- data/app/jobs/operations/agent/timeout_job.rb +5 -0
- data/app/jobs/operations/agent/wake_agents_job.rb +5 -0
- data/app/models/concerns/operations/participant.rb +1 -1
- data/app/models/operations/agent/interaction_handler.rb +30 -0
- data/app/models/operations/agent/plan.rb +38 -0
- data/app/models/operations/agent/runner.rb +37 -0
- data/app/models/operations/{task/state_management → agent}/wait_handler.rb +4 -2
- data/app/models/operations/agent.rb +31 -0
- data/app/models/operations/task/data_carrier.rb +0 -2
- data/app/models/operations/task/exports.rb +4 -4
- data/app/models/operations/task/{state_management → plan}/action_handler.rb +3 -1
- data/app/models/operations/task/{state_management → plan}/decision_handler.rb +3 -1
- data/app/models/operations/task/{state_management/completion_handler.rb → plan/result_handler.rb} +3 -1
- data/app/models/operations/task/{state_management.rb → plan.rb} +2 -4
- data/app/models/operations/task/testing.rb +2 -1
- data/app/models/operations/task.rb +46 -30
- data/app/models/operations/task_participant.rb +2 -0
- data/db/migrate/{20250403075414_add_becomes_zombie_at_field.rb → 20250404085321_add_becomes_zombie_at_field.operations.rb} +1 -0
- data/db/migrate/20250407143513_agent_fields.rb +9 -0
- data/db/migrate/20250408124423_add_task_participant_indexes.rb +5 -0
- data/lib/operations/has_data_attributes.rb +50 -0
- data/lib/operations/invalid_state.rb +2 -0
- data/lib/operations/version.rb +1 -1
- data/lib/operations.rb +2 -1
- data/lib/tasks/operations_tasks.rake +3 -3
- metadata +20 -11
- data/app/jobs/operations/task_runner_job.rb +0 -11
- data/app/models/operations/task/background.rb +0 -39
- data/lib/operations/cannot_wait_in_foreground.rb +0 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 0ae45b3244510bc8b6be2c06051cc8ac01907e5733de968e4f13c410e7a53cb9
|
4
|
+
data.tar.gz: e23c121129ba54551c0a240e5bd2ac19a6116ee4d70605df2b8b8ffae34e0f70
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 31faffeea577216f77828ef9fcbb004a82943ba362e0cb82fc07ee9059d11078a33fda73439a4c751f3774fa589a6004e5a0ab68007c1e07e951cd3c2dc0f1f6
|
7
|
+
data.tar.gz: 7f4547335624db494e2d425e5724a8dfdbe73a1ab0c29d2fdf402db69d17c2ccee2a069051d27c6ae6d0ee28abfe2d701ea218337263f07d32aeaa9f8ce5e889
|
data/README.md
CHANGED
@@ -8,95 +8,92 @@ Most times when I'm adding a feature to a complex application, I tend to end up
|
|
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
10
|
## Usage
|
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).
|
12
11
|
|
13
|
-
###
|
14
|
-
The flowchart, for this simplified example, is something like this:
|
12
|
+
### Drawing up a plan
|
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
|
-
```
|
40
|
-
|
41
|
-
We have five states - three of which are decisions, one is an action and one is a result.
|
42
|
-
|
43
|
-
Here's how this would be represented using Operations.
|
14
|
+
Here's a simple example for planning a party.
|
44
15
|
|
45
16
|
```ruby
|
46
|
-
class
|
47
|
-
|
48
|
-
starts_with :
|
49
|
-
|
50
|
-
decision :
|
51
|
-
condition {
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
condition { user.within_download_limits? }
|
17
|
+
class PlanAParty < Operations::Task
|
18
|
+
input :date, :friends, :food_shop, :beer_shop
|
19
|
+
starts_with :what_day_is_it?
|
20
|
+
|
21
|
+
decision :what_day_is_it? do
|
22
|
+
condition { date.wday == 6 }
|
23
|
+
go_to :buy_food
|
24
|
+
condition { date.wday == 0 }
|
25
|
+
go_to :relax
|
26
|
+
condition { date.wday.in? [1, 2, 3, 4, 5]}
|
27
|
+
go_to :go_to_work
|
28
|
+
end
|
59
29
|
|
60
|
-
|
61
|
-
|
30
|
+
action :buy_food do
|
31
|
+
food_shop.order_party_food
|
62
32
|
end
|
33
|
+
go_to :buy_beer
|
63
34
|
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
if_true :scramble_filename
|
68
|
-
if_false :return_filename
|
35
|
+
action :buy_beer do
|
36
|
+
beer_shop.order_drinks
|
69
37
|
end
|
38
|
+
go_to :invite_friends
|
70
39
|
|
71
|
-
action :
|
72
|
-
self.
|
40
|
+
action :invite_friends do
|
41
|
+
self.available_friends = friends.select { |friend| friend.available_on? date }
|
73
42
|
end
|
74
|
-
go_to :
|
43
|
+
go_to :party!
|
75
44
|
|
76
|
-
result :
|
77
|
-
results.
|
45
|
+
result :party! do |results|
|
46
|
+
results.available_friends = available_friends
|
78
47
|
end
|
48
|
+
result :relax
|
49
|
+
result :go_to_work
|
79
50
|
end
|
51
|
+
```
|
52
|
+
|
53
|
+
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`.
|
54
|
+
|
55
|
+
We would start the task as follows:
|
56
|
+
|
57
|
+
```ruby
|
58
|
+
task = PlanAParty.call date: Date.today, friends: @friends, food_shop: @food_shop, beer_shop: @beer_shop
|
80
59
|
|
81
|
-
task
|
82
|
-
|
60
|
+
expect(task).to be_completed
|
61
|
+
# If it's a weekday
|
62
|
+
expect(task.is?(:go_to_work)).to be true
|
63
|
+
# If it's Sunday
|
64
|
+
expect(task.is?(:relaz)).to be true
|
65
|
+
# If it's Saturday
|
66
|
+
expect(task.is?(:party!)).to be true
|
67
|
+
expect(task.results[:available_friends]).to_not be_empty
|
83
68
|
```
|
69
|
+
We define the `inputs` that the task expects and its starting `state`.
|
70
|
+
|
71
|
+
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. `relax` and `go_to_works` are _results_ which end the task. Whereas `buy_food`, `buy_drinks` and `invite_friends` are _actions_ which do things. And the `party!` _result_ also returns some data - a list of `available_friends`.
|
72
|
+
|
73
|
+
When you `call` the task, it runs through the process immediately and either fails with an exception or completes immediately.
|
74
|
+
|
75
|
+
You can also plan tasks that continue working over a period of time. These [Agents](/docs/agents.md) have extra capabilities - `wait_until` and `interaction`s - but require a bit of setup, so we'll come back to them later.
|
76
|
+
|
77
|
+
### States
|
78
|
+
|
79
|
+
`States` are the heart of each task. Each `state` defines a `handler` which does something, then moves to another `state`.
|
84
80
|
|
85
|
-
|
81
|
+
Any state can also declare which data it expects - both required `inputs`, as well as `optional` inputs. If the task enters a `state` and the required data is not present then it fails with an `ArgumentError`. Optional input declarations do not actually do anything but are useful for documenting your task.
|
86
82
|
|
87
|
-
|
83
|
+
### Decision Handlers
|
88
84
|
|
89
|
-
|
90
|
-
|
85
|
+
A decision handler evaluates a condition, then changes state depending upon the result.
|
86
|
+
|
87
|
+
The simplest tests a boolean condition.
|
91
88
|
|
92
89
|
```ruby
|
93
90
|
decision :is_it_the_weekend? do
|
94
91
|
condition { Date.today.wday.in? [0, 6] }
|
95
|
-
|
96
92
|
if_true :have_a_party
|
97
93
|
if_false :go_to_work
|
98
94
|
end
|
99
95
|
```
|
96
|
+
|
100
97
|
A decision can also mark a failure, which will terminate the task and raise an `Operations::Failure`.
|
101
98
|
```ruby
|
102
99
|
decision :authorised? do
|
@@ -120,6 +117,7 @@ decision :is_the_weather_good? do
|
|
120
117
|
go_to :build_a_snowman
|
121
118
|
end
|
122
119
|
```
|
120
|
+
|
123
121
|
If no conditions are matched then the task fails with a `NoDecision` exception.
|
124
122
|
|
125
123
|
You can specify the data that is required for a decision handler to run by specifying `inputs` and `optionals`:
|
@@ -133,9 +131,9 @@ decision :authorised? do
|
|
133
131
|
if_false { fail_with "Unauthorised" }
|
134
132
|
end
|
135
133
|
```
|
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).
|
137
134
|
|
138
|
-
###
|
135
|
+
### Action Handlers
|
136
|
+
|
139
137
|
An action handler does some work, and then transitions to another state. The state transition is defined statically after the action, using the `go_to` method.
|
140
138
|
|
141
139
|
```ruby
|
@@ -147,7 +145,7 @@ end
|
|
147
145
|
go_to :send_invitations
|
148
146
|
```
|
149
147
|
|
150
|
-
You can also specify the required and optional data for your action handler using parameters or within the block.
|
148
|
+
You can also specify the required and optional data for your action handler using parameters or within the block.
|
151
149
|
|
152
150
|
```ruby
|
153
151
|
action :have_a_party do
|
@@ -160,24 +158,9 @@ end
|
|
160
158
|
go_to :send_invitations
|
161
159
|
```
|
162
160
|
|
163
|
-
###
|
164
|
-
Wait handlers are very similar to decision handlers but only work within [background tasks](#background-operations-and-pauses).
|
161
|
+
### Result Handlers
|
165
162
|
|
166
|
-
|
167
|
-
wait_until :weather_forecast_available? do
|
168
|
-
condition { weather_forecast.sunny? }
|
169
|
-
go_to :the_beach
|
170
|
-
condition { weather_forecast.rainy? }
|
171
|
-
go_to :grab_an_umbrella
|
172
|
-
condition { weather_forecast.snowing? }
|
173
|
-
go_to :build_a_snowman
|
174
|
-
end
|
175
|
-
```
|
176
|
-
|
177
|
-
If no conditions are met, then, unlike a decision handler, the task continues waiting in the same state.
|
178
|
-
|
179
|
-
### Results
|
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.
|
163
|
+
A result handler marks the end of an operation, optionally returning some results. If you're using the results collection, you will 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.
|
181
164
|
|
182
165
|
```ruby
|
183
166
|
action :send_invitations do
|
@@ -186,9 +169,8 @@ action :send_invitations do
|
|
186
169
|
FriendsMailer.with(recipient: friend).party_invitation.deliver_later unless friend.nil?
|
187
170
|
friend
|
188
171
|
end.compact
|
189
|
-
|
190
|
-
go_to :ready_to_party
|
191
172
|
end
|
173
|
+
go_to :ready_to_party
|
192
174
|
|
193
175
|
result :ready_to_party do |results|
|
194
176
|
results.invited_friends = invited_friends
|
@@ -196,26 +178,15 @@ end
|
|
196
178
|
```
|
197
179
|
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.
|
198
180
|
|
199
|
-
If you don't have any meaningful results, you can omit the block on your result handler.
|
181
|
+
If you don't have any meaningful results, you can omit the block on your result handler. I've found that I tend to do this instead, accessing any information I need via `data` and attributes.
|
200
182
|
```ruby
|
201
183
|
result :go_to_work
|
202
184
|
```
|
203
185
|
In this case, the task will be marked as `completed?`, the task's state will be `go_to_work` and `results` will be empty.
|
204
186
|
|
205
|
-
You can also specify the required and optional data for your result handler within the block.
|
206
|
-
```ruby
|
207
|
-
action :send_invitations do
|
208
|
-
inputs :number_of_guests, :friends
|
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
|
215
|
-
|
216
|
-
go_to :ready_to_party
|
217
|
-
end
|
187
|
+
You can also specify the required and optional data for your result handler within the block.
|
218
188
|
|
189
|
+
```ruby
|
219
190
|
result :ready_to_party do |results|
|
220
191
|
inputs :invited_friends
|
221
192
|
|
@@ -224,19 +195,25 @@ end
|
|
224
195
|
```
|
225
196
|
|
226
197
|
### Calling an operation
|
227
|
-
You would use the earlier [PrepareDocumentForDownload](spec/examples/prepare_document_for_download_spec.rb) operation in a controller like this:
|
228
198
|
|
229
|
-
|
230
|
-
class DownloadsController < ApplicationController
|
231
|
-
def show
|
232
|
-
@document = Document.find(params[:id])
|
233
|
-
@task = PrepareDocumentForDownload.call(user: Current.user, document: @document, use_filename_scrambler: Current.account.use_filename_scrambler?)
|
199
|
+
Each task has a `call` method that takes your inputs and runs the task immediately. You can then test to see if it has `completed?` or `failed?` and check the final `state` and `results`
|
234
200
|
|
235
|
-
|
236
|
-
|
237
|
-
|
238
|
-
|
239
|
-
|
201
|
+
```ruby
|
202
|
+
begin
|
203
|
+
task = PlanAParty.call date: Date.today, friends: @friends, food_shop: @food_shop, beer_shop: @beer_shop
|
204
|
+
|
205
|
+
expect(task).to be_completed
|
206
|
+
# If it's a weekday
|
207
|
+
expect(task.is?(:go_to_work)).to be true
|
208
|
+
# If it's Sunday
|
209
|
+
expect(task.is?(:relax)).to be true
|
210
|
+
# If it's Saturday
|
211
|
+
expect(task.is?(:party!)).to be true
|
212
|
+
expect(task.results[:available_friends]).to_not be_empty
|
213
|
+
rescue => ex
|
214
|
+
expect(task).to be_failed
|
215
|
+
expect(task.results[:exception_message]).to eq ex.message
|
216
|
+
expect(task.results[:exception_class]).to eq ex.class
|
240
217
|
end
|
241
218
|
```
|
242
219
|
|
@@ -244,32 +221,37 @@ OK - so that's a pretty longwinded way of performing a simple task.
|
|
244
221
|
|
245
222
|
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
223
|
|
247
|
-
In Collabor8Online, the
|
224
|
+
In [Collabor8Online](https://www.collabor8online.co.uk/), when a user wants to download a file, the task is complicated, based upon feature flags, configuration options and permissions. This involves over fifteen decisions, fifteen actions and, previously, the logic for this was scattered across a number of models and controllers, making it extremely difficult to see what was happening. Whereas now, all the logic for downloads is captured within one overall plan that calls out to three other sub-tasks and the logic is easy to follow.
|
248
225
|
|
249
226
|
### Data and results
|
227
|
+
|
250
228
|
Each operation carries its own, mutable, [data](/app/models/operations/task/data_carrier.rb) for the duration of the operation.
|
251
229
|
|
252
230
|
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.
|
253
231
|
|
254
232
|
Within handlers you can read the data directly (the implementation uses `instance_eval`/`instance_exec`). Here the `build_name` action knows the `first_name` and `last_name` provided and adds in a new property of `name`.
|
255
233
|
|
234
|
+
Accessing the task from outside of a handler (for example in your controller) you can either access the task's `data` property. Or if you have defined your `inputs` and `optional` fields, they will also be available as attributes on the task model itself.
|
235
|
+
|
256
236
|
```ruby
|
257
237
|
class CombineNames < Operations::Task
|
258
238
|
inputs :first_name, :last_name
|
239
|
+
optional :name
|
259
240
|
starts_with :build_name
|
241
|
+
validates :first_name, presence: true
|
242
|
+
validates :last_name, presence: true
|
260
243
|
|
261
244
|
action :build_name do
|
262
245
|
self.name = "#{first_name} #{last_name}"
|
263
246
|
go_to :done
|
264
247
|
end
|
265
248
|
|
266
|
-
result :done
|
267
|
-
results.name = name
|
268
|
-
end
|
249
|
+
result :done
|
269
250
|
end
|
270
251
|
|
271
252
|
task = CombineNames.call first_name: "Alice", last_name: "Aardvark"
|
272
|
-
task.
|
253
|
+
task.name # => Alice Aardvark - `name` is defined as `optional`, so it is available directly on the task
|
254
|
+
task.data[:name] # => Alice Aardvark - if `name` was not included in `inputs` or `optional` we would need to access it via `data`
|
273
255
|
```
|
274
256
|
|
275
257
|
Because handlers are run in the context of the data carrier, you do not have direct access to methods or properties on your task object. However, the data carrier holds a reference to your task; use `task.do_something` or `task.some_attribute` to access it. The exception is the `fail_with`, `call` and `start` methods which the data carrier understands (and are intercepted when you are [testing](#testing)).
|
@@ -304,27 +286,27 @@ Likewise, you can see all the tasks that Alice was involved with using:
|
|
304
286
|
```
|
305
287
|
|
306
288
|
### Failures and exceptions
|
289
|
+
|
307
290
|
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
291
|
|
309
292
|
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
293
|
|
311
294
|
### Task life-cycle and the database
|
295
|
+
|
312
296
|
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.
|
313
297
|
|
314
298
|
When you `call` a task, it is written to the database. Then whenever a state transition occurs, the task record is updated.
|
315
299
|
|
316
300
|
This gives you a number of possibilities:
|
317
|
-
- you can access the results (or error state) of a task after it has completed
|
301
|
+
- you can access the data and results (or error state) of a task after it has completed
|
318
302
|
- 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
303
|
- 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
304
|
- the tasks table acts as an audit trail or activity log for your application
|
321
305
|
|
322
306
|
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 calling `Operations::Task.delete_after 7.days` (or whatever value you prefer) in an initializer. Then, run a cron job, or other scheduled task, once per day that calls `Operations::Task.delete_expired`. This will delete any tasks whose `delete_at` time has passed.
|
323
307
|
|
324
|
-
### Status messages
|
325
|
-
Documentation coming soon.
|
326
|
-
|
327
308
|
### Sub tasks
|
309
|
+
|
328
310
|
Any operation can be composed out of other operations and can therefore call other subtasks.
|
329
311
|
|
330
312
|
```ruby
|
@@ -335,8 +317,8 @@ class PrepareDownload < Operations::Task
|
|
335
317
|
action :get_authorisation do
|
336
318
|
inputs :user, :document
|
337
319
|
|
338
|
-
|
339
|
-
self.authorised =
|
320
|
+
result = call GetAuthorisation, user: user, document: document
|
321
|
+
self.authorised = result[:authorised]
|
340
322
|
end
|
341
323
|
go_to :whatever_happens_next
|
342
324
|
end
|
@@ -360,240 +342,17 @@ class PrepareDownload < Operations::Task
|
|
360
342
|
end
|
361
343
|
```
|
362
344
|
|
363
|
-
|
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.
|
376
|
-
|
377
|
-
```ruby
|
378
|
-
class UserRegistration < Operations::Task
|
379
|
-
inputs :email
|
380
|
-
starts_with :create_user
|
381
|
-
|
382
|
-
action :create_user do
|
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
|
400
|
-
end
|
401
|
-
|
402
|
-
@task = UserRegistration.start email: "someone@example.com"
|
403
|
-
```
|
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.
|
345
|
+
### Agents
|
405
346
|
|
406
|
-
|
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
|
-
|
429
|
-
result :done
|
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.
|
435
|
-
|
436
|
-
#### Delays and Timeouts
|
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.
|
438
|
-
|
439
|
-
However, if you know that your `wait_until` condition may take a while you can change the default delay to something longer. In your operations definition, declare the required delay:
|
440
|
-
|
441
|
-
```ruby
|
442
|
-
class ParallelTasks < Operations::Tasks
|
443
|
-
delay 1.minute
|
444
|
-
...
|
445
|
-
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
|
-
|
450
|
-
If you need to change this (such as the user verification example above), you can declare the timeout when defining the operation. Long timeouts fit well with longer delays, so you're not filling the job queues with jobs that are meaninglessly evaluating your conditions.
|
451
|
-
|
452
|
-
```ruby
|
453
|
-
class UserRegistration < Operations::Task
|
454
|
-
timeout 24.hours
|
455
|
-
delay 15.minutes
|
456
|
-
...
|
457
|
-
end
|
458
|
-
```
|
459
|
-
|
460
|
-
Instead of failing with an `Operations::Timeout` exception, you define an `on_timeout` handler for any special processing should the time-out occur.
|
461
|
-
|
462
|
-
```ruby
|
463
|
-
class WaitForSomething < Operations::Task
|
464
|
-
timeout 10.minutes
|
465
|
-
delay 1.minute
|
466
|
-
|
467
|
-
on_timeout do
|
468
|
-
Notifier.send_timeout_notification
|
469
|
-
end
|
470
|
-
end
|
471
|
-
```
|
472
|
-
|
473
|
-
#### Zombie tasks
|
474
|
-
|
475
|
-
There's a chance that the `Operations::TaskRunnerJob` might get lost - maybe there's a crash in some process and the job does not restart correctly. As the process for handling background tasks relies on the task "waking up", performing the next action, then queuing up the next task-runner, if the background job does not queue as expected, the task will sit there, waiting forever.
|
476
|
-
|
477
|
-
To monitor for this, every task can be checked to see if it is a `zombie?`. This means that the current time is more than 3 times the expected delay, compared to the `updated_at` field. So if the `delay` is set to 1 minute and the task last woke up more than 3 minutes ago, it is classed as a zombie.
|
478
|
-
|
479
|
-
There are two ways to handle zombies.
|
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.
|
347
|
+
So far, we've only defined tasks that run and complete immediately. However, [agents](/docs/agents.md) run over a long period of time and can respond to external interactions.
|
482
348
|
|
483
349
|
## 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
350
|
|
492
|
-
|
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")
|
351
|
+
Tasks represent complex flows of logic, so each state can be [tested in isolation](/docs/testing.md).
|
571
352
|
|
572
|
-
|
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
|
-
```
|
353
|
+
## Visualisation
|
592
354
|
|
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
|
355
|
+
There is a very simple [visualisation tool](/docs/visualisation.md) built into the gem.
|
597
356
|
|
598
357
|
## Installation
|
599
358
|
Step 1: Add the gem to your Rails application's Gemfile:
|
@@ -638,4 +397,6 @@ The gem is available as open source under the terms of the [LGPL License](/LICEN
|
|
638
397
|
- [x] Add pause/resume capabilities (for example, when a task needs to wait for user input)
|
639
398
|
- [x] Add wait for sub-tasks capabilities
|
640
399
|
- [x] Add visualization export for task flows
|
641
|
-
- [ ]
|
400
|
+
- [ ] Replace ActiveJob with a background process
|
401
|
+
- [ ] Rename StateManagent with Plan
|
402
|
+
- [ ] Add interactions
|