job-workflow 0.5.0 → 0.6.1

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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/.agents/instructions/coding-style.md +38 -0
  3. data/.agents/instructions/domain.md +37 -0
  4. data/.agents/instructions/environment.md +44 -0
  5. data/.agents/instructions/general.md +29 -0
  6. data/.agents/instructions/security.md +20 -0
  7. data/.agents/instructions/structure.md +43 -0
  8. data/.agents/instructions/tech-stack.md +40 -0
  9. data/.agents/instructions/testing.md +46 -0
  10. data/.agents/instructions/workflow.md +39 -0
  11. data/.rubocop.yml +1 -2
  12. data/AGENTS.md +23 -0
  13. data/CHANGELOG.md +26 -0
  14. data/README.md +1 -1
  15. data/app/controllers/job_workflow/monitoring/application_controller.rb +11 -0
  16. data/app/controllers/job_workflow/monitoring/executions_controller.rb +28 -0
  17. data/app/controllers/job_workflow/monitoring/workflows_controller.rb +11 -0
  18. data/app/views/job_workflow/monitoring/executions/index.html.erb +57 -0
  19. data/app/views/job_workflow/monitoring/executions/show.html.erb +200 -0
  20. data/app/views/job_workflow/monitoring/workflows/index.html.erb +39 -0
  21. data/app/views/layouts/job_workflow/monitoring/application.html.erb +117 -0
  22. data/config/routes.rb +8 -0
  23. data/guides/API_REFERENCE.md +9 -6
  24. data/guides/DEPENDENCY_WAIT.md +9 -5
  25. data/guides/MONITORING_UI.md +74 -0
  26. data/guides/PARALLEL_PROCESSING.md +33 -21
  27. data/guides/PRODUCTION_DEPLOYMENT.md +1 -1
  28. data/guides/README.md +6 -1
  29. data/guides/THROTTLING.md +24 -0
  30. data/guides/WORKFLOW_STATUS_QUERY.md +7 -1
  31. data/lib/job_workflow/context.rb +7 -5
  32. data/lib/job_workflow/dsl.rb +0 -4
  33. data/lib/job_workflow/instrumentation/opentelemetry_subscriber.rb +1 -1
  34. data/lib/job_workflow/instrumentation.rb +14 -14
  35. data/lib/job_workflow/job_status.rb +16 -1
  36. data/lib/job_workflow/monitoring/dag_layout.rb +186 -0
  37. data/lib/job_workflow/monitoring/engine.rb +15 -0
  38. data/lib/job_workflow/monitoring/execution_page.rb +16 -0
  39. data/lib/job_workflow/monitoring/execution_registry.rb +50 -0
  40. data/lib/job_workflow/monitoring/execution_view_model.rb +258 -0
  41. data/lib/job_workflow/monitoring/parameter_filter.rb +37 -0
  42. data/lib/job_workflow/monitoring/workflow_definition.rb +24 -0
  43. data/lib/job_workflow/monitoring/workflow_registry.rb +24 -0
  44. data/lib/job_workflow/monitoring.rb +120 -0
  45. data/lib/job_workflow/queue_adapters/abstract.rb +7 -2
  46. data/lib/job_workflow/queue_adapters/null_adapter.rb +12 -1
  47. data/lib/job_workflow/queue_adapters/solid_queue_adapter.rb +42 -12
  48. data/lib/job_workflow/railtie.rb +2 -0
  49. data/lib/job_workflow/runner.rb +5 -3
  50. data/lib/job_workflow/sub_task_job.rb +93 -0
  51. data/lib/job_workflow/task_enqueue.rb +19 -12
  52. data/lib/job_workflow/version.rb +1 -1
  53. data/lib/job_workflow/workflow_status.rb +39 -3
  54. data/lib/job_workflow.rb +2 -0
  55. data/rbs_collection.lock.yaml +11 -11
  56. data/sig/generated/job_workflow/context.rbs +7 -7
  57. data/sig/generated/job_workflow/instrumentation/opentelemetry_subscriber.rbs +0 -1
  58. data/sig/generated/job_workflow/instrumentation.rbs +28 -28
  59. data/sig/generated/job_workflow/job_status.rbs +5 -2
  60. data/sig/generated/job_workflow/monitoring/dag_layout.rbs +80 -0
  61. data/sig/generated/job_workflow/monitoring/engine.rbs +8 -0
  62. data/sig/generated/job_workflow/monitoring/execution_page.rbs +14 -0
  63. data/sig/generated/job_workflow/monitoring/execution_registry.rbs +21 -0
  64. data/sig/generated/job_workflow/monitoring/execution_view_model.rbs +111 -0
  65. data/sig/generated/job_workflow/monitoring/parameter_filter.rbs +16 -0
  66. data/sig/generated/job_workflow/monitoring/workflow_definition.rbs +18 -0
  67. data/sig/generated/job_workflow/monitoring/workflow_registry.rbs +13 -0
  68. data/sig/generated/job_workflow/monitoring.rbs +38 -0
  69. data/sig/generated/job_workflow/queue_adapters/abstract.rbs +7 -4
  70. data/sig/generated/job_workflow/queue_adapters/null_adapter.rbs +5 -2
  71. data/sig/generated/job_workflow/queue_adapters/solid_queue_adapter.rbs +18 -6
  72. data/sig/generated/job_workflow/runner.rbs +1 -1
  73. data/sig/generated/job_workflow/sub_task_job.rbs +40 -0
  74. data/sig/generated/job_workflow/task_enqueue.rbs +5 -8
  75. data/sig/generated/job_workflow/workflow_status.rbs +18 -2
  76. data/sig-private/job-workflow.rbs +11 -0
  77. data/sig-private/rails.rbs +5 -0
  78. metadata +42 -1
@@ -1,6 +1,6 @@
1
1
  # Production Deployment
2
2
 
3
- > ⚠️ **Early Stage (v0.5.0):** JobWorkflow is still in early development. While this section outlines potential deployment patterns, please thoroughly test in your specific environment and monitor for any issues before relying on JobWorkflow in critical production systems.
3
+ > ⚠️ **Early Stage (v0.6.1):** JobWorkflow is still in early development. While this section outlines potential deployment patterns, please thoroughly test in your specific environment and monitor for any issues before relying on JobWorkflow in critical production systems.
4
4
 
5
5
  This section covers suggested settings and patterns for running JobWorkflow in production-like environments.
6
6
 
data/guides/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # JobWorkflow Guides
2
2
 
3
- > ⚠️ **Early Stage (v0.5.0):** JobWorkflow is in active development. APIs and features may change. The following guides provide patterns and examples for building workflows, but be aware that implementations may need adjustment as the library evolves.
3
+ > ⚠️ **Early Stage (v0.6.1):** JobWorkflow is in active development. APIs and features may change. The following guides provide patterns and examples for building workflows, but be aware that implementations may need adjustment as the library evolves.
4
4
 
5
5
  Welcome to the JobWorkflow documentation! This directory contains comprehensive guides to help you build robust workflows with JobWorkflow.
6
6
 
@@ -127,6 +127,11 @@ Production deployment and operations:
127
127
  - Accessing arguments, outputs, and job status
128
128
  - Building dashboards and APIs
129
129
 
130
+ - **[MONITORING_UI.md](MONITORING_UI.md)** - Monitoring UI for workflow execution
131
+ - Mounting the engine
132
+ - Navigating workflow definitions and executions
133
+ - Viewing DAG state, arguments, outputs, and fan-out progress
134
+
130
135
  - **[TESTING_STRATEGY.md](TESTING_STRATEGY.md)** - Testing your workflows
131
136
  - Unit testing individual tasks
132
137
  - Integration testing workflows
data/guides/THROTTLING.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  JobWorkflow provides semaphore-based throttling to handle external API rate limits and protect shared resources. Throttling works across multiple jobs and workers, ensuring system-wide rate limiting.
4
4
 
5
+ For async map tasks (`enqueue: true`), `throttle` is also the official way to cap concurrent sub-job execution. This limit is enforced at perform time by JobWorkflow semaphores, not by SolidQueue's ready/blocked dispatch-state controls.
6
+
5
7
  ## Task-Level Throttling
6
8
 
7
9
  ### Simple Integer Syntax (Recommended)
@@ -114,6 +116,28 @@ end
114
116
  # → Max 5 concurrent API calls at any time
115
117
  ```
116
118
 
119
+ ### Throttling Async Sub-Tasks
120
+
121
+ When a map task runs as sub-jobs, combine `enqueue: true` with `throttle`:
122
+
123
+ ```ruby
124
+ class AsyncBatchFetchJob < ApplicationJob
125
+ include JobWorkflow::DSL
126
+
127
+ argument :ids, "Array[Integer]"
128
+
129
+ task :fetch_all,
130
+ each: ->(ctx) { ctx.arguments.ids },
131
+ enqueue: true,
132
+ throttle: 5,
133
+ output: { data: "Hash" } do |ctx|
134
+ { data: RateLimitedAPI.fetch(ctx.each_value) }
135
+ end
136
+ end
137
+ ```
138
+
139
+ This keeps sub-job fan-out while ensuring only 5 iterations execute at the same time across workers.
140
+
117
141
  ## Runtime Throttling
118
142
 
119
143
  For fine-grained control within a task, use the `ctx.throttle` method to wrap specific code blocks. This method can only be called inside a task block; calling it outside will raise an error.
@@ -2,6 +2,8 @@
2
2
 
3
3
  JobWorkflow provides a robust API for querying the execution status of workflows. This allows you to monitor running workflows, inspect their state, and build observability dashboards.
4
4
 
5
+ `JobWorkflow::WorkflowStatus.find` and `find_by` are root workflow APIs. Pass the root workflow `job_id` only. Async sub-job IDs created by `enqueue: true` are intentionally excluded; inspect those via `JobWorkflow::JobStatus`.
6
+
5
7
  ## Basic Usage
6
8
 
7
9
  ### Finding a Workflow
@@ -16,6 +18,10 @@ return unless status
16
18
 
17
19
  # Check workflow status
18
20
  status.status # => :pending, :running, :succeeded, or :failed
21
+
22
+ # Sub-job IDs are excluded from WorkflowStatus
23
+ JobWorkflow::WorkflowStatus.find_by(job_id: "sub-job-123")
24
+ # => nil
19
25
  ```
20
26
 
21
27
  ### Status Check Methods
@@ -251,7 +257,7 @@ end
251
257
 
252
258
  ### NotFoundError
253
259
 
254
- When using `find`, a `JobWorkflow::WorkflowStatus::NotFoundError` is raised if the job is not found:
260
+ When using `find`, a `JobWorkflow::WorkflowStatus::NotFoundError` is raised if the job is not found. The same applies if you pass a sub-job `job_id` instead of a root workflow `job_id`:
255
261
 
256
262
  ```ruby
257
263
  begin
@@ -52,10 +52,12 @@ module JobWorkflow
52
52
  # task_context: TaskContext,
53
53
  # output: Output,
54
54
  # job_status: JobStatus,
55
- # ?job: DSL?
55
+ # ?job: _JobInterface?
56
56
  # ) -> void
57
57
  def initialize(workflow:, arguments:, task_context:, output:, job_status:, job: nil) # rubocop:disable Metrics/ParameterLists, Metrics/AbcSize, Metrics/MethodLength
58
- raise "job does not match the provided workflow" if job&.then { |j| j.class._workflow != workflow }
58
+ if job&.class.respond_to?(:_workflow) && job.class._workflow != workflow
59
+ raise "job does not match the provided workflow"
60
+ end
59
61
 
60
62
  self.job = job
61
63
  self.workflow = workflow
@@ -106,12 +108,12 @@ module JobWorkflow
106
108
  step.set!(build_step_cursor(current_cursor))
107
109
  end
108
110
 
109
- #: (DSL) -> void
111
+ #: (_JobInterface) -> void
110
112
  def _job=(job)
111
113
  self.job = job
112
114
  end
113
115
 
114
- #: () -> DSL?
116
+ #: () -> _JobInterface?
115
117
  def _job
116
118
  job
117
119
  end
@@ -277,7 +279,7 @@ module JobWorkflow
277
279
 
278
280
  private
279
281
 
280
- attr_accessor :job #: DSL?
282
+ attr_accessor :job #: _JobInterface?
281
283
  attr_writer :workflow #: Workflow
282
284
  attr_writer :arguments #: Arguments
283
285
  attr_writer :output #: Output
@@ -156,10 +156,6 @@ module JobWorkflow
156
156
  dry_run:
157
157
  )
158
158
  _workflow.add_task(new_task)
159
- if new_task.enqueue.should_limits_concurrency? # rubocop:disable Style/GuardClause
160
- concurrency = new_task.enqueue.concurrency #: Integer
161
- workflow_concurrency(to: concurrency, key: :concurrency_key.to_proc)
162
- end
163
159
  end
164
160
  # rubocop:enable Metrics/ParameterLists
165
161
 
@@ -18,7 +18,7 @@ module JobWorkflow
18
18
  #
19
19
  # @note This subscriber requires the opentelemetry-api gem to be installed.
20
20
  # If not available, subscription will be silently skipped.
21
- class OpenTelemetrySubscriber # rubocop:disable Metrics/ClassLength
21
+ class OpenTelemetrySubscriber
22
22
  module Attributes
23
23
  JOB_NAME = "#{NAMESPACE}.job.name".freeze #: String
24
24
  JOB_ID = "#{NAMESPACE}.job.id".freeze #: String
@@ -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