job-workflow 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +1 -2
- data/CHANGELOG.md +30 -0
- data/README.md +1 -1
- data/app/controllers/job_workflow/monitoring/application_controller.rb +11 -0
- data/app/controllers/job_workflow/monitoring/executions_controller.rb +28 -0
- data/app/controllers/job_workflow/monitoring/workflows_controller.rb +11 -0
- data/app/views/job_workflow/monitoring/executions/index.html.erb +57 -0
- data/app/views/job_workflow/monitoring/executions/show.html.erb +200 -0
- data/app/views/job_workflow/monitoring/workflows/index.html.erb +39 -0
- data/app/views/layouts/job_workflow/monitoring/application.html.erb +117 -0
- data/config/routes.rb +8 -0
- data/guides/API_REFERENCE.md +79 -6
- data/guides/DEPENDENCY_WAIT.md +9 -5
- data/guides/MONITORING_UI.md +74 -0
- data/guides/PARALLEL_PROCESSING.md +33 -21
- data/guides/PRODUCTION_DEPLOYMENT.md +1 -1
- data/guides/README.md +6 -1
- data/guides/THROTTLING.md +24 -0
- data/guides/WORKFLOW_STATUS_QUERY.md +7 -1
- data/lib/job_workflow/context.rb +68 -6
- data/lib/job_workflow/dsl.rb +1 -5
- data/lib/job_workflow/instrumentation/opentelemetry_subscriber.rb +1 -1
- data/lib/job_workflow/instrumentation.rb +14 -14
- data/lib/job_workflow/job_status.rb +16 -1
- data/lib/job_workflow/monitoring/dag_layout.rb +186 -0
- data/lib/job_workflow/monitoring/engine.rb +15 -0
- data/lib/job_workflow/monitoring/execution_page.rb +16 -0
- data/lib/job_workflow/monitoring/execution_registry.rb +50 -0
- data/lib/job_workflow/monitoring/execution_view_model.rb +262 -0
- data/lib/job_workflow/monitoring/parameter_filter.rb +37 -0
- data/lib/job_workflow/monitoring/workflow_definition.rb +24 -0
- data/lib/job_workflow/monitoring/workflow_registry.rb +24 -0
- data/lib/job_workflow/monitoring.rb +120 -0
- data/lib/job_workflow/queue_adapters/abstract.rb +7 -2
- data/lib/job_workflow/queue_adapters/null_adapter.rb +12 -1
- data/lib/job_workflow/queue_adapters/solid_queue_adapter.rb +42 -12
- data/lib/job_workflow/railtie.rb +12 -0
- data/lib/job_workflow/runner.rb +38 -15
- data/lib/job_workflow/sub_task_job.rb +93 -0
- data/lib/job_workflow/task.rb +7 -0
- data/lib/job_workflow/task_enqueue.rb +19 -12
- data/lib/job_workflow/version.rb +1 -1
- data/lib/job_workflow/workflow_status.rb +20 -1
- data/lib/job_workflow.rb +5 -1
- data/sig/generated/job_workflow/context.rbs +31 -7
- data/sig/generated/job_workflow/instrumentation/opentelemetry_subscriber.rbs +0 -1
- data/sig/generated/job_workflow/instrumentation.rbs +28 -28
- data/sig/generated/job_workflow/job_status.rbs +5 -2
- data/sig/generated/job_workflow/monitoring/dag_layout.rbs +80 -0
- data/sig/generated/job_workflow/monitoring/engine.rbs +8 -0
- data/sig/generated/job_workflow/monitoring/execution_page.rbs +14 -0
- data/sig/generated/job_workflow/monitoring/execution_registry.rbs +21 -0
- data/sig/generated/job_workflow/monitoring/execution_view_model.rbs +111 -0
- data/sig/generated/job_workflow/monitoring/parameter_filter.rbs +16 -0
- data/sig/generated/job_workflow/monitoring/workflow_definition.rbs +18 -0
- data/sig/generated/job_workflow/monitoring/workflow_registry.rbs +13 -0
- data/sig/generated/job_workflow/monitoring.rbs +38 -0
- data/sig/generated/job_workflow/queue_adapters/abstract.rbs +7 -4
- data/sig/generated/job_workflow/queue_adapters/null_adapter.rbs +5 -2
- data/sig/generated/job_workflow/queue_adapters/solid_queue_adapter.rbs +18 -6
- data/sig/generated/job_workflow/railtie.rbs +6 -0
- data/sig/generated/job_workflow/runner.rbs +8 -5
- data/sig/generated/job_workflow/sub_task_job.rbs +40 -0
- data/sig/generated/job_workflow/task.rbs +5 -0
- data/sig/generated/job_workflow/task_enqueue.rbs +5 -8
- data/sig/generated/job_workflow/workflow_status.rbs +6 -0
- data/sig-private/job-workflow.rbs +11 -0
- data/sig-private/rails.rbs +5 -0
- metadata +34 -1
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/parameter_filter"
|
|
4
|
+
|
|
5
|
+
module JobWorkflow
|
|
6
|
+
module Monitoring
|
|
7
|
+
class ParameterFilter
|
|
8
|
+
class << self
|
|
9
|
+
#: (untyped) -> untyped
|
|
10
|
+
def filter(value)
|
|
11
|
+
case value
|
|
12
|
+
when Hash
|
|
13
|
+
parameter_filter.filter(value)
|
|
14
|
+
when Array
|
|
15
|
+
value.map { |item| filter(item) }
|
|
16
|
+
else
|
|
17
|
+
value
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
#: () -> ActiveSupport::ParameterFilter
|
|
24
|
+
def parameter_filter
|
|
25
|
+
ActiveSupport::ParameterFilter.new(filters)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
#: () -> Array[untyped]
|
|
29
|
+
def filters
|
|
30
|
+
return [] unless defined?(Rails.application) && Rails.application.config.respond_to?(:filter_parameters)
|
|
31
|
+
|
|
32
|
+
Rails.application.config.filter_parameters
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobWorkflow
|
|
4
|
+
module Monitoring
|
|
5
|
+
class WorkflowDefinition
|
|
6
|
+
attr_reader :job_class #: singleton(DSL)
|
|
7
|
+
|
|
8
|
+
#: (job_class: singleton(DSL)) -> void
|
|
9
|
+
def initialize(job_class:)
|
|
10
|
+
@job_class = job_class
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
#: () -> String
|
|
14
|
+
def job_class_name
|
|
15
|
+
job_class.name
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
#: () -> Integer
|
|
19
|
+
def task_count
|
|
20
|
+
job_class._workflow.tasks.size
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobWorkflow
|
|
4
|
+
module Monitoring
|
|
5
|
+
class WorkflowRegistry
|
|
6
|
+
class << self
|
|
7
|
+
#: () -> Array[singleton(DSL)]
|
|
8
|
+
def all
|
|
9
|
+
DSL._included_classes.to_a
|
|
10
|
+
.reverse
|
|
11
|
+
.select(&:name)
|
|
12
|
+
.uniq(&:name)
|
|
13
|
+
.reverse
|
|
14
|
+
.sort_by(&:name)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
#: (String) -> singleton(DSL)?
|
|
18
|
+
def find(job_class_name)
|
|
19
|
+
all.find { |job_class| job_class.name == job_class_name }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "monitoring/workflow_registry"
|
|
4
|
+
require_relative "monitoring/workflow_definition"
|
|
5
|
+
require_relative "monitoring/execution_page"
|
|
6
|
+
require_relative "monitoring/dag_layout"
|
|
7
|
+
require_relative "monitoring/parameter_filter"
|
|
8
|
+
require_relative "monitoring/execution_view_model"
|
|
9
|
+
require_relative "monitoring/execution_registry"
|
|
10
|
+
|
|
11
|
+
module JobWorkflow
|
|
12
|
+
module Monitoring
|
|
13
|
+
mattr_accessor :base_controller_class
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
#: () -> Array[WorkflowDefinition]
|
|
17
|
+
def workflows
|
|
18
|
+
WorkflowRegistry.all.map { |job_class| WorkflowDefinition.new(job_class:) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
#: (String?, Symbol?) -> String?
|
|
22
|
+
def mission_control_job_path(job_id, status: nil)
|
|
23
|
+
return if job_id.nil?
|
|
24
|
+
|
|
25
|
+
path_template = mission_control_job_route_path
|
|
26
|
+
return if path_template.nil?
|
|
27
|
+
|
|
28
|
+
path = path_template.sub(":id", job_id.to_s)
|
|
29
|
+
path += "#error" if status == :failed
|
|
30
|
+
path
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
#: () -> String
|
|
34
|
+
def resolved_base_controller_class
|
|
35
|
+
return base_controller_class if base_controller_class.present?
|
|
36
|
+
|
|
37
|
+
mission_control_base_controller_class = defined?(MissionControl::Jobs) &&
|
|
38
|
+
MissionControl::Jobs.base_controller_class
|
|
39
|
+
return mission_control_base_controller_class if mission_control_base_controller_class.present?
|
|
40
|
+
|
|
41
|
+
"::ApplicationController"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
#: (untyped config) -> void
|
|
45
|
+
def configure_engine_config(config)
|
|
46
|
+
config.job_workflow = ActiveSupport::OrderedOptions.new unless config.try(:job_workflow)
|
|
47
|
+
config.job_workflow.monitoring ||= ActiveSupport::OrderedOptions.new
|
|
48
|
+
|
|
49
|
+
config.before_initialize do
|
|
50
|
+
config.job_workflow.monitoring.each do |key, value|
|
|
51
|
+
JobWorkflow::Monitoring.public_send("#{key}=", value)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
#: () -> String?
|
|
59
|
+
def mission_control_job_route_path
|
|
60
|
+
application_id = mission_control_application_id
|
|
61
|
+
route_name = application_id ? "application_job" : "job"
|
|
62
|
+
mount_path = mission_control_mount_path
|
|
63
|
+
return if mount_path.nil?
|
|
64
|
+
|
|
65
|
+
path = engine_route_path(route_name)
|
|
66
|
+
return if path.nil?
|
|
67
|
+
|
|
68
|
+
combined_path = "#{mount_path.chomp("/")}#{path}"
|
|
69
|
+
application_id ? combined_path.sub(":application_id", application_id.to_s) : combined_path
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
#: () -> String?
|
|
73
|
+
def mission_control_application_id
|
|
74
|
+
return unless defined?(MissionControl::Jobs)
|
|
75
|
+
|
|
76
|
+
MissionControl::Jobs.applications.first&.id
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
#: () -> String?
|
|
80
|
+
def mission_control_mount_path
|
|
81
|
+
path = rails_routes&.find { |candidate| candidate.name == "mission_control_jobs" }&.path
|
|
82
|
+
normalized_route_path(path)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
#: (String) -> String?
|
|
86
|
+
def engine_route_path(route_name)
|
|
87
|
+
path = mission_control_routes&.find { |candidate| candidate.name == route_name }&.path
|
|
88
|
+
normalized_route_path(path)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
#: (untyped) -> String?
|
|
92
|
+
def normalized_route_path(path)
|
|
93
|
+
return unless path.respond_to?(:spec)
|
|
94
|
+
|
|
95
|
+
path_spec = path.spec.to_s
|
|
96
|
+
return unless path_spec.is_a?(String) && !path_spec.empty?
|
|
97
|
+
|
|
98
|
+
path_spec.delete_suffix("(.:format)")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
#: () -> untyped
|
|
102
|
+
def mission_control_routes
|
|
103
|
+
return unless defined?(MissionControl::Jobs::Engine)
|
|
104
|
+
|
|
105
|
+
MissionControl::Jobs::Engine.routes.routes
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
#: () -> untyped
|
|
109
|
+
def rails_routes
|
|
110
|
+
return unless defined?(Rails.application)
|
|
111
|
+
return unless Rails.application.respond_to?(:routes)
|
|
112
|
+
|
|
113
|
+
routes = Rails.application.routes
|
|
114
|
+
return unless routes.respond_to?(:routes)
|
|
115
|
+
|
|
116
|
+
routes.routes
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -77,17 +77,22 @@ module JobWorkflow
|
|
|
77
77
|
raise NotImplementedError, "#{self.class}#find_job must be implemented"
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
+
#: (job_class_name: String, limit: Integer, cursor: String?) -> Hash[Symbol, untyped]
|
|
81
|
+
def fetch_root_workflow_job_page(job_class_name:, limit:, cursor:)
|
|
82
|
+
raise NotImplementedError, "#{self.class}#fetch_root_workflow_job_page must be implemented"
|
|
83
|
+
end
|
|
84
|
+
|
|
80
85
|
#: (Array[String]) -> Array[Hash[String, untyped]]
|
|
81
86
|
def fetch_job_contexts(_job_ids)
|
|
82
87
|
raise NotImplementedError, "#{self.class}#fetch_job_contexts must be implemented"
|
|
83
88
|
end
|
|
84
89
|
|
|
85
|
-
#: (
|
|
90
|
+
#: (_JobInterface, Numeric) -> bool
|
|
86
91
|
def reschedule_job(_job, _wait)
|
|
87
92
|
false
|
|
88
93
|
end
|
|
89
94
|
|
|
90
|
-
#: (
|
|
95
|
+
#: (_JobInterface) -> void
|
|
91
96
|
def persist_job_context(_job); end
|
|
92
97
|
end
|
|
93
98
|
# rubocop:enable Naming/PredicateMethod
|
|
@@ -90,12 +90,23 @@ module JobWorkflow
|
|
|
90
90
|
@stored_jobs[job_id]
|
|
91
91
|
end
|
|
92
92
|
|
|
93
|
+
#: (job_class_name: String, limit: Integer, cursor: String?) -> Hash[Symbol, untyped]
|
|
94
|
+
def fetch_root_workflow_job_page(job_class_name:, limit:, cursor:)
|
|
95
|
+
jobs = @stored_jobs.values.select { |job| job["class_name"] == job_class_name }.reverse
|
|
96
|
+
offset = cursor.nil? ? 0 : cursor.to_i
|
|
97
|
+
page_jobs = jobs.slice(offset, limit + 1) || []
|
|
98
|
+
{
|
|
99
|
+
jobs: page_jobs.first(limit),
|
|
100
|
+
next_cursor: page_jobs.size > limit ? (offset + limit).to_s : nil
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
93
104
|
#: (Array[String]) -> Array[Hash[String, untyped]]
|
|
94
105
|
def fetch_job_contexts(_job_ids)
|
|
95
106
|
[]
|
|
96
107
|
end
|
|
97
108
|
|
|
98
|
-
#: (
|
|
109
|
+
#: (_JobInterface, Numeric) -> bool
|
|
99
110
|
def reschedule_job(_job, _wait)
|
|
100
111
|
false
|
|
101
112
|
end
|
|
@@ -160,15 +160,17 @@ module JobWorkflow
|
|
|
160
160
|
job = without_query_cache { SolidQueue::Job.find_by(active_job_id: job_id) }
|
|
161
161
|
return if job.nil?
|
|
162
162
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
163
|
+
normalized_job_data(job)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
#: (job_class_name: String, limit: Integer, cursor: String?) -> Hash[Symbol, untyped]
|
|
167
|
+
def fetch_root_workflow_job_page(job_class_name:, limit:, cursor:)
|
|
168
|
+
return { jobs: [], next_cursor: nil } unless defined?(SolidQueue::Job)
|
|
169
|
+
|
|
170
|
+
without_query_cache do
|
|
171
|
+
jobs = root_jobs_relation(job_class_name:, cursor:).limit(limit + 1).to_a
|
|
172
|
+
build_page(jobs, limit:)
|
|
173
|
+
end
|
|
172
174
|
end
|
|
173
175
|
|
|
174
176
|
# @note
|
|
@@ -186,7 +188,7 @@ module JobWorkflow
|
|
|
186
188
|
end
|
|
187
189
|
end
|
|
188
190
|
|
|
189
|
-
#: (
|
|
191
|
+
#: (_JobInterface, Numeric) -> bool
|
|
190
192
|
def reschedule_job(job, wait)
|
|
191
193
|
return false unless defined?(SolidQueue::Job)
|
|
192
194
|
|
|
@@ -204,7 +206,7 @@ module JobWorkflow
|
|
|
204
206
|
# outputs computed during job execution would be lost because
|
|
205
207
|
# SolidQueue does not re-serialize job arguments after perform.
|
|
206
208
|
#
|
|
207
|
-
#: (
|
|
209
|
+
#: (_JobInterface) -> void
|
|
208
210
|
def persist_job_context(job)
|
|
209
211
|
return unless defined?(SolidQueue::Job)
|
|
210
212
|
|
|
@@ -229,7 +231,35 @@ module JobWorkflow
|
|
|
229
231
|
defined?(SolidQueue::Job) ? SolidQueue::Job.uncached(&) : yield
|
|
230
232
|
end
|
|
231
233
|
|
|
232
|
-
#: (SolidQueue::Job
|
|
234
|
+
#: (SolidQueue::Job) -> Hash[String, untyped]
|
|
235
|
+
def normalized_job_data(job)
|
|
236
|
+
args = job.arguments
|
|
237
|
+
{
|
|
238
|
+
"job_id" => job.active_job_id,
|
|
239
|
+
"class_name" => job.class_name,
|
|
240
|
+
"queue_name" => job.queue_name,
|
|
241
|
+
"arguments" => args.is_a?(Hash) ? args["arguments"] : args,
|
|
242
|
+
"job_workflow_context" => args.is_a?(Hash) ? args["job_workflow_context"] : nil,
|
|
243
|
+
"status" => job_status(job)
|
|
244
|
+
}
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
#: (job_class_name: String, cursor: String?) -> untyped
|
|
248
|
+
def root_jobs_relation(job_class_name:, cursor:)
|
|
249
|
+
relation = SolidQueue::Job.where(class_name: job_class_name)
|
|
250
|
+
relation = relation.where("id < ?", cursor.to_i) unless cursor.nil?
|
|
251
|
+
relation.order(id: :desc)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
#: (Array[SolidQueue::Job], limit: Integer) -> Hash[Symbol, untyped]
|
|
255
|
+
def build_page(jobs, limit:)
|
|
256
|
+
{
|
|
257
|
+
jobs: jobs.first(limit).map { |job| normalized_job_data(job) },
|
|
258
|
+
next_cursor: jobs.size > limit ? jobs[limit - 1].id.to_s : nil
|
|
259
|
+
}
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
#: (SolidQueue::Job, _JobInterface, Numeric) -> bool
|
|
233
263
|
def reschedule_solid_queue_job(solid_queue_job, active_job, wait)
|
|
234
264
|
solid_queue_job.with_lock do
|
|
235
265
|
solid_queue_job.claimed_execution&.destroy!
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "monitoring/engine"
|
|
4
|
+
|
|
5
|
+
module JobWorkflow
|
|
6
|
+
class Railtie < Rails::Railtie
|
|
7
|
+
config.after_initialize do
|
|
8
|
+
JobWorkflow::QueueAdapter.reset!
|
|
9
|
+
JobWorkflow::QueueAdapter.current.initialize_adapter!
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
data/lib/job_workflow/runner.rb
CHANGED
|
@@ -14,7 +14,7 @@ module JobWorkflow
|
|
|
14
14
|
def run
|
|
15
15
|
task = context._task_context.task
|
|
16
16
|
if !task.nil? && context.sub_job?
|
|
17
|
-
run_task(task)
|
|
17
|
+
job.step(task.task_name) { |step| run_task(task, step:) }
|
|
18
18
|
persist_current_job_context
|
|
19
19
|
return
|
|
20
20
|
end
|
|
@@ -25,11 +25,11 @@ module JobWorkflow
|
|
|
25
25
|
|
|
26
26
|
private
|
|
27
27
|
|
|
28
|
-
attr_reader :job #:
|
|
28
|
+
attr_reader :job #: _JobInterface
|
|
29
29
|
|
|
30
30
|
#: () -> Workflow
|
|
31
31
|
def workflow
|
|
32
|
-
|
|
32
|
+
context.workflow
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
#: () -> Array[Task]
|
|
@@ -63,25 +63,46 @@ module JobWorkflow
|
|
|
63
63
|
result
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
-
#: (Task,
|
|
67
|
-
def run_task(task, step:
|
|
66
|
+
#: (Task, step: ActiveJob::Continuation::Step) -> void
|
|
67
|
+
def run_task(task, step:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
68
68
|
context._load_parent_task_output
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
start_index, task_cursor = decode_task_cursor(task, step.cursor)
|
|
70
|
+
|
|
71
|
+
context._with_each_value(task, start_index:).each do |ctx|
|
|
72
|
+
iteration_cursor = task_cursor
|
|
73
|
+
iteration_cursor = nil if task.each? && (task_cursor.nil? || start_index != ctx._task_context.index)
|
|
74
|
+
|
|
75
|
+
run_each_task(task, ctx, step:, cursor: iteration_cursor)
|
|
76
|
+
step.set!(ctx._task_context.index + 1) if task.each?
|
|
72
77
|
rescue StandardError => e
|
|
73
78
|
run_error_hooks(task, ctx, e)
|
|
74
79
|
raise
|
|
75
80
|
end
|
|
76
81
|
end
|
|
77
82
|
|
|
78
|
-
#: (Task,
|
|
79
|
-
def
|
|
83
|
+
#: (Task, untyped) -> [Integer?, untyped]
|
|
84
|
+
def decode_task_cursor(task, task_cursor)
|
|
85
|
+
return [nil, task_cursor] unless task.each?
|
|
86
|
+
return [task_cursor, nil] if task_cursor.is_a?(Integer)
|
|
87
|
+
|
|
88
|
+
if task_cursor.is_a?(Hash) && task_cursor[Context::EACH_TASK_CURSOR_MARKER]
|
|
89
|
+
return [task_cursor.fetch("index"), task_cursor.fetch("cursor")]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
raise "invalid each task cursor: #{task_cursor.inspect}" unless task_cursor.nil?
|
|
93
|
+
|
|
94
|
+
[nil, nil]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
#: (Task, Context, step: ActiveJob::Continuation::Step, ?cursor: untyped) -> void
|
|
98
|
+
def run_each_task(task, ctx, step:, cursor: nil)
|
|
80
99
|
Instrumentation.instrument_task(job, task, ctx) do
|
|
81
|
-
ctx.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
100
|
+
ctx._with_current_step(step, cursor:) do
|
|
101
|
+
ctx._with_task_throttle do
|
|
102
|
+
run_hooks(task, ctx) do
|
|
103
|
+
data = task.block.call(ctx)
|
|
104
|
+
add_task_output(ctx:, task:, each_index: ctx._task_context.index, data:)
|
|
105
|
+
end
|
|
85
106
|
end
|
|
86
107
|
end
|
|
87
108
|
end
|
|
@@ -112,7 +133,9 @@ module JobWorkflow
|
|
|
112
133
|
|
|
113
134
|
#: (Task) -> void
|
|
114
135
|
def enqueue_task(task)
|
|
115
|
-
sub_jobs = context._with_each_value(task).map
|
|
136
|
+
sub_jobs = context._with_each_value(task).map do |ctx|
|
|
137
|
+
SubTaskJob.from_parent_context(context: ctx)
|
|
138
|
+
end
|
|
116
139
|
persist_current_job_context
|
|
117
140
|
ActiveJob.perform_all_later(sub_jobs)
|
|
118
141
|
context.job_status.update_task_job_statuses_from_jobs(task_name: task.task_name, jobs: sub_jobs)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobWorkflow
|
|
4
|
+
class SubTaskJob < ActiveJob::Base
|
|
5
|
+
include ActiveJob::Continuable
|
|
6
|
+
|
|
7
|
+
class << self
|
|
8
|
+
#: (context: Context) -> SubTaskJob
|
|
9
|
+
def from_parent_context(context:)
|
|
10
|
+
validate_sub_task_context!(context)
|
|
11
|
+
|
|
12
|
+
new_context = context.dup
|
|
13
|
+
job = new(context.arguments.to_h)
|
|
14
|
+
new_context._job = job
|
|
15
|
+
job._context = new_context
|
|
16
|
+
task = new_context._task_context.task || (raise "task is not set")
|
|
17
|
+
return job if task.enqueue.queue.nil?
|
|
18
|
+
|
|
19
|
+
job.set(queue: task.enqueue.queue)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
#: (Context) -> void
|
|
25
|
+
def validate_sub_task_context!(context)
|
|
26
|
+
task_context = context._task_context
|
|
27
|
+
raise ArgumentError, "task_context.task is required" if task_context.task.nil?
|
|
28
|
+
raise ArgumentError, "task_context.parent_job_id is required" if task_context.parent_job_id.nil?
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
#: (Hash[untyped, untyped]) -> void
|
|
33
|
+
def perform(arguments)
|
|
34
|
+
payload = arguments.symbolize_keys
|
|
35
|
+
self._context = build_context(payload)
|
|
36
|
+
Runner.new(context: _context).run
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
#: () -> Output
|
|
40
|
+
def output
|
|
41
|
+
context = _context
|
|
42
|
+
raise "context is not set." if context.nil?
|
|
43
|
+
|
|
44
|
+
context.output
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
attr_accessor :_context #: Context?
|
|
48
|
+
|
|
49
|
+
#: () -> Hash[String, untyped]
|
|
50
|
+
def serialize
|
|
51
|
+
super.merge({ "job_workflow_context" => _context&.serialize || serialized_job_workflow_context }.compact)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
#: (Hash[String, untyped]) -> void
|
|
55
|
+
def deserialize(job_data)
|
|
56
|
+
super
|
|
57
|
+
self.serialized_job_workflow_context = job_data["job_workflow_context"]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
attr_accessor :serialized_job_workflow_context #: Hash[String, untyped]?
|
|
63
|
+
|
|
64
|
+
#: (Hash[Symbol, untyped]) -> Context
|
|
65
|
+
def build_context(payload)
|
|
66
|
+
context_data = extract_context_data(payload)
|
|
67
|
+
parent_job_id = context_data.fetch("task_context").fetch("parent_job_id")
|
|
68
|
+
parent_job_data = QueueAdapter.current.find_job(parent_job_id)
|
|
69
|
+
raise WorkflowStatus::NotFoundError, "Workflow with job_id '#{parent_job_id}' not found" if parent_job_data.nil?
|
|
70
|
+
|
|
71
|
+
workflow = resolve_workflow(parent_job_data.fetch("class_name"))
|
|
72
|
+
Context.deserialize(context_data.merge("job" => self, "workflow" => workflow))
|
|
73
|
+
._update_arguments(payload.except(:job_workflow_context))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
#: (job_class_name: String) -> Workflow
|
|
77
|
+
def resolve_workflow(job_class_name)
|
|
78
|
+
job_class = JobWorkflow::DSL._included_classes.to_a.reverse.find { |klass| klass.name == job_class_name }
|
|
79
|
+
job_class ||= job_class_name.safe_constantize
|
|
80
|
+
raise NameError, "uninitialized constant #{job_class_name}" if job_class.nil?
|
|
81
|
+
|
|
82
|
+
job_class._workflow
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
#: (Hash[Symbol, untyped]) -> Hash[String, untyped]
|
|
86
|
+
def extract_context_data(payload)
|
|
87
|
+
context_data = serialized_job_workflow_context || payload[:job_workflow_context]
|
|
88
|
+
raise "job_workflow_context is not set." if context_data.nil?
|
|
89
|
+
|
|
90
|
+
context_data.deep_stringify_keys
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
data/lib/job_workflow/task.rb
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module JobWorkflow
|
|
4
4
|
class Task
|
|
5
|
+
DEFAULT_EACH = ->(_ctx) { [nil] }
|
|
6
|
+
|
|
5
7
|
attr_reader :job_name #: String
|
|
6
8
|
attr_reader :block #: ^(untyped) -> void
|
|
7
9
|
attr_reader :each #: ^(Context) -> untyped
|
|
@@ -72,6 +74,11 @@ module JobWorkflow
|
|
|
72
74
|
"#{job_name}:#{task_name}"
|
|
73
75
|
end
|
|
74
76
|
|
|
77
|
+
#: () -> bool
|
|
78
|
+
def each?
|
|
79
|
+
!each.nil? && !each.equal?(DEFAULT_EACH)
|
|
80
|
+
end
|
|
81
|
+
|
|
75
82
|
private
|
|
76
83
|
|
|
77
84
|
attr_reader :name #: Symbol
|
|
@@ -4,7 +4,6 @@ module JobWorkflow
|
|
|
4
4
|
class TaskEnqueue
|
|
5
5
|
attr_reader :condition #: true | false | ^(Context) -> bool
|
|
6
6
|
attr_reader :queue #: String?
|
|
7
|
-
attr_reader :concurrency #: Integer?
|
|
8
7
|
|
|
9
8
|
class << self
|
|
10
9
|
#: (true | false | ^(Context) -> bool | Hash[Symbol, untyped] | nil) -> TaskEnqueue
|
|
@@ -13,26 +12,39 @@ module JobWorkflow
|
|
|
13
12
|
when TrueClass, FalseClass, Proc
|
|
14
13
|
new(condition: value)
|
|
15
14
|
when Hash
|
|
15
|
+
validate_hash_keys!(value)
|
|
16
|
+
|
|
16
17
|
new(
|
|
17
18
|
condition: value.fetch(:condition, !value.empty?),
|
|
18
|
-
queue: value[:queue]
|
|
19
|
-
concurrency: value[:concurrency]
|
|
19
|
+
queue: value[:queue]
|
|
20
20
|
)
|
|
21
21
|
else
|
|
22
22
|
new
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
#: (Hash[Symbol, untyped]) -> void
|
|
29
|
+
def validate_hash_keys!(value)
|
|
30
|
+
unsupported_keys = value.keys - %i[condition queue]
|
|
31
|
+
return if unsupported_keys.empty?
|
|
32
|
+
|
|
33
|
+
if unsupported_keys == %i[concurrency]
|
|
34
|
+
raise ArgumentError, "enqueue does not support :concurrency; use throttle instead"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
raise ArgumentError, "enqueue supports only :condition and :queue"
|
|
38
|
+
end
|
|
25
39
|
end
|
|
26
40
|
|
|
27
41
|
#: (
|
|
28
42
|
# ?condition: true | false | ^(Context) -> bool,
|
|
29
|
-
# ?queue: String
|
|
30
|
-
# ?concurrency: Integer?
|
|
43
|
+
# ?queue: String?
|
|
31
44
|
# ) -> void
|
|
32
|
-
def initialize(condition: false, queue: nil
|
|
45
|
+
def initialize(condition: false, queue: nil)
|
|
33
46
|
@condition = condition
|
|
34
47
|
@queue = queue
|
|
35
|
-
@concurrency = concurrency
|
|
36
48
|
end
|
|
37
49
|
|
|
38
50
|
#: (Context) -> bool
|
|
@@ -41,10 +53,5 @@ module JobWorkflow
|
|
|
41
53
|
|
|
42
54
|
!!condition
|
|
43
55
|
end
|
|
44
|
-
|
|
45
|
-
#: () -> bool
|
|
46
|
-
def should_limits_concurrency?
|
|
47
|
-
!!condition && !concurrency.nil? && QueueAdapter.current.supports_concurrency_limits?
|
|
48
|
-
end
|
|
49
56
|
end
|
|
50
57
|
end
|
data/lib/job_workflow/version.rb
CHANGED
|
@@ -24,6 +24,7 @@ module JobWorkflow
|
|
|
24
24
|
def find_by(job_id:)
|
|
25
25
|
data = QueueAdapter.current.find_job(job_id)
|
|
26
26
|
return if data.nil?
|
|
27
|
+
return if data["class_name"] == JobWorkflow::SubTaskJob.name
|
|
27
28
|
|
|
28
29
|
WorkflowStatus.from_job_data(data)
|
|
29
30
|
end
|
|
@@ -33,15 +34,33 @@ module JobWorkflow
|
|
|
33
34
|
job_class_name = data["class_name"]
|
|
34
35
|
job_class = job_class_name.constantize
|
|
35
36
|
workflow = job_class._workflow
|
|
37
|
+
context = context_from_job_data(data, workflow)
|
|
36
38
|
|
|
39
|
+
new(context:, job_class_name:, status: data["status"])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
#: (Hash[String, untyped], Workflow) -> Context
|
|
45
|
+
def context_from_job_data(data, workflow)
|
|
37
46
|
context_data = data["job_workflow_context"] || data["arguments"]&.first&.dig("job_workflow_context")
|
|
38
47
|
context = if context_data
|
|
39
48
|
Context.deserialize(context_data.merge("workflow" => workflow))
|
|
40
49
|
else
|
|
41
50
|
Context.from_hash({ workflow: })
|
|
42
51
|
end
|
|
52
|
+
serialized_arguments = workflow_arguments_data(data)
|
|
53
|
+
return context if serialized_arguments.nil?
|
|
43
54
|
|
|
44
|
-
|
|
55
|
+
context._update_arguments(ActiveJob::Arguments.deserialize([serialized_arguments]).first)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
#: (Hash[String, untyped]) -> Hash[String, untyped]?
|
|
59
|
+
def workflow_arguments_data(data)
|
|
60
|
+
serialized_arguments = data["arguments"]&.first
|
|
61
|
+
return if serialized_arguments.nil? || serialized_arguments.key?("job_workflow_context")
|
|
62
|
+
|
|
63
|
+
serialized_arguments
|
|
45
64
|
end
|
|
46
65
|
end
|
|
47
66
|
|