job-workflow 0.5.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -2
  3. data/CHANGELOG.md +20 -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 +9 -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 +7 -5
  22. data/lib/job_workflow/dsl.rb +0 -4
  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 +2 -0
  39. data/lib/job_workflow/runner.rb +5 -3
  40. data/lib/job_workflow/sub_task_job.rb +93 -0
  41. data/lib/job_workflow/task_enqueue.rb +19 -12
  42. data/lib/job_workflow/version.rb +1 -1
  43. data/lib/job_workflow/workflow_status.rb +20 -1
  44. data/lib/job_workflow.rb +2 -0
  45. data/sig/generated/job_workflow/context.rbs +7 -7
  46. data/sig/generated/job_workflow/instrumentation/opentelemetry_subscriber.rbs +0 -1
  47. data/sig/generated/job_workflow/instrumentation.rbs +28 -28
  48. data/sig/generated/job_workflow/job_status.rbs +5 -2
  49. data/sig/generated/job_workflow/monitoring/dag_layout.rbs +80 -0
  50. data/sig/generated/job_workflow/monitoring/engine.rbs +8 -0
  51. data/sig/generated/job_workflow/monitoring/execution_page.rbs +14 -0
  52. data/sig/generated/job_workflow/monitoring/execution_registry.rbs +21 -0
  53. data/sig/generated/job_workflow/monitoring/execution_view_model.rbs +111 -0
  54. data/sig/generated/job_workflow/monitoring/parameter_filter.rbs +16 -0
  55. data/sig/generated/job_workflow/monitoring/workflow_definition.rbs +18 -0
  56. data/sig/generated/job_workflow/monitoring/workflow_registry.rbs +13 -0
  57. data/sig/generated/job_workflow/monitoring.rbs +38 -0
  58. data/sig/generated/job_workflow/queue_adapters/abstract.rbs +7 -4
  59. data/sig/generated/job_workflow/queue_adapters/null_adapter.rbs +5 -2
  60. data/sig/generated/job_workflow/queue_adapters/solid_queue_adapter.rbs +18 -6
  61. data/sig/generated/job_workflow/runner.rbs +1 -1
  62. data/sig/generated/job_workflow/sub_task_job.rbs +40 -0
  63. data/sig/generated/job_workflow/task_enqueue.rbs +5 -8
  64. data/sig/generated/job_workflow/workflow_status.rbs +6 -0
  65. data/sig-private/job-workflow.rbs +11 -0
  66. data/sig-private/rails.rbs +5 -0
  67. metadata +32 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9474c65583cf21d485c3468675d4f23124e60e88f98c954bd38256b916d98ffe
4
- data.tar.gz: 59d5e0b60a2b52e87ea4da4aab1834d02c63f88cba36f661acd8c74f89a4e6a9
3
+ metadata.gz: fbf6231d334745f4814282d93fa8c7b54461680e6bb57edc6632cd39b86cb71b
4
+ data.tar.gz: c627082defb65e2a4cd79cb2b88d3f54ce8b082a88d9ccaff3d96029ee49ea65
5
5
  SHA512:
6
- metadata.gz: 4ac034e1b373b28aa52dcffd059bc5b64e5c45fc04d9b07460c24491c2514eabddf839b2dcb2c4a2cfbf36b578e2836e153e9bd6f34cd24f8af543cf06242ce9
7
- data.tar.gz: 455b6913137103b6a5871562a6adc0ffca11e9e99543b94ec7e0bda9f2b78c6c76c5cee73baec30ca5b5b5e2fd388d6a4b119c08d7cf1039c20e115e255f58d7
6
+ metadata.gz: f8caf717541c7f549d3957a5939d636881f8ad98389e5e53759dde43e561c8bbdeda0211feab0843f2625fbc974fb217b479bc2421e0bc828b9f6d910b749cd5
7
+ data.tar.gz: 466d87d40597d0d3c68de4cec3f2f6d158faab74100d098420cd376108fb88667f15cf1ecccdf2b810e41df402a9db5f7d84945a71de2dd756e71ae99588fe58
data/.rubocop.yml CHANGED
@@ -41,7 +41,7 @@ Metrics/BlockLength:
41
41
  Metrics/ClassLength:
42
42
  Enabled: true
43
43
  CountComments: false
44
- Max: 100
44
+ Max: 124
45
45
  CountAsOne:
46
46
  - array
47
47
  - hash
@@ -88,4 +88,3 @@ Style/StringLiteralsInInterpolation:
88
88
 
89
89
  ThreadSafety/ClassAndModuleAttributes:
90
90
  Enabled: false
91
-
data/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.6.0] - 2026-05-24
4
+
5
+ ### Added
6
+
7
+ - Add `JobWorkflow::SubTaskJob` for `enqueue: true` task fan-out so each-task sub-jobs run on a dedicated job class instead of reusing the workflow DSL job class directly
8
+ - Add a JobWorkflow monitoring UI engine with an execution DAG overview for browsing workflow definitions, root executions, DAG state, arguments, outputs, fan-out progress, and linked Mission Control Jobs rows
9
+
10
+ ### Changed
11
+
12
+ - **Breaking:** `WorkflowStatus.find` / `find_by` are now root-workflow-only APIs. Sub-task job IDs are excluded and should be tracked via `JobStatus`.
13
+
14
+ ### Fixed
15
+
16
+ - Route enqueued each-task execution through `SubTaskJob`, restoring workflow/task context from the parent workflow job and keeping parent/sub-job identity separate during async execution
17
+ - Extend queue-adapter and workflow-status hydration so monitoring can paginate root workflow executions, hydrate sub-task detail on demand, and keep partially completed fan-out tasks visible as `running` until the workflow actually settles
18
+
19
+ ### Removed
20
+
21
+ - Remove `enqueue: { concurrency: N }`. Async map task fan-out is still controlled by `enqueue`, but execution caps are now configured exclusively with `throttle`.
22
+
3
23
  ## [0.5.0] - 2026-05-13
4
24
 
5
25
  ### Added
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # JobWorkflow
2
2
 
3
- > ⚠️ **Early Stage (v0.5.0):** This library is in active development. APIs and features may change in breaking ways without notice. Use in production at your own risk and expect potential breaking changes in future releases.
3
+ > ⚠️ **Early Stage (v0.6.0):** This library is in active development. APIs and features may change in breaking ways without notice. Use in production at your own risk and expect potential breaking changes in future releases.
4
4
 
5
5
  ## Overview
6
6
 
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JobWorkflow
4
+ module Monitoring
5
+ # Resolve the host app controller at load time so engine controllers inherit
6
+ # the app's existing authentication and access control hooks.
7
+ class ApplicationController < JobWorkflow::Monitoring.resolved_base_controller_class.constantize
8
+ layout "job_workflow/monitoring/application"
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JobWorkflow
4
+ module Monitoring
5
+ class ExecutionsController < ApplicationController
6
+ def index
7
+ @workflow = WorkflowRegistry.find(params[:workflow_job_class_name])
8
+ return render plain: "Workflow definition not found.", status: :not_found if @workflow.nil?
9
+
10
+ @page = ExecutionRegistry.page_for(
11
+ job_class_name: @workflow.name,
12
+ cursor: params[:cursor]
13
+ )
14
+ @executions = @page.executions
15
+ end
16
+
17
+ def show
18
+ @workflow = WorkflowRegistry.find(params[:workflow_job_class_name])
19
+ return render plain: "Workflow definition not found.", status: :not_found if @workflow.nil?
20
+
21
+ @execution = ExecutionRegistry.find(params[:id])
22
+ return if @execution && @execution.job_class_name == @workflow.name
23
+
24
+ render plain: "Workflow execution is no longer available.", status: :not_found
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JobWorkflow
4
+ module Monitoring
5
+ class WorkflowsController < ApplicationController
6
+ def index
7
+ @workflows = Monitoring.workflows
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,57 @@
1
+ <section class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-end gap-3 mb-4">
2
+ <div>
3
+ <div class="text-body-secondary small mb-1">Workflow</div>
4
+ <h1 class="display-6 fw-semibold mb-2"><%= @workflow.name %></h1>
5
+ <p class="text-body-secondary mb-0">Paginated root executions. sub task jobs appear only inside execution detail.</p>
6
+ </div>
7
+ <div>
8
+ <%= link_to "Back to workflows", root_path, class: "btn btn-outline-secondary" %>
9
+ </div>
10
+ </section>
11
+
12
+ <div class="card jw-card">
13
+ <div class="card-body p-0">
14
+ <% if @executions.empty? %>
15
+ <div class="p-4 text-body-secondary">No currently available workflow executions.</div>
16
+ <% else %>
17
+ <div class="table-responsive">
18
+ <table class="table table-hover align-middle mb-0">
19
+ <thead class="table-light">
20
+ <tr>
21
+ <th class="ps-4">Status</th>
22
+ <th>Current task</th>
23
+ <th>Queue</th>
24
+ <th>Job</th>
25
+ <th class="text-end pe-4">Detail</th>
26
+ </tr>
27
+ </thead>
28
+ <tbody>
29
+ <% @executions.each do |execution| %>
30
+ <% status_class = case execution.workflow_status
31
+ when :succeeded then "text-bg-success"
32
+ when :failed then "text-bg-danger"
33
+ when :running then "text-bg-primary"
34
+ else "text-bg-secondary"
35
+ end %>
36
+ <tr>
37
+ <td class="ps-4"><span class="badge <%= status_class %>"><%= execution.workflow_status %></span></td>
38
+ <td><%= execution.current_task_name || "-" %></td>
39
+ <td><span class="jw-mono"><%= execution.queue_name || "-" %></span></td>
40
+ <td class="jw-mono"><%= execution.job_id %></td>
41
+ <td class="text-end pe-4">
42
+ <%= link_to "Open", workflow_execution_path(@workflow.name, execution.job_id), class: "btn btn-sm btn-primary" %>
43
+ </td>
44
+ </tr>
45
+ <% end %>
46
+ </tbody>
47
+ </table>
48
+ </div>
49
+ <% end %>
50
+ </div>
51
+ </div>
52
+
53
+ <% if @page.next_cursor %>
54
+ <div class="d-flex justify-content-end mt-3">
55
+ <%= link_to "Next page", workflow_executions_path(@workflow.name, cursor: @page.next_cursor), class: "btn btn-outline-primary" %>
56
+ </div>
57
+ <% end %>
@@ -0,0 +1,200 @@
1
+ <% status_class = case @execution.workflow_status
2
+ when :succeeded then "text-bg-success"
3
+ when :failed then "text-bg-danger"
4
+ when :running then "text-bg-primary"
5
+ else "text-bg-secondary"
6
+ end %>
7
+ <% dag_layout = @execution.dag_layout %>
8
+
9
+ <section class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-start gap-3 mb-4">
10
+ <div>
11
+ <div class="text-body-secondary small mb-1">Execution</div>
12
+ <h1 class="display-6 fw-semibold mb-3"><%= @execution.job_class_name %></h1>
13
+ <div class="d-flex flex-wrap gap-2">
14
+ <span class="badge <%= status_class %>"><%= @execution.workflow_status %></span>
15
+ <span class="badge text-bg-light border text-dark">task <%= @execution.current_task_name || "-" %></span>
16
+ <span class="badge text-bg-light border text-dark">failed <%= @execution.failed_task_name || "-" %></span>
17
+ </div>
18
+ </div>
19
+ <div class="d-flex flex-wrap gap-2">
20
+ <%= link_to "Back to executions", workflow_executions_path(@workflow.name), class: "btn btn-outline-secondary" %>
21
+ <% if @execution.mission_control_job_path %>
22
+ <%= link_to "Mission Control Jobs", @execution.mission_control_job_path, class: "btn btn-primary" %>
23
+ <% end %>
24
+ </div>
25
+ </section>
26
+
27
+ <div class="row g-4 mb-4">
28
+ <div class="col-12 col-xl-4">
29
+ <div class="card jw-card h-100">
30
+ <div class="card-header bg-white fw-semibold">Execution Summary</div>
31
+ <div class="card-body">
32
+ <dl class="row mb-0">
33
+ <dt class="col-sm-4">Job ID</dt>
34
+ <dd class="col-sm-8 jw-mono"><%= @execution.job_id %></dd>
35
+ <dt class="col-sm-4">Queue</dt>
36
+ <dd class="col-sm-8"><%= @execution.queue_name || "-" %></dd>
37
+ <dt class="col-sm-4">Current</dt>
38
+ <dd class="col-sm-8"><%= @execution.current_task_name || "-" %></dd>
39
+ <dt class="col-sm-4">Failed</dt>
40
+ <dd class="col-sm-8"><%= @execution.failed_task_name || "-" %></dd>
41
+ </dl>
42
+ </div>
43
+ </div>
44
+ </div>
45
+
46
+ <div class="col-12 col-xl-8">
47
+ <div class="card jw-card h-100">
48
+ <div class="card-header bg-white fw-semibold">Arguments</div>
49
+ <div class="card-body">
50
+ <pre class="jw-pre"><%= JSON.pretty_generate(@execution.filtered_arguments.as_json) %></pre>
51
+ </div>
52
+ </div>
53
+ </div>
54
+ </div>
55
+
56
+ <div class="card jw-card">
57
+ <div class="card-header bg-white fw-semibold">DAG</div>
58
+ <% if dag_layout[:nodes].any? %>
59
+ <div class="card-body border-bottom" aria-hidden="true">
60
+ <div class="small text-body-secondary mb-3">graph overview</div>
61
+ <div class="jw-dag-viewport">
62
+ <div
63
+ class="jw-dag-canvas"
64
+ style="width: <%= dag_layout[:width] %>px; height: <%= dag_layout[:height] %>px;"
65
+ >
66
+ <svg
67
+ class="jw-dag-svg"
68
+ viewBox="0 0 <%= dag_layout[:width] %> <%= dag_layout[:height] %>"
69
+ preserveAspectRatio="xMinYMin meet"
70
+ >
71
+ <% dag_layout[:edges].each do |edge| %>
72
+ <path class="jw-dag-edge" d="<%= edge[:path] %>"></path>
73
+ <% end %>
74
+ </svg>
75
+
76
+ <% dag_layout[:nodes].each do |task| %>
77
+ <% graph_status_class = case task[:status]
78
+ when :succeeded then "text-bg-success"
79
+ when :failed then "text-bg-danger"
80
+ when :running then "text-bg-primary"
81
+ else "text-bg-secondary"
82
+ end %>
83
+ <div
84
+ class="jw-dag-node"
85
+ style="left: <%= task[:x] %>px; top: <%= task[:y] %>px; width: <%= task[:width] %>px; height: <%= task[:height] %>px;"
86
+ >
87
+ <div class="d-flex flex-column align-items-start gap-1">
88
+ <div class="fw-semibold jw-dag-node-title" title="<%= task[:label] %>"><%= task[:truncated_label] %></div>
89
+ <span class="badge <%= graph_status_class %>"><%= task[:status] %></span>
90
+ </div>
91
+
92
+ <% if task[:meta_label] %>
93
+ <div class="jw-dag-node-meta"><%= task[:meta_label] %></div>
94
+ <% end %>
95
+ </div>
96
+ <% end %>
97
+ </div>
98
+ </div>
99
+ </div>
100
+ <% end %>
101
+ <div class="list-group list-group-flush">
102
+ <% @execution.tasks.each do |task| %>
103
+ <% task_status_class = case task[:status]
104
+ when :succeeded then "text-bg-success"
105
+ when :failed then "text-bg-danger"
106
+ when :running then "text-bg-primary"
107
+ else "text-bg-secondary"
108
+ end %>
109
+ <div class="list-group-item p-4">
110
+ <div class="d-flex flex-column flex-lg-row justify-content-between gap-3">
111
+ <div>
112
+ <div class="d-flex flex-wrap align-items-center gap-2 mb-2">
113
+ <span class="fw-semibold"><%= task[:name] %></span>
114
+ <span class="badge <%= task_status_class %>"><%= task[:status] %></span>
115
+ <% if task[:each] %>
116
+ <span class="badge text-bg-light border text-dark">
117
+ each <%= task[:each_progress][:succeeded] %>/<%= task[:each_progress][:total] %>
118
+ </span>
119
+ <% end %>
120
+ </div>
121
+ <% if task[:depends_on].any? %>
122
+ <div class="text-body-secondary small">depends on <%= task[:depends_on].join(", ") %></div>
123
+ <% end %>
124
+ </div>
125
+
126
+ <% if task[:each] %>
127
+ <div class="text-lg-end">
128
+ <div class="small text-body-secondary mb-1">fan-out progress</div>
129
+ <div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="<%= task[:each_progress][:total] %>" aria-valuenow="<%= task[:each_progress][:succeeded] %>">
130
+ <div
131
+ class="progress-bar"
132
+ style="width: <%= task[:each_progress][:total].positive? ? (task[:each_progress][:succeeded] * 100 / task[:each_progress][:total]) : 0 %>%"
133
+ ></div>
134
+ </div>
135
+ </div>
136
+ <% end %>
137
+ </div>
138
+
139
+ <div class="row g-3 mt-1">
140
+ <div class="col-12 col-xl-6">
141
+ <div class="small text-body-secondary mb-2">configuration</div>
142
+ <pre class="jw-pre"><%= JSON.pretty_generate(task[:configuration].as_json) %></pre>
143
+ </div>
144
+
145
+ <div class="col-12 col-xl-6">
146
+ <div class="small text-body-secondary mb-2">progress</div>
147
+ <pre class="jw-pre"><%= JSON.pretty_generate(task[:each_progress].as_json) %></pre>
148
+ </div>
149
+ </div>
150
+
151
+ <% if task[:outputs].any? %>
152
+ <div class="mt-3">
153
+ <div class="small text-body-secondary mb-2">outputs</div>
154
+ <pre class="jw-pre"><%= JSON.pretty_generate(task[:outputs].as_json) %></pre>
155
+ </div>
156
+ <% end %>
157
+
158
+ <% if Array(task[:sub_task_jobs]).any? %>
159
+ <div class="mt-3">
160
+ <div class="small text-body-secondary mb-2">sub task jobs</div>
161
+ <div class="table-responsive">
162
+ <table class="table table-sm align-middle mb-0">
163
+ <thead>
164
+ <tr>
165
+ <th>Each</th>
166
+ <th>Status</th>
167
+ <th>Job ID</th>
168
+ <th class="text-end">Mission Control</th>
169
+ </tr>
170
+ </thead>
171
+ <tbody>
172
+ <% Array(task[:sub_task_jobs]).each do |sub_task_job| %>
173
+ <% sub_task_status_class = case sub_task_job[:status]
174
+ when :succeeded then "text-bg-success"
175
+ when :failed then "text-bg-danger"
176
+ when :running then "text-bg-primary"
177
+ else "text-bg-secondary"
178
+ end %>
179
+ <tr>
180
+ <td><%= sub_task_job[:each_index] %></td>
181
+ <td><span class="badge <%= sub_task_status_class %>"><%= sub_task_job[:status] %></span></td>
182
+ <td class="jw-mono"><%= sub_task_job[:job_id] %></td>
183
+ <td class="text-end">
184
+ <% if sub_task_job[:mission_control_job_path] %>
185
+ <%= link_to "Open", sub_task_job[:mission_control_job_path], class: "btn btn-sm btn-outline-primary" %>
186
+ <% else %>
187
+ <span class="text-body-secondary">-</span>
188
+ <% end %>
189
+ </td>
190
+ </tr>
191
+ <% end %>
192
+ </tbody>
193
+ </table>
194
+ </div>
195
+ </div>
196
+ <% end %>
197
+ </div>
198
+ <% end %>
199
+ </div>
200
+ </div>
@@ -0,0 +1,39 @@
1
+ <section class="mb-4">
2
+ <h1 class="display-6 fw-semibold mb-2">Workflow Definitions</h1>
3
+ <p class="text-body-secondary mb-0">Root execution only. Select one workflow, then drill into its executions.</p>
4
+ </section>
5
+
6
+ <div class="card jw-card">
7
+ <div class="card-body p-0">
8
+ <% if @workflows.empty? %>
9
+ <div class="p-4 text-body-secondary">No workflows are registered.</div>
10
+ <% else %>
11
+ <div class="table-responsive">
12
+ <table class="table table-hover align-middle mb-0">
13
+ <thead class="table-light">
14
+ <tr>
15
+ <th class="ps-4">Workflow</th>
16
+ <th>Tasks</th>
17
+ <th class="text-end pe-4">Executions</th>
18
+ </tr>
19
+ </thead>
20
+ <tbody>
21
+ <% @workflows.each do |workflow| %>
22
+ <tr>
23
+ <td class="ps-4">
24
+ <div class="fw-semibold"><%= workflow.job_class_name %></div>
25
+ </td>
26
+ <td>
27
+ <span class="badge text-bg-secondary"><%= workflow.task_count %> tasks</span>
28
+ </td>
29
+ <td class="text-end pe-4">
30
+ <%= link_to "View executions", workflow_executions_path(workflow.job_class_name), class: "btn btn-sm btn-primary" %>
31
+ </td>
32
+ </tr>
33
+ <% end %>
34
+ </tbody>
35
+ </table>
36
+ </div>
37
+ <% end %>
38
+ </div>
39
+ </div>
@@ -0,0 +1,117 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>JobWorkflow Monitoring</title>
7
+ <link
8
+ href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css"
9
+ rel="stylesheet"
10
+ integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB"
11
+ crossorigin="anonymous"
12
+ >
13
+ <style>
14
+ :root {
15
+ --jw-surface: #f8f9fa;
16
+ --jw-border: #dee2e6;
17
+ }
18
+
19
+ body {
20
+ background:
21
+ radial-gradient(circle at top right, rgba(13, 110, 253, 0.08), transparent 24rem),
22
+ linear-gradient(180deg, #f8fbff 0%, #f8f9fa 100%);
23
+ }
24
+
25
+ .jw-shell {
26
+ max-width: 1200px;
27
+ }
28
+
29
+ .jw-mono {
30
+ font-family: ui-monospace, SFMono-Regular, SFMono-Regular, Menlo, Consolas, monospace;
31
+ word-break: break-all;
32
+ }
33
+
34
+ .jw-pre {
35
+ background: #212529;
36
+ color: #f8f9fa;
37
+ border-radius: 0.75rem;
38
+ padding: 1rem;
39
+ margin: 0;
40
+ white-space: pre-wrap;
41
+ }
42
+
43
+ .jw-card {
44
+ border-color: var(--jw-border);
45
+ box-shadow: 0 0.5rem 1.5rem rgba(33, 37, 41, 0.06);
46
+ }
47
+
48
+ .jw-dag-viewport {
49
+ overflow-x: auto;
50
+ padding-bottom: 0.25rem;
51
+ }
52
+
53
+ .jw-dag-canvas {
54
+ position: relative;
55
+ min-height: 12rem;
56
+ }
57
+
58
+ .jw-dag-svg {
59
+ position: absolute;
60
+ inset: 0;
61
+ overflow: visible;
62
+ }
63
+
64
+ .jw-dag-edge {
65
+ fill: none;
66
+ stroke: #adb5bd;
67
+ stroke-width: 2;
68
+ stroke-linecap: round;
69
+ stroke-linejoin: round;
70
+ }
71
+
72
+ .jw-dag-node {
73
+ position: absolute;
74
+ display: flex;
75
+ flex-direction: column;
76
+ justify-content: flex-start;
77
+ gap: 0.4rem;
78
+ background: #fff;
79
+ border: 1px solid var(--jw-border);
80
+ border-radius: 0.85rem;
81
+ box-shadow: 0 0.4rem 1rem rgba(33, 37, 41, 0.08);
82
+ padding: 0.75rem 0.875rem;
83
+ }
84
+
85
+ .jw-dag-node-title {
86
+ font-size: 0.875rem;
87
+ line-height: 1.15;
88
+ max-width: 10rem;
89
+ overflow: hidden;
90
+ text-overflow: ellipsis;
91
+ white-space: nowrap;
92
+ }
93
+
94
+ .jw-dag-node-meta {
95
+ color: #6c757d;
96
+ font-size: 0.72rem;
97
+ }
98
+ </style>
99
+ </head>
100
+ <body>
101
+ <nav class="navbar navbar-expand-lg bg-white border-bottom sticky-top">
102
+ <div class="container-fluid jw-shell px-3 px-lg-4">
103
+ <a class="navbar-brand fw-semibold" href="<%= root_path %>">JobWorkflow Monitoring</a>
104
+ </div>
105
+ </nav>
106
+
107
+ <main class="container-fluid jw-shell py-4 py-lg-5 px-3 px-lg-4">
108
+ <%= yield %>
109
+ </main>
110
+
111
+ <script
112
+ src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"
113
+ integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI"
114
+ crossorigin="anonymous"
115
+ ></script>
116
+ </body>
117
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ JobWorkflow::Monitoring::Engine.routes.draw do
4
+ resources :workflows, only: [:index], param: :job_class_name do
5
+ resources :executions, only: %i[index show]
6
+ end
7
+ root "workflows#index"
8
+ end
@@ -18,10 +18,9 @@ task(name, **options, &block)
18
18
  - `depends_on` (Symbol | Array[Symbol]): Dependent tasks
19
19
  - `each` (Proc): Proc that returns an enumerable for map task execution
20
20
  - `enqueue` (Hash | Proc | bool): Controls whether task iterations are enqueued as sub-jobs
21
- - Hash format (recommended): `{ condition: Proc, queue: String, concurrency: Integer }`
21
+ - Hash format (recommended): `{ condition: Proc, queue: String }`
22
22
  - `condition` (Proc | bool): Determines if task should be enqueued (default: true if Hash is not empty)
23
23
  - `queue` (String): Custom queue name for the task (optional)
24
- - `concurrency` (Integer): Concurrency limit for parallel processing (default: unlimited)
25
24
  - Proc format (legacy): Proc that returns boolean
26
25
  - bool format: true/false for simple cases
27
26
  - Default: nil (synchronous execution)
@@ -75,7 +74,8 @@ end
75
74
  # Parallel processing with collection
76
75
  task :process_items,
77
76
  each: ->(ctx) { ctx.arguments.items },
78
- enqueue: { concurrency: 5 },
77
+ enqueue: true,
78
+ throttle: 5,
79
79
  output: { result: "String" } do |ctx|
80
80
  item = ctx.each_value
81
81
  { result: ProcessService.handle(item) }
@@ -187,7 +187,8 @@ class ImportJob < ApplicationJob
187
187
 
188
188
  task :process,
189
189
  each: ->(ctx) { ctx.arguments.items },
190
- enqueue: { concurrency: 5 },
190
+ enqueue: true,
191
+ throttle: 5,
191
192
  output: { result: "String" } do |ctx|
192
193
  { result: handle(ctx.each_value) }
193
194
  end
@@ -211,7 +212,8 @@ class BatchImportJob < ApplicationJob
211
212
 
212
213
  task :process,
213
214
  each: ->(ctx) { ctx.arguments.items },
214
- enqueue: { concurrency: 5 },
215
+ enqueue: true,
216
+ throttle: 5,
215
217
  output: { result: "String" } do |ctx|
216
218
  { result: handle(ctx.each_value) }
217
219
  end
@@ -228,7 +230,8 @@ argument :items, "Array[String]"
228
230
 
229
231
  task :process_items,
230
232
  each: ->(ctx) { ctx.arguments.items },
231
- enqueue: { concurrency: 5 },
233
+ enqueue: true,
234
+ throttle: 5,
232
235
  output: { result: "String", status: "Symbol" } do |ctx|
233
236
  item = ctx.each_value
234
237
  {
@@ -14,7 +14,8 @@ class ExampleJob < ApplicationJob
14
14
 
15
15
  task :process_items,
16
16
  each: ->(ctx) { ctx.arguments.items },
17
- enqueue: { concurrency: 5 },
17
+ enqueue: true,
18
+ throttle: 5,
18
19
  output: { result: "Integer" } do |ctx|
19
20
  # This creates many sub-jobs
20
21
  { result: ctx.each_value * 2 }
@@ -172,7 +173,8 @@ class DataPipelineJob < ApplicationJob
172
173
  # Extract data from multiple sources in parallel
173
174
  task :extract_data,
174
175
  each: ->(ctx) { %w[users orders products inventory] },
175
- enqueue: { concurrency: 4 },
176
+ enqueue: true,
177
+ throttle: 4,
176
178
  output: { source: "String", count: "Integer" } do |ctx|
177
179
  source = ctx.each_value
178
180
  data = DataSource.fetch(source, date: ctx.arguments.date)
@@ -210,10 +212,11 @@ class APIAggregatorJob < ApplicationJob
210
212
 
211
213
  argument :user_ids, "Array[Integer]"
212
214
 
213
- # Fetch user data with rate limiting
215
+ # Fetch user data with rate limiting.
216
+ # Async fan-out is unbounded here; the official execution cap is throttle: 5.
214
217
  task :fetch_users,
215
218
  each: ->(ctx) { ctx.arguments.user_ids },
216
- enqueue: { concurrency: 10 },
219
+ enqueue: true,
217
220
  throttle: { key: "external_api", limit: 5 },
218
221
  output: { user_id: "Integer", data: "Hash" } do |ctx|
219
222
  user_id = ctx.each_value
@@ -270,7 +273,8 @@ dependency_wait: { poll_timeout: 60, reschedule_delay: 10 }
270
273
  # ✅ Good: dependency_wait with parallel sub-jobs
271
274
  task :process,
272
275
  each: ->(ctx) { ctx.arguments.items },
273
- enqueue: { concurrency: 10 } do |ctx|
276
+ enqueue: true,
277
+ throttle: 10 do |ctx|
274
278
  heavy_process(ctx.each_value)
275
279
  end
276
280