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
|
@@ -40,7 +40,7 @@ module JobWorkflow
|
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
class << self
|
|
43
|
-
#: (
|
|
43
|
+
#: (_JobInterface) { () -> untyped } -> untyped
|
|
44
44
|
def instrument_workflow(job, &)
|
|
45
45
|
payload = build_workflow_payload(job)
|
|
46
46
|
instrument(Events::WORKFLOW_START, payload)
|
|
@@ -49,7 +49,7 @@ module JobWorkflow
|
|
|
49
49
|
instrument(Events::WORKFLOW_COMPLETE, payload)
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
-
#: (
|
|
52
|
+
#: (_JobInterface, Task, Context) { () -> untyped } -> untyped
|
|
53
53
|
def instrument_task(job, task, ctx, &)
|
|
54
54
|
payload = build_task_payload(job, task, ctx)
|
|
55
55
|
instrument(Events::TASK_START, payload)
|
|
@@ -58,12 +58,12 @@ module JobWorkflow
|
|
|
58
58
|
instrument(Events::TASK_COMPLETE, payload)
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
-
#: (
|
|
61
|
+
#: (_JobInterface, Task, String) -> void
|
|
62
62
|
def notify_task_skip(job, task, reason)
|
|
63
63
|
instrument(Events::TASK_SKIP, build_task_skip_payload(job, task, reason))
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
-
#: (
|
|
66
|
+
#: (_JobInterface, Task, Integer) -> void
|
|
67
67
|
def notify_task_enqueue(job, task, sub_job_count)
|
|
68
68
|
instrument(Events::TASK_ENQUEUE, build_task_enqueue_payload(job, task, sub_job_count))
|
|
69
69
|
end
|
|
@@ -73,7 +73,7 @@ module JobWorkflow
|
|
|
73
73
|
instrument(Events::TASK_RETRY, build_task_retry_payload(task, ctx, job_id, attempt, delay, error))
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
-
#: (
|
|
76
|
+
#: (_JobInterface, Task) { () -> untyped } -> untyped
|
|
77
77
|
def instrument_dependent_wait(job, task, &)
|
|
78
78
|
payload = build_dependent_payload(job, task)
|
|
79
79
|
instrument(Events::DEPENDENT_WAIT_START, payload)
|
|
@@ -82,7 +82,7 @@ module JobWorkflow
|
|
|
82
82
|
instrument(Events::DEPENDENT_WAIT_COMPLETE, payload)
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
-
#: (
|
|
85
|
+
#: (_JobInterface, Task, Numeric, Integer) -> void
|
|
86
86
|
def notify_dependent_reschedule(job, task, reschedule_delay, poll_count)
|
|
87
87
|
instrument(
|
|
88
88
|
Events::DEPENDENT_RESCHEDULE,
|
|
@@ -120,7 +120,7 @@ module JobWorkflow
|
|
|
120
120
|
instrument(event_name, payload, &)
|
|
121
121
|
end
|
|
122
122
|
|
|
123
|
-
#: (
|
|
123
|
+
#: (_JobInterface, Context, Symbol?, Integer, bool) { () -> untyped } -> untyped
|
|
124
124
|
def instrument_dry_run(job, ctx, dry_run_name, skip_in_dry_run_index, dry_run, &)
|
|
125
125
|
start_event = dry_run ? Events::DRY_RUN_SKIP : Events::DRY_RUN_EXECUTE
|
|
126
126
|
payload = build_skip_in_dry_run_payload(job, ctx, dry_run_name, skip_in_dry_run_index, dry_run)
|
|
@@ -135,7 +135,7 @@ module JobWorkflow
|
|
|
135
135
|
ActiveSupport::Notifications.instrument(event_name, payload, &)
|
|
136
136
|
end
|
|
137
137
|
|
|
138
|
-
#: (
|
|
138
|
+
#: (_JobInterface) -> Hash[Symbol, untyped]
|
|
139
139
|
def build_workflow_payload(job)
|
|
140
140
|
{
|
|
141
141
|
job:,
|
|
@@ -144,7 +144,7 @@ module JobWorkflow
|
|
|
144
144
|
}
|
|
145
145
|
end
|
|
146
146
|
|
|
147
|
-
#: (
|
|
147
|
+
#: (_JobInterface, Task, Context) -> Hash[Symbol, untyped]
|
|
148
148
|
def build_task_payload(job, task, ctx)
|
|
149
149
|
task_ctx = ctx._task_context
|
|
150
150
|
{
|
|
@@ -159,7 +159,7 @@ module JobWorkflow
|
|
|
159
159
|
}
|
|
160
160
|
end
|
|
161
161
|
|
|
162
|
-
#: (
|
|
162
|
+
#: (_JobInterface, Task, String) -> Hash[Symbol, untyped]
|
|
163
163
|
def build_task_skip_payload(job, task, reason)
|
|
164
164
|
{
|
|
165
165
|
job:,
|
|
@@ -171,7 +171,7 @@ module JobWorkflow
|
|
|
171
171
|
}
|
|
172
172
|
end
|
|
173
173
|
|
|
174
|
-
#: (
|
|
174
|
+
#: (_JobInterface, Task, Integer) -> Hash[Symbol, untyped]
|
|
175
175
|
def build_task_enqueue_payload(job, task, sub_job_count)
|
|
176
176
|
{
|
|
177
177
|
job:,
|
|
@@ -200,7 +200,7 @@ module JobWorkflow
|
|
|
200
200
|
}
|
|
201
201
|
end
|
|
202
202
|
|
|
203
|
-
#: (
|
|
203
|
+
#: (_JobInterface, Task) -> Hash[Symbol, untyped]
|
|
204
204
|
def build_dependent_payload(job, task)
|
|
205
205
|
{
|
|
206
206
|
job:,
|
|
@@ -211,7 +211,7 @@ module JobWorkflow
|
|
|
211
211
|
}
|
|
212
212
|
end
|
|
213
213
|
|
|
214
|
-
#: (
|
|
214
|
+
#: (_JobInterface, Task, Numeric, Integer) -> Hash[Symbol, untyped]
|
|
215
215
|
def build_dependent_reschedule_payload(job, task, reschedule_delay, poll_count)
|
|
216
216
|
{
|
|
217
217
|
job:,
|
|
@@ -240,7 +240,7 @@ module JobWorkflow
|
|
|
240
240
|
}
|
|
241
241
|
end
|
|
242
242
|
|
|
243
|
-
#: (
|
|
243
|
+
#: (_JobInterface, Context, Symbol?, Integer, bool) -> Hash[Symbol, untyped]
|
|
244
244
|
def build_skip_in_dry_run_payload(job, ctx, dry_run_name, dry_run_index, dry_run)
|
|
245
245
|
{
|
|
246
246
|
job_id: job.job_id,
|
|
@@ -55,7 +55,7 @@ module JobWorkflow
|
|
|
55
55
|
task_job_statuses[task_job_status.task_name][task_job_status.each_index] = task_job_status
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
-
#: (task_name: Symbol, jobs: Array[
|
|
58
|
+
#: (task_name: Symbol, jobs: Array[_JobInterface]) -> void
|
|
59
59
|
def update_task_job_statuses_from_jobs(task_name:, jobs:)
|
|
60
60
|
jobs.each.with_index do |job, index|
|
|
61
61
|
update_task_job_status(
|
|
@@ -85,6 +85,21 @@ module JobWorkflow
|
|
|
85
85
|
end
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
+
#: () -> void
|
|
89
|
+
def refresh_from_db!
|
|
90
|
+
statuses = flat_task_job_statuses.reject(&:finished?).index_by(&:job_id)
|
|
91
|
+
return if statuses.empty?
|
|
92
|
+
|
|
93
|
+
task_jobs = QueueAdapter.current.fetch_job_statuses(statuses.keys)
|
|
94
|
+
statuses.each do |job_id, task_job_status|
|
|
95
|
+
task_job = task_jobs[job_id]
|
|
96
|
+
next if task_job.nil?
|
|
97
|
+
|
|
98
|
+
task_job_status.update_status(QueueAdapter.current.job_status(task_job))
|
|
99
|
+
update_task_job_status(task_job_status)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
88
103
|
private
|
|
89
104
|
|
|
90
105
|
attr_accessor :task_job_statuses #: Hash[Symbol, Array[TaskJobStatus]]
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobWorkflow
|
|
4
|
+
module Monitoring
|
|
5
|
+
class DagLayout
|
|
6
|
+
NODE_WIDTH = 224
|
|
7
|
+
NODE_HEIGHT = 84
|
|
8
|
+
COLUMN_GAP = 56
|
|
9
|
+
ROW_GAP = 24
|
|
10
|
+
PADDING = 16
|
|
11
|
+
LABEL_LIMIT = 24
|
|
12
|
+
|
|
13
|
+
#: (tasks: Array[Hash[Symbol, untyped]]) -> void
|
|
14
|
+
def initialize(tasks:)
|
|
15
|
+
validate_tasks!(tasks)
|
|
16
|
+
@tasks = tasks
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
#: () -> Hash[Symbol, untyped]
|
|
20
|
+
def to_h
|
|
21
|
+
{
|
|
22
|
+
width: canvas_width,
|
|
23
|
+
height: canvas_height,
|
|
24
|
+
nodes:,
|
|
25
|
+
edges:
|
|
26
|
+
}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
attr_reader :tasks #: Array[Hash[Symbol, untyped]]
|
|
32
|
+
|
|
33
|
+
#: (Array[Hash[Symbol, untyped]]) -> void
|
|
34
|
+
def validate_tasks!(tasks)
|
|
35
|
+
seen_names = {} #: Hash[Symbol, bool]
|
|
36
|
+
|
|
37
|
+
tasks.each do |task|
|
|
38
|
+
validate_dependencies!(task, seen_names)
|
|
39
|
+
seen_names[task.fetch(:name)] = true
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
#: (Hash[Symbol, untyped], Hash[Symbol, bool]) -> void
|
|
44
|
+
def validate_dependencies!(task, seen_names)
|
|
45
|
+
missing_dependencies = task.fetch(:depends_on).reject { |dependency_name| seen_names[dependency_name] }
|
|
46
|
+
return if missing_dependencies.empty?
|
|
47
|
+
|
|
48
|
+
task_name = task.fetch(:name)
|
|
49
|
+
dependency_names = missing_dependencies.join(", ")
|
|
50
|
+
|
|
51
|
+
raise(
|
|
52
|
+
ArgumentError,
|
|
53
|
+
"DagLayout tasks must be topologically sorted; " \
|
|
54
|
+
"#{task_name} depends on unavailable prior tasks: #{dependency_names}"
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
#: () -> Array[Hash[Symbol, untyped]]
|
|
59
|
+
def nodes
|
|
60
|
+
@nodes ||= tasks.map do |task|
|
|
61
|
+
position = node_positions.fetch(task.fetch(:name))
|
|
62
|
+
task.merge(
|
|
63
|
+
x: x_for(position.fetch(:column)),
|
|
64
|
+
y: y_for(position.fetch(:row)),
|
|
65
|
+
width: NODE_WIDTH,
|
|
66
|
+
height: NODE_HEIGHT,
|
|
67
|
+
label: task.fetch(:name).to_s,
|
|
68
|
+
truncated_label: truncate_label(task.fetch(:name)),
|
|
69
|
+
meta_label: node_meta_label(task)
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
#: () -> Array[Hash[Symbol, untyped]]
|
|
75
|
+
def edges
|
|
76
|
+
@edges ||= tasks.flat_map do |task|
|
|
77
|
+
task.fetch(:depends_on).map do |dependency_name|
|
|
78
|
+
edge_view(dependency_name, task.fetch(:name))
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
#: () -> Hash[Symbol, Hash[Symbol, Integer]]
|
|
84
|
+
def node_positions
|
|
85
|
+
@node_positions ||= begin
|
|
86
|
+
column_rows = Hash.new(0) #: Hash[Integer, Integer]
|
|
87
|
+
positions = {} #: Hash[Symbol, Hash[Symbol, Integer]]
|
|
88
|
+
|
|
89
|
+
tasks.each_with_object(positions) do |task, current_positions|
|
|
90
|
+
column = dependency_column(task, current_positions)
|
|
91
|
+
row = column_rows[column]
|
|
92
|
+
column_rows[column] += 1
|
|
93
|
+
current_positions[task.fetch(:name)] = { column:, row: }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
#: (Hash[Symbol, untyped], Hash[Symbol, Hash[Symbol, Integer]]) -> Integer
|
|
99
|
+
def dependency_column(task, positions)
|
|
100
|
+
depends_on = task.fetch(:depends_on)
|
|
101
|
+
return 0 if depends_on.empty?
|
|
102
|
+
|
|
103
|
+
depends_on.map { |dependency_name| positions.fetch(dependency_name).fetch(:column) + 1 }.max || 0
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
#: (Symbol, Symbol) -> Hash[Symbol, untyped]
|
|
107
|
+
def edge_view(from_name, to_name)
|
|
108
|
+
from_node = node_view(from_name)
|
|
109
|
+
to_node = node_view(to_name)
|
|
110
|
+
{
|
|
111
|
+
from: from_name,
|
|
112
|
+
to: to_name,
|
|
113
|
+
path: edge_path(from_node, to_node)
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
#: (Hash[Symbol, untyped], Hash[Symbol, untyped]) -> String
|
|
118
|
+
def edge_path(from_node, to_node)
|
|
119
|
+
start_x = from_node.fetch(:x) + from_node.fetch(:width)
|
|
120
|
+
start_y = from_node.fetch(:y) + (from_node.fetch(:height) / 2)
|
|
121
|
+
end_x = to_node.fetch(:x)
|
|
122
|
+
end_y = to_node.fetch(:y) + (to_node.fetch(:height) / 2)
|
|
123
|
+
mid_x = ((start_x + end_x) / 2.0).round(2)
|
|
124
|
+
|
|
125
|
+
"M #{start_x} #{start_y} L #{mid_x} #{start_y} L #{mid_x} #{end_y} L #{end_x} #{end_y}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
#: (Symbol) -> Hash[Symbol, untyped]
|
|
129
|
+
def node_view(task_name)
|
|
130
|
+
nodes.find { |task| task.fetch(:name) == task_name } || raise(KeyError, task_name.to_s)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
#: () -> Integer
|
|
134
|
+
def canvas_width
|
|
135
|
+
return 0 if nodes.empty?
|
|
136
|
+
|
|
137
|
+
max_column = node_positions.values.map { |position| position.fetch(:column) }.max || 0
|
|
138
|
+
(PADDING * 2) + ((max_column + 1) * NODE_WIDTH) + (max_column * COLUMN_GAP)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
#: () -> Integer
|
|
142
|
+
def canvas_height
|
|
143
|
+
return 0 if nodes.empty?
|
|
144
|
+
|
|
145
|
+
max_row = node_positions.values.map { |position| position.fetch(:row) }.max || 0
|
|
146
|
+
(PADDING * 2) + ((max_row + 1) * NODE_HEIGHT) + (max_row * ROW_GAP)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
#: (Integer) -> Integer
|
|
150
|
+
def x_for(column)
|
|
151
|
+
PADDING + (column * (NODE_WIDTH + COLUMN_GAP))
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
#: (Integer) -> Integer
|
|
155
|
+
def y_for(row)
|
|
156
|
+
PADDING + (row * (NODE_HEIGHT + ROW_GAP))
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
#: (Symbol) -> String
|
|
160
|
+
def truncate_label(task_name)
|
|
161
|
+
label = task_name.to_s
|
|
162
|
+
return label if label.length <= LABEL_LIMIT
|
|
163
|
+
|
|
164
|
+
"#{label[0, LABEL_LIMIT - 1]}…"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
#: (Hash[Symbol, untyped]) -> String?
|
|
168
|
+
def node_meta_label(task)
|
|
169
|
+
return root_task_label if task.fetch(:depends_on).empty?
|
|
170
|
+
return each_progress_label(task.fetch(:each_progress)) if task.fetch(:each)
|
|
171
|
+
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
#: () -> String
|
|
176
|
+
def root_task_label
|
|
177
|
+
"root task"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
#: (Hash[Symbol, Integer]) -> String
|
|
181
|
+
def each_progress_label(progress)
|
|
182
|
+
"each #{progress.fetch(:succeeded)}/#{progress.fetch(:total)}"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobWorkflow
|
|
4
|
+
module Monitoring
|
|
5
|
+
# :nocov:
|
|
6
|
+
if defined?(Rails::Engine)
|
|
7
|
+
class Engine < ::Rails::Engine
|
|
8
|
+
isolate_namespace JobWorkflow::Monitoring
|
|
9
|
+
|
|
10
|
+
JobWorkflow::Monitoring.configure_engine_config(config) if respond_to?(:config)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
# :nocov:
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobWorkflow
|
|
4
|
+
module Monitoring
|
|
5
|
+
class ExecutionPage
|
|
6
|
+
attr_reader :executions #: Array[ExecutionViewModel]
|
|
7
|
+
attr_reader :next_cursor #: String?
|
|
8
|
+
|
|
9
|
+
#: (executions: Array[ExecutionViewModel], next_cursor: String?) -> void
|
|
10
|
+
def initialize(executions:, next_cursor:)
|
|
11
|
+
@executions = executions
|
|
12
|
+
@next_cursor = next_cursor
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobWorkflow
|
|
4
|
+
module Monitoring
|
|
5
|
+
class ExecutionRegistry
|
|
6
|
+
DEFAULT_LIMIT = 25
|
|
7
|
+
private_constant :DEFAULT_LIMIT
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
#: (job_class_name: String, ?limit: Integer, ?cursor: String?) -> ExecutionPage
|
|
11
|
+
def page_for(job_class_name:, limit: DEFAULT_LIMIT, cursor: nil)
|
|
12
|
+
page = QueueAdapter.current.fetch_root_workflow_job_page(job_class_name:, limit:, cursor:)
|
|
13
|
+
executions = page.fetch(:jobs).filter_map { |job_data| build_view_model(job_data) }
|
|
14
|
+
ExecutionPage.new(executions:, next_cursor: page[:next_cursor])
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
#: (String) -> ExecutionViewModel?
|
|
18
|
+
def find(job_id)
|
|
19
|
+
job_data = QueueAdapter.current.find_job(job_id)
|
|
20
|
+
return if job_data.nil?
|
|
21
|
+
|
|
22
|
+
build_view_model(job_data, hydrate_sub_tasks: true)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
#: (Hash[String, untyped], ?hydrate_sub_tasks: bool) -> ExecutionViewModel?
|
|
28
|
+
def build_view_model(job_data, hydrate_sub_tasks: false)
|
|
29
|
+
return if job_data["class_name"] == JobWorkflow::SubTaskJob.name
|
|
30
|
+
|
|
31
|
+
status = WorkflowStatus.from_job_data(job_data)
|
|
32
|
+
hydrate_sub_task_state(status) if hydrate_sub_tasks
|
|
33
|
+
ExecutionViewModel.new(job_id: job_data.fetch("job_id"), queue_name: job_data["queue_name"], status:)
|
|
34
|
+
rescue NameError => e
|
|
35
|
+
raise e if e.is_a?(NoMethodError)
|
|
36
|
+
|
|
37
|
+
nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
#: (WorkflowStatus) -> void
|
|
41
|
+
def hydrate_sub_task_state(status)
|
|
42
|
+
status.job_status.refresh_from_db!
|
|
43
|
+
sub_task_job_ids = status.job_status.flat_task_job_statuses.map(&:job_id)
|
|
44
|
+
sub_task_contexts = QueueAdapter.current.fetch_job_contexts(sub_task_job_ids)
|
|
45
|
+
status.output.update_task_outputs_from_contexts(sub_task_contexts, status.context.workflow)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobWorkflow
|
|
4
|
+
module Monitoring
|
|
5
|
+
class ExecutionViewModel
|
|
6
|
+
attr_reader :job_id #: String
|
|
7
|
+
attr_reader :queue_name #: String?
|
|
8
|
+
attr_reader :status #: WorkflowStatus
|
|
9
|
+
|
|
10
|
+
#: (job_id: String, queue_name: String?, status: WorkflowStatus) -> void
|
|
11
|
+
def initialize(job_id:, queue_name:, status:)
|
|
12
|
+
@job_id = job_id
|
|
13
|
+
@queue_name = queue_name
|
|
14
|
+
@status = status
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
#: () -> String
|
|
18
|
+
def job_class_name
|
|
19
|
+
status.job_class_name
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
#: () -> Symbol
|
|
23
|
+
def workflow_status
|
|
24
|
+
status.status
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
#: () -> Symbol?
|
|
28
|
+
def current_task_name
|
|
29
|
+
status.current_task_name
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
#: () -> Arguments
|
|
33
|
+
def arguments
|
|
34
|
+
status.arguments
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
#: () -> Hash[untyped, untyped]
|
|
38
|
+
def filtered_arguments
|
|
39
|
+
ParameterFilter.filter(arguments.to_h)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
#: () -> Array[Hash[Symbol, untyped]]
|
|
43
|
+
def tasks
|
|
44
|
+
@tasks ||= status.context.workflow.tasks.map { |task| task_view_model(task) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
#: () -> Symbol?
|
|
48
|
+
def failed_task_name
|
|
49
|
+
@failed_task_name ||= begin
|
|
50
|
+
failed_task = tasks.find { |task| task[:status] == :failed }
|
|
51
|
+
failed_task&.fetch(:name)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
#: () -> String?
|
|
56
|
+
def mission_control_job_path
|
|
57
|
+
JobWorkflow::Monitoring.mission_control_job_path(job_id, status: workflow_status)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
#: () -> Hash[Symbol, untyped]
|
|
61
|
+
def dag_layout
|
|
62
|
+
@dag_layout ||= DagLayout.new(tasks:).to_h
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
#: () -> bool
|
|
66
|
+
def running?
|
|
67
|
+
workflow_status == :running
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
#: () -> Hash[Symbol, untyped]
|
|
71
|
+
def to_h
|
|
72
|
+
{
|
|
73
|
+
job_id:,
|
|
74
|
+
queue_name:,
|
|
75
|
+
job_class_name:,
|
|
76
|
+
status: workflow_status,
|
|
77
|
+
current_task_name:,
|
|
78
|
+
failed_task_name:,
|
|
79
|
+
arguments: filtered_arguments,
|
|
80
|
+
tasks:,
|
|
81
|
+
mission_control_job_path:
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
#: (Symbol, Array[TaskOutput], Array[TaskJobStatus]) -> Symbol
|
|
88
|
+
def task_status(task_name, task_outputs, task_job_statuses)
|
|
89
|
+
return :failed if task_job_statuses.any?(&:failed?)
|
|
90
|
+
return :succeeded if completed_task?(task_outputs, task_job_statuses)
|
|
91
|
+
return :running if task_running?(task_name, task_job_statuses)
|
|
92
|
+
|
|
93
|
+
:pending
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
#: (Array[TaskOutput], Array[TaskJobStatus]) -> bool
|
|
97
|
+
def completed_task?(task_outputs, task_job_statuses)
|
|
98
|
+
return true if !running? && task_outputs.any?
|
|
99
|
+
return task_job_statuses.all?(&:succeeded?) if task_job_statuses.any?
|
|
100
|
+
|
|
101
|
+
task_outputs.any?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
#: (Symbol, Array[TaskJobStatus]) -> bool
|
|
105
|
+
def task_running?(task_name, task_job_statuses)
|
|
106
|
+
current_task_running?(task_name) ||
|
|
107
|
+
(!task_job_statuses.empty? && task_job_statuses.any? { |task_status| !task_status.finished? })
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
#: (Symbol) -> bool
|
|
111
|
+
def current_task_running?(task_name) = running? && current_task_name == task_name
|
|
112
|
+
|
|
113
|
+
#: (Task) -> Hash[Symbol, untyped]
|
|
114
|
+
def task_view_model(task)
|
|
115
|
+
task_name = task.task_name
|
|
116
|
+
task_outputs, task_job_statuses = task_state(task_name)
|
|
117
|
+
|
|
118
|
+
task_configuration_view(task).merge(
|
|
119
|
+
task_runtime_view(task_name, task_outputs, task_job_statuses)
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
#: (Task) -> Hash[Symbol, untyped]
|
|
124
|
+
def task_configuration_view(task)
|
|
125
|
+
{
|
|
126
|
+
name: task.task_name,
|
|
127
|
+
depends_on: task.depends_on,
|
|
128
|
+
each: task.each?,
|
|
129
|
+
configuration: task_configuration(task)
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
#: (Symbol, Array[TaskOutput], Array[TaskJobStatus]) -> Hash[Symbol, untyped]
|
|
134
|
+
def task_runtime_view(task_name, task_outputs, task_job_statuses)
|
|
135
|
+
{
|
|
136
|
+
status: task_status(task_name, task_outputs, task_job_statuses),
|
|
137
|
+
each_progress: each_progress(task_outputs, task_job_statuses),
|
|
138
|
+
outputs: task_outputs_view(task_outputs),
|
|
139
|
+
sub_task_jobs: sub_task_jobs_view(task_job_statuses)
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
#: (Symbol) -> [Array[TaskOutput], Array[TaskJobStatus]]
|
|
144
|
+
def task_state(task_name)
|
|
145
|
+
[
|
|
146
|
+
status.output.fetch_all(task_name:),
|
|
147
|
+
status.job_status.fetch_all(task_name:)
|
|
148
|
+
]
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
#: (Task) -> Hash[Symbol, untyped]
|
|
152
|
+
def task_configuration(task)
|
|
153
|
+
{
|
|
154
|
+
job_name: task.job_name,
|
|
155
|
+
each: callable_summary(task.each),
|
|
156
|
+
condition: callable_summary(task.condition),
|
|
157
|
+
enqueue: enqueue_configuration(task),
|
|
158
|
+
outputs: output_configuration(task),
|
|
159
|
+
retry: retry_configuration(task),
|
|
160
|
+
throttle: throttle_configuration(task),
|
|
161
|
+
timeout: task.timeout,
|
|
162
|
+
dependency_wait: dependency_wait_configuration(task),
|
|
163
|
+
dry_run: callable_summary(task.dry_run_config.evaluator)
|
|
164
|
+
}
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
#: (Task) -> Hash[Symbol, untyped]
|
|
168
|
+
def enqueue_configuration(task)
|
|
169
|
+
{
|
|
170
|
+
enabled: primitive_summary(task.enqueue.condition),
|
|
171
|
+
queue: task.enqueue.queue
|
|
172
|
+
}
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
#: (Task) -> Array[Hash[Symbol, untyped]]
|
|
176
|
+
def output_configuration(task)
|
|
177
|
+
task.output.map { |output| { name: output.name, type: output.type } }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
#: (Task) -> Hash[Symbol, untyped]
|
|
181
|
+
def retry_configuration(task)
|
|
182
|
+
{
|
|
183
|
+
count: task.task_retry.count,
|
|
184
|
+
strategy: task.task_retry.strategy,
|
|
185
|
+
base_delay: task.task_retry.base_delay,
|
|
186
|
+
jitter: task.task_retry.jitter
|
|
187
|
+
}
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
#: (Task) -> Hash[Symbol, untyped]
|
|
191
|
+
def throttle_configuration(task)
|
|
192
|
+
{
|
|
193
|
+
key: task.throttle.key,
|
|
194
|
+
limit: task.throttle.limit,
|
|
195
|
+
ttl: task.throttle.ttl
|
|
196
|
+
}
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
#: (Task) -> Hash[Symbol, untyped]
|
|
200
|
+
def dependency_wait_configuration(task)
|
|
201
|
+
{
|
|
202
|
+
poll_timeout: task.dependency_wait.poll_timeout,
|
|
203
|
+
poll_interval: task.dependency_wait.poll_interval,
|
|
204
|
+
reschedule_delay: task.dependency_wait.reschedule_delay,
|
|
205
|
+
polling_only: task.dependency_wait.polling_only?
|
|
206
|
+
}
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
#: (untyped) -> untyped
|
|
210
|
+
def callable_summary(value)
|
|
211
|
+
case value
|
|
212
|
+
when nil
|
|
213
|
+
nil
|
|
214
|
+
when Proc
|
|
215
|
+
"proc"
|
|
216
|
+
else
|
|
217
|
+
value
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
#: (untyped) -> untyped
|
|
222
|
+
def primitive_summary(value)
|
|
223
|
+
value.is_a?(Proc) ? "proc" : value
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
#: (Array[TaskOutput], Array[TaskJobStatus]) -> Hash[Symbol, Integer]
|
|
227
|
+
def each_progress(task_outputs, task_job_statuses)
|
|
228
|
+
{
|
|
229
|
+
total: [task_outputs.size, task_job_statuses.size].max,
|
|
230
|
+
succeeded: task_job_statuses.count(&:succeeded?),
|
|
231
|
+
failed: task_job_statuses.count(&:failed?),
|
|
232
|
+
pending: task_job_statuses.count { |task_status| task_status.status == :pending },
|
|
233
|
+
running: task_job_statuses.count { |task_status| task_status.status == :running }
|
|
234
|
+
}
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
#: (Array[TaskOutput]) -> Array[Hash[Symbol, untyped]]
|
|
238
|
+
def task_outputs_view(task_outputs)
|
|
239
|
+
task_outputs.map do |output|
|
|
240
|
+
{ each_index: output.each_index, data: ParameterFilter.filter(output.data) }
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
#: (Array[TaskJobStatus]) -> Array[Hash[Symbol, untyped]]
|
|
245
|
+
def sub_task_jobs_view(task_job_statuses)
|
|
246
|
+
Array(task_job_statuses).map do |task_job_status|
|
|
247
|
+
{
|
|
248
|
+
job_id: task_job_status.job_id,
|
|
249
|
+
each_index: task_job_status.each_index,
|
|
250
|
+
status: task_job_status.status,
|
|
251
|
+
mission_control_job_path: mission_control_job_path_for(task_job_status.job_id, task_job_status.status)
|
|
252
|
+
}
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
#: (String?, Symbol?) -> String?
|
|
257
|
+
def mission_control_job_path_for(job_id, status = nil)
|
|
258
|
+
JobWorkflow::Monitoring.mission_control_job_path(job_id, status:)
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|