standard_procedure_operations 0.2.6 → 0.3.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 +130 -35
- data/app/jobs/operations/application_job.rb +9 -0
- data/app/jobs/operations/task_runner_job.rb +11 -0
- data/app/models/operations/task/background.rb +26 -0
- data/app/models/operations/task/data_carrier.rb +3 -1
- data/app/models/operations/task/state_management/action_handler.rb +1 -3
- data/app/models/operations/task/state_management/decision_handler.rb +2 -3
- data/app/models/operations/task/state_management/wait_handler.rb +18 -0
- data/app/models/operations/task/state_management.rb +2 -6
- data/app/models/operations/task/testing.rb +11 -2
- data/app/models/operations/task.rb +46 -9
- data/lib/operations/cannot_wait_in_foreground.rb +2 -0
- data/lib/operations/global_id_serialiser.rb +9 -1
- data/lib/operations/timeout.rb +2 -0
- data/lib/operations/version.rb +1 -1
- data/lib/operations.rb +2 -0
- metadata +8 -3
- data/app/models/operations/task/sub_tasks.rb +0 -7
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f99019aca1964cfe7e6607d45c1c253ed73f8081702b596bb6fdbb918c7d74f7
|
4
|
+
data.tar.gz: 8c6599fe4f7227226ecc23773902eb8f2c3e0a5d37d2f6f3a918a8dfff8ee35b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2d40c6a60efea70434ae993ad37d5872f71173cf1c4d6e61cf436f735538dc9e72c9d268a40a7c7be81ebb66a7bc46ff70b868ff6af66bf30db1c574eca8787b
|
7
|
+
data.tar.gz: 5931d257ab4d45b04d20f96244ce20a65d292808bf10eae66930fcfe0bf35e52d1c539bde83c8fabf8ddff0cdb44be548f763b0949671aaaa5309bda039f285d
|
data/README.md
CHANGED
@@ -157,8 +157,11 @@ end
|
|
157
157
|
```
|
158
158
|
Do not forget to call `go_to` from your action handler, otherwise the operation will just stop whilst still being marked as in progress. (TODO: don't let this happen).
|
159
159
|
|
160
|
+
### Waiting
|
161
|
+
Wait handlers only work within [background tasks](#background-operations-and-pauses). They define a condition that is evaluated; if it is false, the task is paused and the condition re-evaluated later. If it is true, the task moves to the next state.
|
162
|
+
|
160
163
|
### Results
|
161
|
-
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
|
164
|
+
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.
|
162
165
|
|
163
166
|
```ruby
|
164
167
|
action :send_invitations do
|
@@ -207,22 +210,6 @@ end
|
|
207
210
|
### Calling an operation
|
208
211
|
You would use the earlier [PrepareDocumentForDownload](spec/examples/prepare_document_for_download_spec.rb) operation in a controller like this:
|
209
212
|
|
210
|
-
```ruby
|
211
|
-
class DownloadsController < ApplicationController
|
212
|
-
def show
|
213
|
-
@document = Document.includes(:account).find(params[:id])
|
214
|
-
@task = PrepareDocumentForDownload.call(user: Current.user, document: @document, use_filename_scrambler: @document.account.use_filename_scrambler?)
|
215
|
-
if @task.completed?
|
216
|
-
@filename = @task.results[:filename]
|
217
|
-
send_data @document.contents, filename: @filename, disposition: "attachment"
|
218
|
-
else
|
219
|
-
render action: "error", message: @task.results[:failure_message], status: 401
|
220
|
-
end
|
221
|
-
end
|
222
|
-
end
|
223
|
-
```
|
224
|
-
|
225
|
-
Future dev: I'm going to change this so it raises an exception on failure so it will become:
|
226
213
|
```ruby
|
227
214
|
class DownloadsController < ApplicationController
|
228
215
|
def show
|
@@ -237,12 +224,16 @@ class DownloadsController < ApplicationController
|
|
237
224
|
end
|
238
225
|
```
|
239
226
|
|
240
|
-
OK - so that's a pretty longwinded way of performing a simple task.
|
227
|
+
OK - so that's a pretty longwinded way of performing a simple task.
|
228
|
+
|
229
|
+
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.
|
230
|
+
|
231
|
+
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.
|
241
232
|
|
242
233
|
### Data and results
|
243
234
|
Each operation carries its own, mutable, [data](/app/models/operations/task/data_carrier.rb) for the duration of the operation.
|
244
235
|
|
245
|
-
This is provided when you `call` the operation to start it and is passed through to each decision, action and result.
|
236
|
+
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.
|
246
237
|
|
247
238
|
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`.
|
248
239
|
|
@@ -265,18 +256,22 @@ task = CombineNames.call first_name: "Alice", last_name: "Aardvark"
|
|
265
256
|
task.results[:name] # => Alice Aardvark
|
266
257
|
```
|
267
258
|
|
268
|
-
Because handlers are run in the context of the data carrier,
|
259
|
+
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 exceptions are the `go_to`, `fail_with`, `call` and `start` methods which the data carrier understands (and are intercepted when you are [testing](#testing)).
|
260
|
+
|
261
|
+
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.
|
269
262
|
|
270
|
-
|
263
|
+
They are both stored as hashes that are encoded into JSON.
|
271
264
|
|
272
|
-
|
265
|
+
Instead of using the standard [JSON coder](https://api.rubyonrails.org/v4.2/classes/ActiveModel/Serializers/JSON.html), we use a [GlobalIDSerialiser](/lib/operations/global_id_serialiser.rb). This uses [ActiveJob::Arguments](https://guides.rubyonrails.org/active_job_basics.html#supported-types-for-arguments) to transform any models into [GlobalIDs](https://github.com/rails/globalid) before storage and convert them back to models upon retrieval.
|
266
|
+
|
267
|
+
If the original database record was deleted between the time the hash was serialised and when it was retrieved, the `GlobalID::Locator` will fail. With ActiveJob, this means that the job cannot run and is discarded. For Operations, we attempt to deserialise a second time, returning the GlobalID string instead of the model. So be aware that when you access `data` or `results` you may receive a string (similar to `"gid://test-app/User/1"`) instead of the models you were expecting. And the error handling deserialiser is very simple so you may get format changes in some of the data as well. If serialisation fails you can access the original JSON string as `data.raw_data` or `results[:raw_data]`.
|
268
|
+
|
269
|
+
TODO: Replace the ActiveJob::Arguments deserialiser with the [transporter](https://github.com/standard-procedure/plumbing/blob/main/lib/plumbing/actor/transporter.rb) from [plumbing](https://github.com/standard-procedure/plumbing)
|
273
270
|
|
274
271
|
### Failures and exceptions
|
275
272
|
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.
|
276
273
|
|
277
|
-
You can also stop a task at any point by calling `fail_with message`. This will
|
278
|
-
|
279
|
-
(Future dev - going to change this so it raises an exception (or passes it on to the caller) so you don't need to test for `failed?` every time - this will simplify managing sub-tasks).
|
274
|
+
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]`.
|
280
275
|
|
281
276
|
### Task life-cycle and the database
|
282
277
|
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.
|
@@ -332,8 +327,109 @@ class PrepareDownload < Operations::Task
|
|
332
327
|
end
|
333
328
|
```
|
334
329
|
|
330
|
+
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).
|
331
|
+
|
335
332
|
### Background operations and pauses
|
336
|
-
|
333
|
+
If you have ActiveJob configured, you can run your operations in the background.
|
334
|
+
|
335
|
+
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.
|
336
|
+
|
337
|
+
By itself, this is not particularly useful - it just makes your operation take even longer to complete.
|
338
|
+
|
339
|
+
But if your operation may need to wait for something else to happen, background tasks are perfect.
|
340
|
+
|
341
|
+
#### Waiting for user input
|
342
|
+
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.
|
343
|
+
|
344
|
+
```ruby
|
345
|
+
class UserRegistration < Operations::Task
|
346
|
+
inputs :email
|
347
|
+
starts_with :create_user
|
348
|
+
|
349
|
+
action :create_user do
|
350
|
+
inputs :email
|
351
|
+
|
352
|
+
self.user = User.create! email: email
|
353
|
+
go_to :send_verification_email
|
354
|
+
end
|
355
|
+
|
356
|
+
action :send_verification_email do
|
357
|
+
inputs :user
|
358
|
+
|
359
|
+
UserMailer.with(user: user).verification_email.deliver_later
|
360
|
+
go_to :verified?
|
361
|
+
end
|
362
|
+
|
363
|
+
wait_until :verified? do
|
364
|
+
condition { user.verified? }
|
365
|
+
go_to :notify_administrator
|
366
|
+
end
|
367
|
+
|
368
|
+
action :notify_administrator do
|
369
|
+
inputs :user
|
370
|
+
|
371
|
+
AdminMailer.with(user: user).verification_completed.deliver_later
|
372
|
+
end
|
373
|
+
end
|
374
|
+
|
375
|
+
@task = UserRegistration.start email: "someone@example.com"
|
376
|
+
```
|
377
|
+
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.
|
378
|
+
|
379
|
+
#### Waiting for sub-tasks to complete
|
380
|
+
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).
|
381
|
+
|
382
|
+
```ruby
|
383
|
+
class ParallelTasks < Operations::Task
|
384
|
+
inputs :number_of_sub_tasks
|
385
|
+
starts_with :start_sub_tasks
|
386
|
+
|
387
|
+
action :start_sub_tasks do
|
388
|
+
inputs :number_of_sub_tasks
|
389
|
+
self.sub_tasks = (1..number_of_sub_tasks).collect { |i| start LongRunningTask, number: i }
|
390
|
+
go_to :do_something_else
|
391
|
+
end
|
392
|
+
|
393
|
+
action :do_something_else do
|
394
|
+
# do something else while the sub-tasks do their thing
|
395
|
+
go_to :sub_tasks_completed?
|
396
|
+
end
|
397
|
+
|
398
|
+
wait_until :sub_tasks_completed? do
|
399
|
+
condition { sub_tasks.all? { |t| t.completed? } }
|
400
|
+
go_to :done
|
401
|
+
end
|
402
|
+
|
403
|
+
result :done
|
404
|
+
end
|
405
|
+
|
406
|
+
@task = ParallelTasks.start number_of_sub_tasks: 5
|
407
|
+
```
|
408
|
+
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.
|
409
|
+
|
410
|
+
#### Delays and Timeouts
|
411
|
+
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.
|
412
|
+
|
413
|
+
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:
|
414
|
+
|
415
|
+
```ruby
|
416
|
+
class ParallelTasks < Operations::Tasks
|
417
|
+
delay 1.minute
|
418
|
+
...
|
419
|
+
end
|
420
|
+
```
|
421
|
+
|
422
|
+
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.
|
423
|
+
|
424
|
+
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.
|
425
|
+
|
426
|
+
```ruby
|
427
|
+
class UserRegistration < Operations::Task
|
428
|
+
timeout 24.hours
|
429
|
+
delay 15.minutes
|
430
|
+
...
|
431
|
+
end
|
432
|
+
```
|
337
433
|
|
338
434
|
## Testing
|
339
435
|
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.
|
@@ -407,13 +503,9 @@ end
|
|
407
503
|
### Testing failures
|
408
504
|
To test if a handler has failed:
|
409
505
|
```ruby
|
410
|
-
|
411
|
-
|
412
|
-
# or
|
413
|
-
expect(test).to have_failed_with "oh dear"
|
414
|
-
end
|
506
|
+
|
507
|
+
expect { MyOperation.handling(:a_failure, some: "data") }.to raise_error(SomeException)
|
415
508
|
```
|
416
|
-
(Future dev: exceptions will not be silently caught so this will probably become `expect { MyOperation.handling(:a_failure, some: "data") }.to raise_exception(Whatever)).
|
417
509
|
|
418
510
|
If you are using RSpec, you must `require "operations/matchers"` to make the matchers available to your specs.
|
419
511
|
|
@@ -457,7 +549,10 @@ The gem is available as open source under the terms of the [LGPL License](/LICEN
|
|
457
549
|
- [ ] Figure out how to stub calling sub-tasks with known results data
|
458
550
|
- [ ] Figure out how to test the parameters passed to sub-tasks when they are called
|
459
551
|
- [ ] Split out the state-management definition stuff from the task class (so you can use it without subclassing Operations::Task)
|
460
|
-
- [
|
461
|
-
- [
|
462
|
-
- [
|
552
|
+
- [x] Make Operations::Task work in the background using ActiveJob
|
553
|
+
- [x] Add pause/resume capabilities (for example, when a task needs to wait for user input)
|
554
|
+
- [x] Add wait for sub-tasks capabilities
|
555
|
+
- [ ] Add ActiveModel validations support for task parameters
|
556
|
+
- [ ] Option to change background job queue and priority settings
|
557
|
+
- [ ] Replace the ActiveJob::Arguments deserialiser with the [transporter](https://github.com/standard-procedure/plumbing/blob/main/lib/plumbing/actor/transporter.rb) from [plumbing](https://github.com/standard-procedure/plumbing)
|
463
558
|
- [ ] Maybe? Split this out into two gems - one defining an Operation (pure ruby) and another defining the Task (using ActiveJob as part of a Rails Engine)
|
@@ -0,0 +1,9 @@
|
|
1
|
+
module Operations
|
2
|
+
class ApplicationJob < ActiveJob::Base
|
3
|
+
# Automatically retry jobs that encountered a deadlock
|
4
|
+
# retry_on ActiveRecord::Deadlocked
|
5
|
+
|
6
|
+
# Most jobs are safe to ignore if the underlying records are no longer available
|
7
|
+
# discard_on ActiveJob::DeserializationError
|
8
|
+
end
|
9
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module Operations::Task::Background
|
2
|
+
extend ActiveSupport::Concern
|
3
|
+
|
4
|
+
class_methods do
|
5
|
+
def delay(value)
|
6
|
+
@background_delay = value
|
7
|
+
end
|
8
|
+
|
9
|
+
def timeout(value)
|
10
|
+
@execution_timeout = value
|
11
|
+
end
|
12
|
+
|
13
|
+
def background_delay = @background_delay ||= 1.second
|
14
|
+
|
15
|
+
def execution_timeout = @execution_timeout ||= 5.minutes
|
16
|
+
|
17
|
+
def with_timeout(data) = data.merge(_execution_timeout: execution_timeout.from_now.utc)
|
18
|
+
end
|
19
|
+
|
20
|
+
private def background_delay = self.class.background_delay
|
21
|
+
private def execution_timeout = self.class.execution_timeout
|
22
|
+
private def timeout!
|
23
|
+
raise Operations::Timeout.new("Timeout expired", self) if timeout_expired?
|
24
|
+
end
|
25
|
+
private def timeout_expired? = data[:_execution_timeout].present? && data[:_execution_timeout] < Time.now.utc
|
26
|
+
end
|
@@ -1,10 +1,12 @@
|
|
1
1
|
class Operations::Task::DataCarrier < OpenStruct
|
2
|
-
def go_to(state, message
|
2
|
+
def go_to(state, message: nil) = task.go_to(state, self, message: message)
|
3
3
|
|
4
4
|
def fail_with(message) = task.fail_with(message)
|
5
5
|
|
6
6
|
def call(sub_task_class, **data, &result_handler) = task.call(sub_task_class, **data, &result_handler)
|
7
7
|
|
8
|
+
def start(sub_task_class, **data, &result_handler) = task.start(sub_task_class, **data, &result_handler)
|
9
|
+
|
8
10
|
def complete(results) = task.complete(results)
|
9
11
|
|
10
12
|
def inputs(*names)
|
@@ -17,8 +17,7 @@ class Operations::Task::StateManagement::DecisionHandler
|
|
17
17
|
|
18
18
|
def call(task, data)
|
19
19
|
validate_inputs! data.to_h
|
20
|
-
|
21
|
-
next_state
|
22
|
-
next_state.respond_to?(:call) ? data.instance_eval(&next_state) : data.go_to(next_state, data)
|
20
|
+
next_state = data.instance_eval(&@condition) ? @true_state : @false_state
|
21
|
+
next_state.respond_to?(:call) ? data.instance_eval(&next_state) : data.go_to(next_state)
|
23
22
|
end
|
24
23
|
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
class Operations::Task::StateManagement::WaitHandler
|
2
|
+
def initialize name, &config
|
3
|
+
@name = name.to_sym
|
4
|
+
@next_state = nil
|
5
|
+
@condition = nil
|
6
|
+
instance_eval(&config)
|
7
|
+
end
|
8
|
+
|
9
|
+
def condition(&condition) = @condition = condition
|
10
|
+
|
11
|
+
def go_to(state) = @next_state = state
|
12
|
+
|
13
|
+
def call(task, data)
|
14
|
+
raise Operations::CannotWaitInForeground.new("#{task.class} cannot wait in the foreground", task) unless task.background?
|
15
|
+
next_state = data.instance_eval(&@condition) ? @next_state : task.state
|
16
|
+
data.go_to(next_state)
|
17
|
+
end
|
18
|
+
end
|
@@ -15,6 +15,8 @@ module Operations::Task::StateManagement
|
|
15
15
|
|
16
16
|
def action(name, inputs: [], optional: [], &handler) = state_handlers[name.to_sym] = ActionHandler.new(name, inputs, optional, &handler)
|
17
17
|
|
18
|
+
def wait_until(name, &config) = state_handlers[name.to_sym] = WaitHandler.new(name, &config)
|
19
|
+
|
18
20
|
def result(name, inputs: [], optional: [], &results) = state_handlers[name.to_sym] = CompletionHandler.new(name, inputs, optional, &results)
|
19
21
|
|
20
22
|
def state_handlers = @state_handlers ||= {}
|
@@ -23,12 +25,6 @@ module Operations::Task::StateManagement
|
|
23
25
|
end
|
24
26
|
|
25
27
|
private def handler_for(state) = self.class.handler_for(state.to_sym)
|
26
|
-
private def process_current_state(data)
|
27
|
-
handler_for(state).call(self, data)
|
28
|
-
rescue => ex
|
29
|
-
update! status: "failed", status_message: ex.message.to_s.truncate(240), results: {failure_message: ex.message, exception_class: ex.class.name, exception_backtrace: ex.backtrace}
|
30
|
-
raise ex
|
31
|
-
end
|
32
28
|
private def state_is_valid
|
33
29
|
errors.add :state, :invalid if state.blank? || handler_for(state.to_sym).nil?
|
34
30
|
end
|
@@ -21,13 +21,22 @@ module Operations::Task::Testing
|
|
21
21
|
end
|
22
22
|
|
23
23
|
def call(sub_task_class, **data, &result_handler)
|
24
|
-
|
25
|
-
|
24
|
+
record_sub_task sub_task_class
|
25
|
+
super
|
26
|
+
end
|
27
|
+
|
28
|
+
def start(sub_task_class, **data, &result_handler)
|
29
|
+
record_sub_task sub_task_class
|
26
30
|
super
|
27
31
|
end
|
28
32
|
|
29
33
|
def complete(results)
|
30
34
|
self.completion_results = results
|
31
35
|
end
|
36
|
+
|
37
|
+
private def record_sub_task sub_task_class
|
38
|
+
self.sub_tasks ||= []
|
39
|
+
self.sub_tasks << sub_task_class
|
40
|
+
end
|
32
41
|
end
|
33
42
|
end
|
@@ -1,24 +1,54 @@
|
|
1
1
|
module Operations
|
2
2
|
class Task < ApplicationRecord
|
3
3
|
include StateManagement
|
4
|
-
include SubTasks
|
5
4
|
include Deletion
|
6
5
|
include Testing
|
6
|
+
include Background
|
7
7
|
extend InputValidation
|
8
8
|
|
9
|
-
enum :status, in_progress: 0, completed:
|
9
|
+
enum :status, in_progress: 0, waiting: 10, completed: 100, failed: -1
|
10
|
+
serialize :data, coder: Operations::GlobalIDSerialiser, type: Hash, default: {}
|
10
11
|
serialize :results, coder: Operations::GlobalIDSerialiser, type: Hash, default: {}
|
11
12
|
|
12
|
-
def
|
13
|
-
|
14
|
-
|
15
|
-
|
13
|
+
def call sub_task_class, **data, &result_handler
|
14
|
+
sub_task = sub_task_class.call(**data)
|
15
|
+
result_handler&.call(sub_task.results)
|
16
|
+
sub_task.results
|
17
|
+
end
|
18
|
+
|
19
|
+
def start sub_task_class, **data, &result_handler
|
20
|
+
sub_task_class.start(**data)
|
21
|
+
end
|
22
|
+
|
23
|
+
def perform
|
24
|
+
timeout!
|
25
|
+
in_progress!
|
26
|
+
handler_for(state).call(self, carrier_for(data))
|
27
|
+
rescue => ex
|
28
|
+
update! status: "failed", status_message: ex.message.to_s.truncate(240), results: {failure_message: ex.message, exception_class: ex.class.name, exception_backtrace: ex.backtrace}
|
29
|
+
raise ex
|
30
|
+
end
|
31
|
+
|
32
|
+
def perform_later
|
33
|
+
waiting!
|
34
|
+
TaskRunnerJob.set(wait_until: background_delay.from_now).perform_later self
|
35
|
+
end
|
36
|
+
|
37
|
+
def self.call(**)
|
38
|
+
build(background: false, **).tap do |task|
|
39
|
+
task.perform
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.start(**data)
|
44
|
+
build(background: true, **with_timeout(data)).tap do |task|
|
45
|
+
task.perform_later
|
16
46
|
end
|
17
47
|
end
|
18
48
|
|
19
|
-
def go_to(state, data = {}, message
|
20
|
-
update!(state: state, status_message: (message || state).to_s.truncate(240))
|
21
|
-
|
49
|
+
def go_to(state, data = {}, message: nil)
|
50
|
+
update!(state: state, data: data.to_h, status_message: (message || state).to_s.truncate(240))
|
51
|
+
background? ? perform_later : perform
|
22
52
|
end
|
23
53
|
|
24
54
|
def fail_with(message)
|
@@ -27,5 +57,12 @@ module Operations
|
|
27
57
|
end
|
28
58
|
|
29
59
|
def complete(results) = update!(status: "completed", status_message: "completed", results: results.to_h)
|
60
|
+
|
61
|
+
private def carrier_for(data) = data.is_a?(DataCarrier) ? data : DataCarrier.new(data.merge(task: self))
|
62
|
+
|
63
|
+
def self.build(background:, **data)
|
64
|
+
validate_inputs! data
|
65
|
+
create!(state: initial_state, status: background ? "waiting" : "in_progress", data: data, status_message: "", background: background)
|
66
|
+
end
|
30
67
|
end
|
31
68
|
end
|
@@ -15,7 +15,15 @@ module Operations
|
|
15
15
|
def self.load(json)
|
16
16
|
ActiveJob::Arguments.deserialize(ActiveSupport::JSON.decode(json)).first
|
17
17
|
rescue => ex
|
18
|
-
|
18
|
+
_load_without_global_ids(json).merge exception_message: ex.message, exception_class: ex.class.name, raw_data: json.to_s
|
19
|
+
end
|
20
|
+
|
21
|
+
def self._load_without_global_ids(json)
|
22
|
+
ActiveSupport::JSON.decode(json).first.tap do |hash|
|
23
|
+
hash.delete("_aj_symbol_keys")
|
24
|
+
end.transform_values do |value|
|
25
|
+
(value.is_a?(Hash) && value.key?("_aj_globalid")) ? value["_aj_globalid"] : value
|
26
|
+
end.transform_keys(&:to_sym)
|
19
27
|
end
|
20
28
|
end
|
21
29
|
end
|
data/lib/operations/version.rb
CHANGED
data/lib/operations.rb
CHANGED
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: standard_procedure_operations
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Rahoul Baruah
|
8
8
|
bindir: bin
|
9
9
|
cert_chain: []
|
10
|
-
date: 2025-02-
|
10
|
+
date: 2025-02-05 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
12
12
|
- !ruby/object:Gem::Dependency
|
13
13
|
name: rails
|
@@ -33,7 +33,10 @@ files:
|
|
33
33
|
- LICENSE
|
34
34
|
- README.md
|
35
35
|
- Rakefile
|
36
|
+
- app/jobs/operations/application_job.rb
|
37
|
+
- app/jobs/operations/task_runner_job.rb
|
36
38
|
- app/models/operations/task.rb
|
39
|
+
- app/models/operations/task/background.rb
|
37
40
|
- app/models/operations/task/data_carrier.rb
|
38
41
|
- app/models/operations/task/deletion.rb
|
39
42
|
- app/models/operations/task/input_validation.rb
|
@@ -41,15 +44,17 @@ files:
|
|
41
44
|
- app/models/operations/task/state_management/action_handler.rb
|
42
45
|
- app/models/operations/task/state_management/completion_handler.rb
|
43
46
|
- app/models/operations/task/state_management/decision_handler.rb
|
44
|
-
- app/models/operations/task/
|
47
|
+
- app/models/operations/task/state_management/wait_handler.rb
|
45
48
|
- app/models/operations/task/testing.rb
|
46
49
|
- config/routes.rb
|
47
50
|
- db/migrate/20250127160616_create_operations_tasks.rb
|
48
51
|
- lib/operations.rb
|
52
|
+
- lib/operations/cannot_wait_in_foreground.rb
|
49
53
|
- lib/operations/engine.rb
|
50
54
|
- lib/operations/failure.rb
|
51
55
|
- lib/operations/global_id_serialiser.rb
|
52
56
|
- lib/operations/matchers.rb
|
57
|
+
- lib/operations/timeout.rb
|
53
58
|
- lib/operations/version.rb
|
54
59
|
- lib/standard_procedure_operations.rb
|
55
60
|
- lib/tasks/operations_tasks.rake
|