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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: abbdad16738becd8b42eefb407d07e199bd4ab02b3590cdc5e14a605a8e9a8a2
4
- data.tar.gz: f78887c11e55d0542d0b6f32435de1b07132879248dfccc9349f7b87e2746000
3
+ metadata.gz: f99019aca1964cfe7e6607d45c1c253ed73f8081702b596bb6fdbb918c7d74f7
4
+ data.tar.gz: 8c6599fe4f7227226ecc23773902eb8f2c3e0a5d37d2f6f3a918a8dfff8ee35b
5
5
  SHA512:
6
- metadata.gz: e1d7e6702dce6caaeaae519e72dbad953b9a597e70863d8dc222f9db08c9292c8736b9e3f16059567d6b972754272aa7e11dffd9ab73f4168ed178ce286030d2
7
- data.tar.gz: d1c4e2642723b131264dc6421755cd781f40ec6a1da78b385ac4b53bd11ad52901f1cbeacfd5b76620076ed3f3adc33a56088c5c764d9ba1cf1a05c310c03615
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 (and your data, effectively your "working memory", can be safely discarded).
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. But, in Collabor8Online, the actual operation for handling downloads has over twenty states, with half of them being decisions (as there are a number of feature flags and per-account configuration options). Originally these were spread across multiple controllers, models and other objects. Now they are centralised in a single "operations map" that describes the flowchart used to prepare a document for download - invaluable for comprehension of complex logic.
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. This data is transient and not stored in the database. If you modify the data then that modification is passed on to the next handler. (Note - when background tasks are implemented, we may end up storing the data in the database).
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, this means you do not have direct access to methods or properties on your task object. So you need to use `task` to access it - `task.do_something` or `task.some_attribute`. The exceptions are the `go_to` and `fail_with` methods which the data carrier forwards to the task (and the `TestResultCarrier` intercepts when you are testing your operation).
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
- The final `results` data from any `result` handlers is stored, along with the task, in the database, so it can be examined later. It is a Hash that is encoded into JSON with any ActiveRecord models translated using a [GlobalID](https://github.com/rails/globalid) (this uses [ActiveJob::Arguments](https://guides.rubyonrails.org/active_job_basics.html#supported-types-for-arguments) so works the same way as passing data to ActiveJob).
263
+ They are both stored as hashes that are encoded into JSON.
271
264
 
272
- Be aware that if you do store an ActiveRecord model into your `results` and that model is later deleted from the database, your task's `results` will be unavailable (as `GlobalID::Locator` will fail when it tries to load the record). The data is not lost though - if the deserialisation fails, the routine will return the JSON string as `results[:raw_data]`.
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 mark the task as `failed?` and the `results` has will contain `results[:failure_message]`.
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
- Coming soon.
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
- MyOperation.handling(:a_failure, some: "data") do |test|
411
- assert_equal test.failure_message, "oh dear"
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
- - [ ] Make Operations::Task work in the background using ActiveJob
461
- - [ ] Add pause/resume capabilities (for example, when a task needs to wait for user input)
462
- - [ ] Add wait for sub-tasks capabilities
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,11 @@
1
+ module Operations
2
+ class TaskRunnerJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ def perform task
6
+ task.perform if task.waiting?
7
+ rescue => ex
8
+ Rails.logger.error "TaskRunnerJob failed: #{ex.message} for #{task.inspect}"
9
+ end
10
+ end
11
+ 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 = nil) = task.go_to(state, self, 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)
@@ -6,7 +6,5 @@ class Operations::Task::StateManagement::ActionHandler
6
6
  @action = action
7
7
  end
8
8
 
9
- def call(task, data)
10
- @action.nil? ? task.send(@name, data) : data.instance_exec(&@action)
11
- end
9
+ def call(task, data) = data.instance_exec(&@action)
12
10
  end
@@ -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
- result = @condition.nil? ? task.send(@name, data) : data.instance_exec(&@condition)
21
- next_state = result ? @true_state : @false_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
- self.sub_tasks ||= []
25
- self.sub_tasks << sub_task_class
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: 1, failed: -1
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 self.call(data = {})
13
- validate_inputs! data
14
- create!(state: initial_state, status_message: "").tap do |task|
15
- task.send(:process_current_state, DataCarrier.new(data.merge(task: task)))
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 = nil)
20
- update!(state: state, status_message: (message || state).to_s.truncate(240))
21
- process_current_state(data)
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
@@ -0,0 +1,2 @@
1
+ class Operations::CannotWaitInForeground < Operations::Error
2
+ 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
- {exception_message: ex.message, exception_class: ex.class.name, raw_data: json.to_s}
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
@@ -0,0 +1,2 @@
1
+ class Operations::Timeout < Operations::Error
2
+ end
@@ -1,3 +1,3 @@
1
1
  module Operations
2
- VERSION = "0.2.6"
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/operations.rb CHANGED
@@ -12,4 +12,6 @@ module Operations
12
12
  require "operations/engine"
13
13
  require "operations/global_id_serialiser"
14
14
  require "operations/failure"
15
+ require "operations/cannot_wait_in_foreground"
16
+ require "operations/timeout"
15
17
  end
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.2.6
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-04 00:00:00.000000000 Z
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/sub_tasks.rb
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
@@ -1,7 +0,0 @@
1
- module Operations::Task::SubTasks
2
- def call sub_task_class, **data, &result_handler
3
- sub_task = sub_task_class.call(data)
4
- result_handler&.call(sub_task.results)
5
- sub_task.results
6
- end
7
- end