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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -2
  3. data/CHANGELOG.md +30 -0
  4. data/README.md +1 -1
  5. data/app/controllers/job_workflow/monitoring/application_controller.rb +11 -0
  6. data/app/controllers/job_workflow/monitoring/executions_controller.rb +28 -0
  7. data/app/controllers/job_workflow/monitoring/workflows_controller.rb +11 -0
  8. data/app/views/job_workflow/monitoring/executions/index.html.erb +57 -0
  9. data/app/views/job_workflow/monitoring/executions/show.html.erb +200 -0
  10. data/app/views/job_workflow/monitoring/workflows/index.html.erb +39 -0
  11. data/app/views/layouts/job_workflow/monitoring/application.html.erb +117 -0
  12. data/config/routes.rb +8 -0
  13. data/guides/API_REFERENCE.md +79 -6
  14. data/guides/DEPENDENCY_WAIT.md +9 -5
  15. data/guides/MONITORING_UI.md +74 -0
  16. data/guides/PARALLEL_PROCESSING.md +33 -21
  17. data/guides/PRODUCTION_DEPLOYMENT.md +1 -1
  18. data/guides/README.md +6 -1
  19. data/guides/THROTTLING.md +24 -0
  20. data/guides/WORKFLOW_STATUS_QUERY.md +7 -1
  21. data/lib/job_workflow/context.rb +68 -6
  22. data/lib/job_workflow/dsl.rb +1 -5
  23. data/lib/job_workflow/instrumentation/opentelemetry_subscriber.rb +1 -1
  24. data/lib/job_workflow/instrumentation.rb +14 -14
  25. data/lib/job_workflow/job_status.rb +16 -1
  26. data/lib/job_workflow/monitoring/dag_layout.rb +186 -0
  27. data/lib/job_workflow/monitoring/engine.rb +15 -0
  28. data/lib/job_workflow/monitoring/execution_page.rb +16 -0
  29. data/lib/job_workflow/monitoring/execution_registry.rb +50 -0
  30. data/lib/job_workflow/monitoring/execution_view_model.rb +262 -0
  31. data/lib/job_workflow/monitoring/parameter_filter.rb +37 -0
  32. data/lib/job_workflow/monitoring/workflow_definition.rb +24 -0
  33. data/lib/job_workflow/monitoring/workflow_registry.rb +24 -0
  34. data/lib/job_workflow/monitoring.rb +120 -0
  35. data/lib/job_workflow/queue_adapters/abstract.rb +7 -2
  36. data/lib/job_workflow/queue_adapters/null_adapter.rb +12 -1
  37. data/lib/job_workflow/queue_adapters/solid_queue_adapter.rb +42 -12
  38. data/lib/job_workflow/railtie.rb +12 -0
  39. data/lib/job_workflow/runner.rb +38 -15
  40. data/lib/job_workflow/sub_task_job.rb +93 -0
  41. data/lib/job_workflow/task.rb +7 -0
  42. data/lib/job_workflow/task_enqueue.rb +19 -12
  43. data/lib/job_workflow/version.rb +1 -1
  44. data/lib/job_workflow/workflow_status.rb +20 -1
  45. data/lib/job_workflow.rb +5 -1
  46. data/sig/generated/job_workflow/context.rbs +31 -7
  47. data/sig/generated/job_workflow/instrumentation/opentelemetry_subscriber.rbs +0 -1
  48. data/sig/generated/job_workflow/instrumentation.rbs +28 -28
  49. data/sig/generated/job_workflow/job_status.rbs +5 -2
  50. data/sig/generated/job_workflow/monitoring/dag_layout.rbs +80 -0
  51. data/sig/generated/job_workflow/monitoring/engine.rbs +8 -0
  52. data/sig/generated/job_workflow/monitoring/execution_page.rbs +14 -0
  53. data/sig/generated/job_workflow/monitoring/execution_registry.rbs +21 -0
  54. data/sig/generated/job_workflow/monitoring/execution_view_model.rbs +111 -0
  55. data/sig/generated/job_workflow/monitoring/parameter_filter.rbs +16 -0
  56. data/sig/generated/job_workflow/monitoring/workflow_definition.rbs +18 -0
  57. data/sig/generated/job_workflow/monitoring/workflow_registry.rbs +13 -0
  58. data/sig/generated/job_workflow/monitoring.rbs +38 -0
  59. data/sig/generated/job_workflow/queue_adapters/abstract.rbs +7 -4
  60. data/sig/generated/job_workflow/queue_adapters/null_adapter.rbs +5 -2
  61. data/sig/generated/job_workflow/queue_adapters/solid_queue_adapter.rbs +18 -6
  62. data/sig/generated/job_workflow/railtie.rbs +6 -0
  63. data/sig/generated/job_workflow/runner.rbs +8 -5
  64. data/sig/generated/job_workflow/sub_task_job.rbs +40 -0
  65. data/sig/generated/job_workflow/task.rbs +5 -0
  66. data/sig/generated/job_workflow/task_enqueue.rbs +5 -8
  67. data/sig/generated/job_workflow/workflow_status.rbs +6 -0
  68. data/sig-private/job-workflow.rbs +11 -0
  69. data/sig-private/rails.rbs +5 -0
  70. metadata +34 -1
@@ -40,7 +40,7 @@ module JobWorkflow
40
40
  end
41
41
 
42
42
  class << self
43
- #: (DSL) { () -> untyped } -> untyped
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
- #: (DSL, Task, Context) { () -> untyped } -> untyped
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
- #: (DSL, Task, String) -> void
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
- #: (DSL, Task, Integer) -> void
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
- #: (DSL, Task) { () -> untyped } -> untyped
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
- #: (DSL, Task, Numeric, Integer) -> void
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
- #: (DSL, Context, Symbol?, Integer, bool) { () -> untyped } -> untyped
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
- #: (DSL) -> Hash[Symbol, untyped]
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
- #: (DSL, Task, Context) -> Hash[Symbol, untyped]
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
- #: (DSL, Task, String) -> Hash[Symbol, untyped]
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
- #: (DSL, Task, Integer) -> Hash[Symbol, untyped]
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
- #: (DSL, Task) -> Hash[Symbol, untyped]
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
- #: (DSL, Task, Numeric, Integer) -> Hash[Symbol, untyped]
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
- #: (DSL, Context, Symbol?, Integer, bool) -> Hash[Symbol, untyped]
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[DSL]) -> void
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