standard_procedure_operations 0.5.0 → 0.5.2
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 +20 -24
- data/app/models/operations/task/background.rb +11 -0
- data/app/models/operations/task.rb +4 -3
- data/db/migrate/20250403075414_add_becomes_zombie_at_field.rb +6 -0
- data/lib/operations/version.rb +1 -1
- data/lib/operations.rb +1 -1
- data/lib/tasks/operations_tasks.rake +4 -4
- metadata +18 -4
- data/lib/operations/global_id_serialiser.rb +0 -29
- /data/db/migrate/{20250309_create_operations_task_participants.rb → 20250309160616_create_operations_task_participants.rb} +0 -0
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c254fd322931c448af32ef7aef5bfba2f09293b5d2ab2b85d069783be09cb8e3
|
4
|
+
data.tar.gz: e329a253576b4c4756793a068eedbb275c9ddab4ce0af4059285c0d13ad5dffe
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 920f49215031fce81eabe5dd4aec50df392d1dfd93cc497eb47aafbb4544ab9defd378faf0bb99d88b0c5c5fbf326e2dd2e77df6f2d8c737589944e174c0e740
|
7
|
+
data.tar.gz: 2c5c2cc235ed70113c7881fb75f46a9962548bd333d589cc73cbcbc5fbeb50f030b5212e2e2bbd16b7b4b3509284aa1b8e04d151b84cfde07780c4da5f9a9e5c
|
data/README.md
CHANGED
@@ -48,7 +48,6 @@ class PrepareDocumentForDownload < Operations::Task
|
|
48
48
|
starts_with :authorised?
|
49
49
|
|
50
50
|
decision :authorised? do
|
51
|
-
inputs :user
|
52
51
|
condition { user.can?(:read, data.document) }
|
53
52
|
|
54
53
|
if_true :within_download_limits?
|
@@ -56,7 +55,6 @@ class PrepareDocumentForDownload < Operations::Task
|
|
56
55
|
end
|
57
56
|
|
58
57
|
decision :within_download_limits? do
|
59
|
-
inputs :user
|
60
58
|
condition { user.within_download_limits? }
|
61
59
|
|
62
60
|
if_true :use_filename_scrambler?
|
@@ -64,7 +62,6 @@ class PrepareDocumentForDownload < Operations::Task
|
|
64
62
|
end
|
65
63
|
|
66
64
|
decision :use_filename_scrambler? do
|
67
|
-
inputs :use_filename_scrambler
|
68
65
|
condition { use_filename_scrambler }
|
69
66
|
|
70
67
|
if_true :scramble_filename
|
@@ -72,16 +69,11 @@ class PrepareDocumentForDownload < Operations::Task
|
|
72
69
|
end
|
73
70
|
|
74
71
|
action :scramble_filename do
|
75
|
-
inputs :document
|
76
|
-
|
77
72
|
self.filename = "#{Faker::Lorem.word}#{File.extname(document.filename.to_s)}"
|
78
|
-
end
|
73
|
+
end
|
79
74
|
go_to :return_filename
|
80
75
|
|
81
76
|
result :return_filename do |results|
|
82
|
-
inputs :document
|
83
|
-
optional :filename
|
84
|
-
|
85
77
|
results.filename = filename || document.filename.to_s
|
86
78
|
end
|
87
79
|
end
|
@@ -158,7 +150,9 @@ go_to :send_invitations
|
|
158
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.
|
159
151
|
|
160
152
|
```ruby
|
161
|
-
action :have_a_party
|
153
|
+
action :have_a_party do
|
154
|
+
inputs :number_of_guests
|
155
|
+
optional :music
|
162
156
|
self.food = task.buy_some_food_for(number_of_guests)
|
163
157
|
self.beer = task.buy_some_beer_for(number_of_guests)
|
164
158
|
self.music ||= task.plan_a_party_playlist
|
@@ -166,8 +160,6 @@ end
|
|
166
160
|
go_to :send_invitations
|
167
161
|
```
|
168
162
|
|
169
|
-
Defining state transitions statically with `go_to` ensures that all transitions are known when the operation class is loaded, making the flow easier to understand and analyze. The `go_to` method will automatically associate the transition with the most recently defined action handler.
|
170
|
-
|
171
163
|
### Waiting
|
172
164
|
Wait handlers are very similar to decision handlers but only work within [background tasks](#background-operations-and-pauses).
|
173
165
|
|
@@ -280,15 +272,17 @@ task = CombineNames.call first_name: "Alice", last_name: "Aardvark"
|
|
280
272
|
task.results[:name] # => Alice Aardvark
|
281
273
|
```
|
282
274
|
|
283
|
-
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)).
|
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)).
|
284
276
|
|
285
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.
|
286
278
|
|
287
279
|
They are both stored as hashes that are encoded into JSON.
|
288
280
|
|
289
|
-
Instead of using the standard [JSON coder](https://api.rubyonrails.org/v4.2/classes/ActiveModel/Serializers/JSON.html), we use a [
|
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.
|
282
|
+
|
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.
|
290
284
|
|
291
|
-
|
285
|
+
Also note that the GlobalIdSerialiser automatically converts all hash keys into symbols (unlike the standard JSON coder which uses strings).
|
292
286
|
|
293
287
|
#### Indexing data and results
|
294
288
|
|
@@ -301,7 +295,7 @@ For example, you create your task as:
|
|
301
295
|
@alice = User.find 123
|
302
296
|
@task = DoSomethingImportant.call user: @alice
|
303
297
|
```
|
304
|
-
There will
|
298
|
+
There will be a `TaskParticipant` record with a `context` of "data", `role` of "user" and `participant` of `@alice`.
|
305
299
|
|
306
300
|
Likewise, you can see all the tasks that Alice was involved with using:
|
307
301
|
```ruby
|
@@ -386,15 +380,11 @@ class UserRegistration < Operations::Task
|
|
386
380
|
starts_with :create_user
|
387
381
|
|
388
382
|
action :create_user do
|
389
|
-
inputs :email
|
390
|
-
|
391
383
|
self.user = User.create! email: email
|
392
384
|
end
|
393
385
|
go_to :send_verification_email
|
394
386
|
|
395
387
|
action :send_verification_email do
|
396
|
-
inputs :user
|
397
|
-
|
398
388
|
UserMailer.with(user: user).verification_email.deliver_later
|
399
389
|
end
|
400
390
|
go_to :verified?
|
@@ -405,8 +395,6 @@ class UserRegistration < Operations::Task
|
|
405
395
|
end
|
406
396
|
|
407
397
|
action :notify_administrator do
|
408
|
-
inputs :user
|
409
|
-
|
410
398
|
AdminMailer.with(user: user).verification_completed.deliver_later
|
411
399
|
end
|
412
400
|
end
|
@@ -424,7 +412,6 @@ class ParallelTasks < Operations::Task
|
|
424
412
|
starts_with :start_sub_tasks
|
425
413
|
|
426
414
|
action :start_sub_tasks do
|
427
|
-
inputs :number_of_sub_tasks
|
428
415
|
self.sub_tasks = (1..number_of_sub_tasks).collect { |i| start LongRunningTask, number: i }
|
429
416
|
end
|
430
417
|
go_to :do_something_else
|
@@ -483,6 +470,16 @@ class WaitForSomething < Operations::Task
|
|
483
470
|
end
|
484
471
|
```
|
485
472
|
|
473
|
+
#### Zombie tasks
|
474
|
+
|
475
|
+
There's a chance that the `Operations::TaskRunnerJob` might get lost - maybe there's a crash in some process and the job does not restart correctly. As the process for handling background tasks relies on the task "waking up", performing the next action, then queuing up the next task-runner, if the background job does not queue as expected, the task will sit there, waiting forever.
|
476
|
+
|
477
|
+
To monitor for this, every task can be checked to see if it is a `zombie?`. This means that the current time is more than 3 times the expected delay, compared to the `updated_at` field. So if the `delay` is set to 1 minute and the task last woke up more than 3 minutes ago, it is classed as a zombie.
|
478
|
+
|
479
|
+
There are two ways to handle zombies.
|
480
|
+
- Manually; add a user interface listing your tasks with a "Restart" button. The "Restart" button calls `restart` on the task (which internally schedules a new task runner job).
|
481
|
+
- Automatically; set up a cron job which calls the `operations:restart_zombie_tasks` rake task. This rake task searches for zombie jobs and calls `restart` on them. Note that cron jobs have a minimum resolution of 1 minute so this will cause pauses in tasks with a delay measured in seconds. Also be aware that a cron job that calls a rake task will load the entire Rails stack as a new process, so be sure that your server has sufficient memory to cope. If you're using [SolidQueue](https://github.com/rails/solid_queue/), the job runner already sets up a separate "supervisor" process and allows you to define [recurring jobs](https://github.com/rails/solid_queue/#recurring-tasks) with a resolution of 1 second. This may be a suitable solution, but I've not tried it yet.
|
482
|
+
|
486
483
|
## Testing
|
487
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.
|
488
485
|
|
@@ -598,7 +595,6 @@ end
|
|
598
595
|
|
599
596
|
The visualization includes:
|
600
597
|
- Color-coded nodes by state type (decisions, actions, wait states, results)
|
601
|
-
- Required and optional inputs for each state
|
602
598
|
- Transition conditions between states with custom labels when provided
|
603
599
|
- Special handling for custom transition blocks
|
604
600
|
|
@@ -1,6 +1,11 @@
|
|
1
1
|
module Operations::Task::Background
|
2
2
|
extend ActiveSupport::Concern
|
3
3
|
|
4
|
+
included do
|
5
|
+
scope :zombies, -> { zombies_at(Time.now) }
|
6
|
+
scope :zombies_at, ->(time) { where(becomes_zombie_at: ..time) }
|
7
|
+
end
|
8
|
+
|
4
9
|
class_methods do
|
5
10
|
def delay(value) = @background_delay = value
|
6
11
|
|
@@ -15,9 +20,15 @@ module Operations::Task::Background
|
|
15
20
|
def timeout_handler = @on_timeout
|
16
21
|
|
17
22
|
def with_timeout(data) = data.merge(_execution_timeout: execution_timeout.from_now.utc)
|
23
|
+
|
24
|
+
def restart_zombie_tasks = zombies.find_each { |t| t.restart! }
|
18
25
|
end
|
19
26
|
|
27
|
+
def zombie? = Time.now > (updated_at + zombie_delay)
|
28
|
+
|
20
29
|
private def background_delay = self.class.background_delay
|
30
|
+
private def zombie_delay = background_delay * 3
|
31
|
+
private def zombie_time = becomes_zombie_at || Time.now
|
21
32
|
private def execution_timeout = self.class.execution_timeout
|
22
33
|
private def timeout_handler = self.class.timeout_handler
|
23
34
|
private def timeout!
|
@@ -9,8 +9,8 @@ module Operations
|
|
9
9
|
|
10
10
|
enum :status, in_progress: 0, waiting: 10, completed: 100, failed: -1
|
11
11
|
|
12
|
-
serialize :data, coder:
|
13
|
-
serialize :results, coder:
|
12
|
+
serialize :data, coder: GlobalIdSerialiser, type: Hash, default: {}
|
13
|
+
serialize :results, coder: GlobalIdSerialiser, type: Hash, default: {}
|
14
14
|
|
15
15
|
has_many :task_participants, class_name: "Operations::TaskParticipant", dependent: :destroy
|
16
16
|
after_save :record_participants
|
@@ -35,9 +35,10 @@ module Operations
|
|
35
35
|
end
|
36
36
|
|
37
37
|
def perform_later
|
38
|
-
waiting
|
38
|
+
update! status: "waiting", becomes_zombie_at: Time.now + zombie_delay
|
39
39
|
TaskRunnerJob.set(wait_until: background_delay.from_now).perform_later self
|
40
40
|
end
|
41
|
+
alias_method :restart!, :perform_later
|
41
42
|
|
42
43
|
def self.call(**)
|
43
44
|
build(background: false, **).tap do |task|
|
data/lib/operations/version.rb
CHANGED
data/lib/operations.rb
CHANGED
@@ -1,4 +1,5 @@
|
|
1
1
|
require "ostruct"
|
2
|
+
require "global_id_serialiser"
|
2
3
|
|
3
4
|
module Operations
|
4
5
|
class Error < StandardError
|
@@ -10,7 +11,6 @@ module Operations
|
|
10
11
|
end
|
11
12
|
require "operations/version"
|
12
13
|
require "operations/engine"
|
13
|
-
require "operations/global_id_serialiser"
|
14
14
|
require "operations/failure"
|
15
15
|
require "operations/cannot_wait_in_foreground"
|
16
16
|
require "operations/timeout"
|
@@ -1,4 +1,4 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
4
|
-
|
1
|
+
desc "Restart any zombie tasks"
|
2
|
+
task :restart_zombie_tasks do
|
3
|
+
Operations::Task.restart_zombie_tasks
|
4
|
+
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.5.
|
4
|
+
version: 0.5.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Rahoul Baruah
|
8
8
|
bindir: bin
|
9
9
|
cert_chain: []
|
10
|
-
date: 2025-03
|
10
|
+
date: 2025-04-03 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
12
12
|
- !ruby/object:Gem::Dependency
|
13
13
|
name: rails
|
@@ -23,6 +23,20 @@ dependencies:
|
|
23
23
|
- - ">="
|
24
24
|
- !ruby/object:Gem::Version
|
25
25
|
version: 7.1.3
|
26
|
+
- !ruby/object:Gem::Dependency
|
27
|
+
name: standard_procedure_global_id_serialiser
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
29
|
+
requirements:
|
30
|
+
- - ">="
|
31
|
+
- !ruby/object:Gem::Version
|
32
|
+
version: '0'
|
33
|
+
type: :runtime
|
34
|
+
prerelease: false
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
36
|
+
requirements:
|
37
|
+
- - ">="
|
38
|
+
- !ruby/object:Gem::Version
|
39
|
+
version: '0'
|
26
40
|
description: Pipelines and State Machines for composable, trackable business logic
|
27
41
|
email:
|
28
42
|
- rahoulb@echodek.co
|
@@ -51,13 +65,13 @@ files:
|
|
51
65
|
- app/models/operations/task_participant.rb
|
52
66
|
- config/routes.rb
|
53
67
|
- db/migrate/20250127160616_create_operations_tasks.rb
|
54
|
-
- db/migrate/
|
68
|
+
- db/migrate/20250309160616_create_operations_task_participants.rb
|
69
|
+
- db/migrate/20250403075414_add_becomes_zombie_at_field.rb
|
55
70
|
- lib/operations.rb
|
56
71
|
- lib/operations/cannot_wait_in_foreground.rb
|
57
72
|
- lib/operations/engine.rb
|
58
73
|
- lib/operations/exporters/graphviz.rb
|
59
74
|
- lib/operations/failure.rb
|
60
|
-
- lib/operations/global_id_serialiser.rb
|
61
75
|
- lib/operations/matchers.rb
|
62
76
|
- lib/operations/no_decision.rb
|
63
77
|
- lib/operations/timeout.rb
|
@@ -1,29 +0,0 @@
|
|
1
|
-
module Operations
|
2
|
-
# Serialise and deserialise data to and from JSON
|
3
|
-
# Unlike the standard JSON coder, this coder uses the ActiveJob::Arguments serializer.
|
4
|
-
# This means that if the data contains an ActiveRecord model, it will be serialised as a GlobalID string
|
5
|
-
#
|
6
|
-
# Usage:
|
7
|
-
# class MyModel < ApplicationRecord
|
8
|
-
# serialize :data, coder: GlobalIDSerialiser, type: Hash, default: {}
|
9
|
-
# end
|
10
|
-
# @my_model = MyModel.create! data: {hello: "world", user: User.first}
|
11
|
-
# puts @my_model[:data] # => {hello: "world", user: #<User id: 1>}
|
12
|
-
class GlobalIDSerialiser
|
13
|
-
def self.dump(data) = ActiveSupport::JSON.dump(ActiveJob::Arguments.serialize([data]))
|
14
|
-
|
15
|
-
def self.load(json)
|
16
|
-
ActiveJob::Arguments.deserialize(ActiveSupport::JSON.decode(json)).first
|
17
|
-
rescue => ex
|
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)
|
27
|
-
end
|
28
|
-
end
|
29
|
-
end
|