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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +163 -474
  3. data/app/jobs/operations/delete_old_task_job.rb +5 -0
  4. data/app/jobs/operations/wake_task_job.rb +5 -0
  5. data/app/models/concerns/operations/participant.rb +4 -9
  6. data/app/models/operations/task/index.rb +22 -0
  7. data/app/models/operations/task/plan/action_handler.rb +20 -0
  8. data/app/models/operations/task/plan/decision_handler.rb +40 -0
  9. data/app/models/operations/task/plan/interaction_handler.rb +20 -0
  10. data/app/models/operations/task/plan/result_handler.rb +9 -0
  11. data/app/models/operations/task/{state_management → plan}/wait_handler.rb +8 -6
  12. data/app/models/operations/task/plan.rb +70 -0
  13. data/app/models/operations/task/runner.rb +34 -0
  14. data/app/models/operations/task.rb +50 -68
  15. data/app/models/operations/task_participant.rb +2 -5
  16. data/db/migrate/20250701190516_rename_existing_operations_tables.rb +19 -0
  17. data/db/migrate/20250701190716_create_new_operations_tasks.rb +20 -0
  18. data/db/migrate/20250702113801_create_task_participants.rb +10 -0
  19. data/lib/operations/invalid_state.rb +2 -0
  20. data/lib/operations/version.rb +1 -1
  21. data/lib/operations.rb +2 -3
  22. data/lib/tasks/operations_tasks.rake +3 -3
  23. metadata +17 -20
  24. data/app/jobs/operations/task_runner_job.rb +0 -11
  25. data/app/models/operations/task/background.rb +0 -39
  26. data/app/models/operations/task/data_carrier.rb +0 -18
  27. data/app/models/operations/task/deletion.rb +0 -17
  28. data/app/models/operations/task/exports.rb +0 -45
  29. data/app/models/operations/task/input_validation.rb +0 -17
  30. data/app/models/operations/task/state_management/action_handler.rb +0 -17
  31. data/app/models/operations/task/state_management/completion_handler.rb +0 -14
  32. data/app/models/operations/task/state_management/decision_handler.rb +0 -49
  33. data/app/models/operations/task/state_management.rb +0 -39
  34. data/app/models/operations/task/testing.rb +0 -62
  35. data/db/migrate/20250127160616_create_operations_tasks.rb +0 -17
  36. data/db/migrate/20250309160616_create_operations_task_participants.rb +0 -15
  37. data/db/migrate/20250403075414_add_becomes_zombie_at_field.rb +0 -6
  38. data/lib/operations/cannot_wait_in_foreground.rb +0 -2
  39. 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
- ## 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).
10
+ ## Breaking Change
12
11
 
13
- ### Defining an operation
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
- We have five states - three of which are decisions, one is an action and one is a result.
16
+ ### Drawing up a plan
42
17
 
43
- Here's how this would be represented using Operations.
18
+ Here's a simple example for planning a party.
44
19
 
45
20
  ```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? }
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
- if_true :use_filename_scrambler?
61
- if_false { fail_with "download_limit_reached" }
39
+ action :buy_food do
40
+ food_shop.order_party_food
62
41
  end
42
+ go_to :buy_beer
63
43
 
64
- decision :use_filename_scrambler? do
65
- condition { use_filename_scrambler }
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 :scramble_filename do
72
- self.filename = "#{Faker::Lorem.word}#{File.extname(document.filename.to_s)}"
49
+ action :invite_friends do
50
+ self.available_friends = friends.select { |friend| friend.available_on? date }
73
51
  end
74
- go_to :return_filename
52
+ go_to :party!
75
53
 
76
- result :return_filename do |results|
77
- results.filename = filename || document.filename.to_s
78
- end
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
- The task declares that it requires `user`, `document` and `use_filename_scrambler` parameters and that it starts in the `authorised?` state.
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
- The five states are represented as three [decision](#decisions) handlers, one [action](#actions) handler and a [result](#results) handler.
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
- decision :is_it_the_weekend? do
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
- if_true :have_a_party
97
- if_false :go_to_work
98
- end
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
- A decision can also mark a failure, which will terminate the task and raise an `Operations::Failure`.
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
- if_true :do_some_work
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
- Alternatively, you can evaluate multiple conditions in your decision handler.
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
- ```ruby
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
- You can specify the data that is required for a decision handler to run by specifying `inputs` and `optionals`:
126
- ```ruby
127
- decision :authorised? do
128
- inputs :user
129
- optional :override
130
- condition { override || user.administrator? }
84
+ ### States
131
85
 
132
- if_true :do_some_work
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
- ### Actions
139
- 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.
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 ||= task.plan_a_party_playlist
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
- ### Waiting
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
- wait_until :weather_forecast_available? do
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 met, then, unlike a decision handler, the task continues waiting in the same state.
140
+ If no conditions are matched then the task fails with a `NoDecision` exception.
178
141
 
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.
142
+ As a convention, use a question to name your decision handlers.
181
143
 
182
- ```ruby
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
- result :ready_to_party do |results|
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
- If you don't have any meaningful results, you can omit the block on your result handler.
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
- 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
151
+ result :done
152
+ ```
215
153
 
216
- go_to :ready_to_party
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
- result :ready_to_party do |results|
220
- inputs :invited_friends
156
+ ### Waiting and interactions
221
157
 
222
- results.invited_friends = invited_friends
223
- end
224
- ```
158
+ Many processes involve waiting for some external event to take place.
225
159
 
226
- ### Calling an operation
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 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?)
234
-
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
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
- end
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
- 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`.
177
+ wait_until :name_provided? do
178
+ condition { name.present? }
179
+ go_to :create_user
180
+ end
255
181
 
256
- ```ruby
257
- class CombineNames < Operations::Task
258
- inputs :first_name, :last_name
259
- starts_with :build_name
182
+ interaction :register! do |name|
183
+ self.name = name
184
+ end.when :name_provided?
260
185
 
261
- action :build_name do
262
- self.name = "#{first_name} #{last_name}"
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 do |results|
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
- 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)).
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
- They are both stored as hashes that are encoded into JSON.
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
- Instead of using the standard [JSON coder](https://api.rubyonrails.org/v4.2/classes/ActiveModel/Serializers/JSON.html), we use a [GlobalIdSerialiser](https://github.com/standard-procedure/global_id_serialiser). This serialises most data into standard JSON types, as you would expect, but it also takes any [GlobalID::Identification](https://github.com/rails/globalid) objects (which includes all ActiveRecord models) and converts them to a GlobalID string. Then when the data is deserialised from the database, the GlobalID is converted back into the appropriate model.
200
+ Like decisions, use a question as the name for your wait handlers.
282
201
 
283
- If the original database record was deleted between the time the hash was serialised and when it was retrieved, the `GlobalID::Locator` will fail. In this case, the deserialised data will contain a `nil` for the value in question.
202
+ #### Interactions
284
203
 
285
- Also note that the GlobalIdSerialiser automatically converts all hash keys into symbols (unlike the standard JSON coder which uses strings).
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
- #### Indexing data and results
206
+ As a convention, use an exclamation mark to name your interaction handlers.
288
207
 
289
- If you need to search through existing tasks by a model that is stored in the `data` or `results` fields - for example, you might want to list all operations that were started by a particular `User` - the models can be indexed alongside the task.
208
+ #### Background processor
290
209
 
291
- If your ActiveRecord model (in this example, `User`) includes the `Operations::Participant` module, it will be linked with any task that references that model. A polymorphic join table, `operations_task_participants` is used for this. Whenever a task is saved, any `Operations::Participant` records are located in the `data` and `results` collections and a `Operations::TaskParticipant` record created to join the model to the task. The `context` attribute records whether the association is in the `data` or `results` collection and the `role` attribute is the name of the hash key.
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
- When you `call` a task, it is written to the database. Then whenever a state transition occurs, the task record is updated.
212
+ #### Starting tasks in the background
315
213
 
316
- This gives you a number of possibilities:
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
- 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.
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
- ### Status messages
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
- ```ruby
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
- You can also access the results in a block:
347
- ```ruby
348
- class PrepareDownload < Operations::Task
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
- 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
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
- 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.
240
+ #### Indexing data and results
435
241
 
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.
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
- 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:
244
+ For example:
440
245
 
441
246
  ```ruby
442
- class ParallelTasks < Operations::Tasks
443
- delay 1.minute
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
- 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.
256
+ @task = IndexesModelsTask.call user: @user, documents: [@document1, @document2]
451
257
 
452
- ```ruby
453
- class UserRegistration < Operations::Task
454
- timeout 24.hours
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
- Instead of failing with an `Operations::Timeout` exception, you define an `on_timeout` handler for any special processing should the time-out occur.
263
+ ### Failures and exceptions
461
264
 
462
- ```ruby
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
- on_timeout do
468
- Notifier.send_timeout_notification
469
- end
470
- end
471
- ```
267
+ ### Task life-cycle and the database
472
268
 
473
- #### Zombie tasks
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
- 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.
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
- 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.
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
- 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.
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
- 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
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
- - [ ] Option to change background job queue and priority settings
328
+ - [ ] Replace ActiveJob with a background process
329
+ - [ ] Rename StateManagent with Plan
330
+ - [ ] Add interactions