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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +111 -350
  3. data/app/jobs/operations/agent/find_timeouts_job.rb +5 -0
  4. data/app/jobs/operations/agent/runner_job.rb +5 -0
  5. data/app/jobs/operations/agent/timeout_job.rb +5 -0
  6. data/app/jobs/operations/agent/wake_agents_job.rb +5 -0
  7. data/app/models/concerns/operations/participant.rb +1 -1
  8. data/app/models/operations/agent/interaction_handler.rb +30 -0
  9. data/app/models/operations/agent/plan.rb +38 -0
  10. data/app/models/operations/agent/runner.rb +37 -0
  11. data/app/models/operations/{task/state_management → agent}/wait_handler.rb +4 -2
  12. data/app/models/operations/agent.rb +31 -0
  13. data/app/models/operations/task/data_carrier.rb +0 -2
  14. data/app/models/operations/task/exports.rb +4 -4
  15. data/app/models/operations/task/{state_management → plan}/action_handler.rb +3 -1
  16. data/app/models/operations/task/{state_management → plan}/decision_handler.rb +3 -1
  17. data/app/models/operations/task/{state_management/completion_handler.rb → plan/result_handler.rb} +3 -1
  18. data/app/models/operations/task/{state_management.rb → plan.rb} +2 -4
  19. data/app/models/operations/task/testing.rb +2 -1
  20. data/app/models/operations/task.rb +46 -30
  21. data/app/models/operations/task_participant.rb +2 -0
  22. data/db/migrate/{20250403075414_add_becomes_zombie_at_field.rb → 20250404085321_add_becomes_zombie_at_field.operations.rb} +1 -0
  23. data/db/migrate/20250407143513_agent_fields.rb +9 -0
  24. data/db/migrate/20250408124423_add_task_participant_indexes.rb +5 -0
  25. data/lib/operations/has_data_attributes.rb +50 -0
  26. data/lib/operations/invalid_state.rb +2 -0
  27. data/lib/operations/version.rb +1 -1
  28. data/lib/operations.rb +2 -1
  29. data/lib/tasks/operations_tasks.rake +3 -3
  30. metadata +20 -11
  31. data/app/jobs/operations/task_runner_job.rb +0 -11
  32. data/app/models/operations/task/background.rb +0 -39
  33. 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: 05e2b09556bdcf1a1be75461eb956d4be95e0a736c49cf7cf40d83feb16e7ee3
4
- data.tar.gz: 5fadaecabdc294707fc6a82f57c8163390494c7bb57afddb2701a9bd35ee3cc6
3
+ metadata.gz: 0ae45b3244510bc8b6be2c06051cc8ac01907e5733de968e4f13c410e7a53cb9
4
+ data.tar.gz: e23c121129ba54551c0a240e5bd2ac19a6116ee4d70605df2b8b8ffae34e0f70
5
5
  SHA512:
6
- metadata.gz: 5db051c5929ba86f92a0eff0417fd84bc5e0aa5f368d5ef66855b5ad94df5b205da996b96235ae8802b07fa954a09464fda892bc670fa30df65f59a7c20e67a5
7
- data.tar.gz: d155b7c72d0f452f792e75cae431e196f8dd22d844e9953959ec95fa1582a963644777317ba33c7b1c74a43c28b38c4489ae166a1ebce64f83d1c58ad0d6c55d
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
- ### Defining an operation
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 PrepareDocumentForDownload < Operations::Task
47
- inputs :user, :document, :use_filename_scrambler
48
- starts_with :authorised?
49
-
50
- decision :authorised? do
51
- condition { user.can?(:read, data.document) }
52
-
53
- if_true :within_download_limits?
54
- if_false { fail_with "unauthorised" }
55
- end
56
-
57
- decision :within_download_limits? do
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
- if_true :use_filename_scrambler?
61
- if_false { fail_with "download_limit_reached" }
30
+ action :buy_food do
31
+ food_shop.order_party_food
62
32
  end
33
+ go_to :buy_beer
63
34
 
64
- decision :use_filename_scrambler? do
65
- condition { use_filename_scrambler }
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 :scramble_filename do
72
- self.filename = "#{Faker::Lorem.word}#{File.extname(document.filename.to_s)}"
40
+ action :invite_friends do
41
+ self.available_friends = friends.select { |friend| friend.available_on? date }
73
42
  end
74
- go_to :return_filename
43
+ go_to :party!
75
44
 
76
- result :return_filename do |results|
77
- results.filename = filename || document.filename.to_s
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 = PrepareDocumentForDownload.call user: @user, document: @document, use_filename_scrambler: @account.feature_flags[:use_filename_scramber]
82
- puts task.results[:filename]
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
- The task declares that it requires `user`, `document` and `use_filename_scrambler` parameters and that it starts in the `authorised?` state.
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
- The five states are represented as three [decision](#decisions) handlers, one [action](#actions) handler and a [result](#results) handler.
83
+ ### Decision Handlers
88
84
 
89
- ### Decisions
90
- A decision handler evaluates a condition, then changes state depending upon if the result is true or false.
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
- ### Actions
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. `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.
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
- ### Waiting
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
- ```ruby
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. `optional` is decorative and to help with your documentation. Ensure you call `inputs` at the start of 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
- ```ruby
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
- send_data @document.contents, filename: @task.results[:filename], disposition: "attachment"
236
-
237
- rescue => failure
238
- render action: "error", locals: {error: failure.message}, status: 422
239
- end
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 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.
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 do |results|
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.results[:name] # => Alice Aardvark
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
- results = call GetAuthorisation, user: user, document: document
339
- self.authorised = results[: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
- 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.
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
- #### 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
-
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
- ### 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")
351
+ Tasks represent complex flows of logic, so each state can be [tested in isolation](/docs/testing.md).
571
352
 
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
- ```
353
+ ## Visualisation
592
354
 
593
- The visualization includes:
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
- - [ ] Option to change background job queue and priority settings
400
+ - [ ] Replace ActiveJob with a background process
401
+ - [ ] Rename StateManagent with Plan
402
+ - [ ] Add interactions
@@ -0,0 +1,5 @@
1
+ class Operations::Agent::FindTimeoutsJob < ApplicationJob
2
+ queue_as :default
3
+
4
+ def perform = Operations::Agent.active.timed_out.find_each { |agent| Operations::AgentTimeoutJob.perform_later(agent) }
5
+ end
@@ -0,0 +1,5 @@
1
+ class Operations::Agent::RunnerJob < ApplicationJob
2
+ queue_as :default
3
+
4
+ def perform(agent) = agent.perform!
5
+ end
@@ -0,0 +1,5 @@
1
+ class Operations::Agent::TimeoutJob < ApplicationJob
2
+ queue_as :default
3
+
4
+ def perform(agent) = agent.timeout!
5
+ end
@@ -0,0 +1,5 @@
1
+ class Operations::Agent::WakeAgentsJob < ApplicationJob
2
+ queue_as :default
3
+
4
+ def perform = Operations::Agent.waiting.ready_to_wake.find_each { |agent| Operations::Agent::RunnerJob.perform_later(agent) }
5
+ end