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.
- checksums.yaml +4 -4
- data/.agents/instructions/coding-style.md +38 -0
- data/.agents/instructions/domain.md +37 -0
- data/.agents/instructions/environment.md +44 -0
- data/.agents/instructions/general.md +29 -0
- data/.agents/instructions/security.md +20 -0
- data/.agents/instructions/structure.md +43 -0
- data/.agents/instructions/tech-stack.md +40 -0
- data/.agents/instructions/testing.md +46 -0
- data/.agents/instructions/workflow.md +39 -0
- data/.rubocop.yml +1 -2
- data/AGENTS.md +23 -0
- data/CHANGELOG.md +26 -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 +9 -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 +7 -5
- data/lib/job_workflow/dsl.rb +0 -4
- 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 +258 -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 +2 -0
- data/lib/job_workflow/runner.rb +5 -3
- data/lib/job_workflow/sub_task_job.rb +93 -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 +39 -3
- data/lib/job_workflow.rb +2 -0
- data/rbs_collection.lock.yaml +11 -11
- data/sig/generated/job_workflow/context.rbs +7 -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/runner.rbs +1 -1
- data/sig/generated/job_workflow/sub_task_job.rbs +40 -0
- data/sig/generated/job_workflow/task_enqueue.rbs +5 -8
- data/sig/generated/job_workflow/workflow_status.rbs +18 -2
- data/sig-private/job-workflow.rbs +11 -0
- data/sig-private/rails.rbs +5 -0
- metadata +42 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Production Deployment
|
|
2
2
|
|
|
3
|
-
> ⚠️ **Early Stage (v0.
|
|
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.
|
|
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
|
data/lib/job_workflow/context.rb
CHANGED
|
@@ -52,10 +52,12 @@ module JobWorkflow
|
|
|
52
52
|
# task_context: TaskContext,
|
|
53
53
|
# output: Output,
|
|
54
54
|
# job_status: JobStatus,
|
|
55
|
-
# ?job:
|
|
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
|
-
|
|
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
|
-
#: (
|
|
111
|
+
#: (_JobInterface) -> void
|
|
110
112
|
def _job=(job)
|
|
111
113
|
self.job = job
|
|
112
114
|
end
|
|
113
115
|
|
|
114
|
-
#: () ->
|
|
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 #:
|
|
282
|
+
attr_accessor :job #: _JobInterface?
|
|
281
283
|
attr_writer :workflow #: Workflow
|
|
282
284
|
attr_writer :arguments #: Arguments
|
|
283
285
|
attr_writer :output #: Output
|
data/lib/job_workflow/dsl.rb
CHANGED
|
@@ -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
|
|
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
|
-
#: (
|
|
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
|