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
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/parameter_filter"
4
+
5
+ module JobWorkflow
6
+ module Monitoring
7
+ class ParameterFilter
8
+ class << self
9
+ #: (untyped) -> untyped
10
+ def filter(value)
11
+ case value
12
+ when Hash
13
+ parameter_filter.filter(value)
14
+ when Array
15
+ value.map { |item| filter(item) }
16
+ else
17
+ value
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ #: () -> ActiveSupport::ParameterFilter
24
+ def parameter_filter
25
+ ActiveSupport::ParameterFilter.new(filters)
26
+ end
27
+
28
+ #: () -> Array[untyped]
29
+ def filters
30
+ return [] unless defined?(Rails.application) && Rails.application.config.respond_to?(:filter_parameters)
31
+
32
+ Rails.application.config.filter_parameters
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JobWorkflow
4
+ module Monitoring
5
+ class WorkflowDefinition
6
+ attr_reader :job_class #: singleton(DSL)
7
+
8
+ #: (job_class: singleton(DSL)) -> void
9
+ def initialize(job_class:)
10
+ @job_class = job_class
11
+ end
12
+
13
+ #: () -> String
14
+ def job_class_name
15
+ job_class.name
16
+ end
17
+
18
+ #: () -> Integer
19
+ def task_count
20
+ job_class._workflow.tasks.size
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JobWorkflow
4
+ module Monitoring
5
+ class WorkflowRegistry
6
+ class << self
7
+ #: () -> Array[singleton(DSL)]
8
+ def all
9
+ DSL._included_classes.to_a
10
+ .reverse
11
+ .select(&:name)
12
+ .uniq(&:name)
13
+ .reverse
14
+ .sort_by(&:name)
15
+ end
16
+
17
+ #: (String) -> singleton(DSL)?
18
+ def find(job_class_name)
19
+ all.find { |job_class| job_class.name == job_class_name }
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "monitoring/workflow_registry"
4
+ require_relative "monitoring/workflow_definition"
5
+ require_relative "monitoring/execution_page"
6
+ require_relative "monitoring/dag_layout"
7
+ require_relative "monitoring/parameter_filter"
8
+ require_relative "monitoring/execution_view_model"
9
+ require_relative "monitoring/execution_registry"
10
+
11
+ module JobWorkflow
12
+ module Monitoring
13
+ mattr_accessor :base_controller_class
14
+
15
+ class << self
16
+ #: () -> Array[WorkflowDefinition]
17
+ def workflows
18
+ WorkflowRegistry.all.map { |job_class| WorkflowDefinition.new(job_class:) }
19
+ end
20
+
21
+ #: (String?, Symbol?) -> String?
22
+ def mission_control_job_path(job_id, status: nil)
23
+ return if job_id.nil?
24
+
25
+ path_template = mission_control_job_route_path
26
+ return if path_template.nil?
27
+
28
+ path = path_template.sub(":id", job_id.to_s)
29
+ path += "#error" if status == :failed
30
+ path
31
+ end
32
+
33
+ #: () -> String
34
+ def resolved_base_controller_class
35
+ return base_controller_class if base_controller_class.present?
36
+
37
+ mission_control_base_controller_class = defined?(MissionControl::Jobs) &&
38
+ MissionControl::Jobs.base_controller_class
39
+ return mission_control_base_controller_class if mission_control_base_controller_class.present?
40
+
41
+ "::ApplicationController"
42
+ end
43
+
44
+ #: (untyped config) -> void
45
+ def configure_engine_config(config)
46
+ config.job_workflow = ActiveSupport::OrderedOptions.new unless config.try(:job_workflow)
47
+ config.job_workflow.monitoring ||= ActiveSupport::OrderedOptions.new
48
+
49
+ config.before_initialize do
50
+ config.job_workflow.monitoring.each do |key, value|
51
+ JobWorkflow::Monitoring.public_send("#{key}=", value)
52
+ end
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ #: () -> String?
59
+ def mission_control_job_route_path
60
+ application_id = mission_control_application_id
61
+ route_name = application_id ? "application_job" : "job"
62
+ mount_path = mission_control_mount_path
63
+ return if mount_path.nil?
64
+
65
+ path = engine_route_path(route_name)
66
+ return if path.nil?
67
+
68
+ combined_path = "#{mount_path.chomp("/")}#{path}"
69
+ application_id ? combined_path.sub(":application_id", application_id.to_s) : combined_path
70
+ end
71
+
72
+ #: () -> String?
73
+ def mission_control_application_id
74
+ return unless defined?(MissionControl::Jobs)
75
+
76
+ MissionControl::Jobs.applications.first&.id
77
+ end
78
+
79
+ #: () -> String?
80
+ def mission_control_mount_path
81
+ path = rails_routes&.find { |candidate| candidate.name == "mission_control_jobs" }&.path
82
+ normalized_route_path(path)
83
+ end
84
+
85
+ #: (String) -> String?
86
+ def engine_route_path(route_name)
87
+ path = mission_control_routes&.find { |candidate| candidate.name == route_name }&.path
88
+ normalized_route_path(path)
89
+ end
90
+
91
+ #: (untyped) -> String?
92
+ def normalized_route_path(path)
93
+ return unless path.respond_to?(:spec)
94
+
95
+ path_spec = path.spec.to_s
96
+ return unless path_spec.is_a?(String) && !path_spec.empty?
97
+
98
+ path_spec.delete_suffix("(.:format)")
99
+ end
100
+
101
+ #: () -> untyped
102
+ def mission_control_routes
103
+ return unless defined?(MissionControl::Jobs::Engine)
104
+
105
+ MissionControl::Jobs::Engine.routes.routes
106
+ end
107
+
108
+ #: () -> untyped
109
+ def rails_routes
110
+ return unless defined?(Rails.application)
111
+ return unless Rails.application.respond_to?(:routes)
112
+
113
+ routes = Rails.application.routes
114
+ return unless routes.respond_to?(:routes)
115
+
116
+ routes.routes
117
+ end
118
+ end
119
+ end
120
+ end
@@ -77,17 +77,22 @@ module JobWorkflow
77
77
  raise NotImplementedError, "#{self.class}#find_job must be implemented"
78
78
  end
79
79
 
80
+ #: (job_class_name: String, limit: Integer, cursor: String?) -> Hash[Symbol, untyped]
81
+ def fetch_root_workflow_job_page(job_class_name:, limit:, cursor:)
82
+ raise NotImplementedError, "#{self.class}#fetch_root_workflow_job_page must be implemented"
83
+ end
84
+
80
85
  #: (Array[String]) -> Array[Hash[String, untyped]]
81
86
  def fetch_job_contexts(_job_ids)
82
87
  raise NotImplementedError, "#{self.class}#fetch_job_contexts must be implemented"
83
88
  end
84
89
 
85
- #: (DSL, Numeric) -> bool
90
+ #: (_JobInterface, Numeric) -> bool
86
91
  def reschedule_job(_job, _wait)
87
92
  false
88
93
  end
89
94
 
90
- #: (DSL) -> void
95
+ #: (_JobInterface) -> void
91
96
  def persist_job_context(_job); end
92
97
  end
93
98
  # rubocop:enable Naming/PredicateMethod
@@ -90,12 +90,23 @@ module JobWorkflow
90
90
  @stored_jobs[job_id]
91
91
  end
92
92
 
93
+ #: (job_class_name: String, limit: Integer, cursor: String?) -> Hash[Symbol, untyped]
94
+ def fetch_root_workflow_job_page(job_class_name:, limit:, cursor:)
95
+ jobs = @stored_jobs.values.select { |job| job["class_name"] == job_class_name }.reverse
96
+ offset = cursor.nil? ? 0 : cursor.to_i
97
+ page_jobs = jobs.slice(offset, limit + 1) || []
98
+ {
99
+ jobs: page_jobs.first(limit),
100
+ next_cursor: page_jobs.size > limit ? (offset + limit).to_s : nil
101
+ }
102
+ end
103
+
93
104
  #: (Array[String]) -> Array[Hash[String, untyped]]
94
105
  def fetch_job_contexts(_job_ids)
95
106
  []
96
107
  end
97
108
 
98
- #: (DSL, Numeric) -> bool
109
+ #: (_JobInterface, Numeric) -> bool
99
110
  def reschedule_job(_job, _wait)
100
111
  false
101
112
  end
@@ -160,15 +160,17 @@ module JobWorkflow
160
160
  job = without_query_cache { SolidQueue::Job.find_by(active_job_id: job_id) }
161
161
  return if job.nil?
162
162
 
163
- args = job.arguments
164
- {
165
- "job_id" => job.active_job_id,
166
- "class_name" => job.class_name,
167
- "queue_name" => job.queue_name,
168
- "arguments" => args.is_a?(Hash) ? args["arguments"] : args,
169
- "job_workflow_context" => args.is_a?(Hash) ? args["job_workflow_context"] : nil,
170
- "status" => job_status(job)
171
- }
163
+ normalized_job_data(job)
164
+ end
165
+
166
+ #: (job_class_name: String, limit: Integer, cursor: String?) -> Hash[Symbol, untyped]
167
+ def fetch_root_workflow_job_page(job_class_name:, limit:, cursor:)
168
+ return { jobs: [], next_cursor: nil } unless defined?(SolidQueue::Job)
169
+
170
+ without_query_cache do
171
+ jobs = root_jobs_relation(job_class_name:, cursor:).limit(limit + 1).to_a
172
+ build_page(jobs, limit:)
173
+ end
172
174
  end
173
175
 
174
176
  # @note
@@ -186,7 +188,7 @@ module JobWorkflow
186
188
  end
187
189
  end
188
190
 
189
- #: (DSL, Numeric) -> bool
191
+ #: (_JobInterface, Numeric) -> bool
190
192
  def reschedule_job(job, wait)
191
193
  return false unless defined?(SolidQueue::Job)
192
194
 
@@ -204,7 +206,7 @@ module JobWorkflow
204
206
  # outputs computed during job execution would be lost because
205
207
  # SolidQueue does not re-serialize job arguments after perform.
206
208
  #
207
- #: (DSL) -> void
209
+ #: (_JobInterface) -> void
208
210
  def persist_job_context(job)
209
211
  return unless defined?(SolidQueue::Job)
210
212
 
@@ -229,7 +231,35 @@ module JobWorkflow
229
231
  defined?(SolidQueue::Job) ? SolidQueue::Job.uncached(&) : yield
230
232
  end
231
233
 
232
- #: (SolidQueue::Job, DSL, Numeric) -> bool
234
+ #: (SolidQueue::Job) -> Hash[String, untyped]
235
+ def normalized_job_data(job)
236
+ args = job.arguments
237
+ {
238
+ "job_id" => job.active_job_id,
239
+ "class_name" => job.class_name,
240
+ "queue_name" => job.queue_name,
241
+ "arguments" => args.is_a?(Hash) ? args["arguments"] : args,
242
+ "job_workflow_context" => args.is_a?(Hash) ? args["job_workflow_context"] : nil,
243
+ "status" => job_status(job)
244
+ }
245
+ end
246
+
247
+ #: (job_class_name: String, cursor: String?) -> untyped
248
+ def root_jobs_relation(job_class_name:, cursor:)
249
+ relation = SolidQueue::Job.where(class_name: job_class_name)
250
+ relation = relation.where("id < ?", cursor.to_i) unless cursor.nil?
251
+ relation.order(id: :desc)
252
+ end
253
+
254
+ #: (Array[SolidQueue::Job], limit: Integer) -> Hash[Symbol, untyped]
255
+ def build_page(jobs, limit:)
256
+ {
257
+ jobs: jobs.first(limit).map { |job| normalized_job_data(job) },
258
+ next_cursor: jobs.size > limit ? jobs[limit - 1].id.to_s : nil
259
+ }
260
+ end
261
+
262
+ #: (SolidQueue::Job, _JobInterface, Numeric) -> bool
233
263
  def reschedule_solid_queue_job(solid_queue_job, active_job, wait)
234
264
  solid_queue_job.with_lock do
235
265
  solid_queue_job.claimed_execution&.destroy!
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "monitoring/engine"
4
+
5
+ module JobWorkflow
6
+ class Railtie < Rails::Railtie
7
+ config.after_initialize do
8
+ JobWorkflow::QueueAdapter.reset!
9
+ JobWorkflow::QueueAdapter.current.initialize_adapter!
10
+ end
11
+ end
12
+ end
@@ -14,7 +14,7 @@ module JobWorkflow
14
14
  def run
15
15
  task = context._task_context.task
16
16
  if !task.nil? && context.sub_job?
17
- run_task(task)
17
+ job.step(task.task_name) { |step| run_task(task, step:) }
18
18
  persist_current_job_context
19
19
  return
20
20
  end
@@ -25,11 +25,11 @@ module JobWorkflow
25
25
 
26
26
  private
27
27
 
28
- attr_reader :job #: DSL
28
+ attr_reader :job #: _JobInterface
29
29
 
30
30
  #: () -> Workflow
31
31
  def workflow
32
- job.class._workflow
32
+ context.workflow
33
33
  end
34
34
 
35
35
  #: () -> Array[Task]
@@ -63,25 +63,46 @@ module JobWorkflow
63
63
  result
64
64
  end
65
65
 
66
- #: (Task, ?step: ActiveJob::Continuation::Step?) -> void
67
- def run_task(task, step: nil)
66
+ #: (Task, step: ActiveJob::Continuation::Step) -> void
67
+ def run_task(task, step:) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
68
68
  context._load_parent_task_output
69
- context._with_each_value(task, start_index: step&.cursor).each do |ctx|
70
- run_each_task(task, ctx)
71
- step&.set!(ctx._task_context.index + 1)
69
+ start_index, task_cursor = decode_task_cursor(task, step.cursor)
70
+
71
+ context._with_each_value(task, start_index:).each do |ctx|
72
+ iteration_cursor = task_cursor
73
+ iteration_cursor = nil if task.each? && (task_cursor.nil? || start_index != ctx._task_context.index)
74
+
75
+ run_each_task(task, ctx, step:, cursor: iteration_cursor)
76
+ step.set!(ctx._task_context.index + 1) if task.each?
72
77
  rescue StandardError => e
73
78
  run_error_hooks(task, ctx, e)
74
79
  raise
75
80
  end
76
81
  end
77
82
 
78
- #: (Task, Context) -> void
79
- def run_each_task(task, ctx)
83
+ #: (Task, untyped) -> [Integer?, untyped]
84
+ def decode_task_cursor(task, task_cursor)
85
+ return [nil, task_cursor] unless task.each?
86
+ return [task_cursor, nil] if task_cursor.is_a?(Integer)
87
+
88
+ if task_cursor.is_a?(Hash) && task_cursor[Context::EACH_TASK_CURSOR_MARKER]
89
+ return [task_cursor.fetch("index"), task_cursor.fetch("cursor")]
90
+ end
91
+
92
+ raise "invalid each task cursor: #{task_cursor.inspect}" unless task_cursor.nil?
93
+
94
+ [nil, nil]
95
+ end
96
+
97
+ #: (Task, Context, step: ActiveJob::Continuation::Step, ?cursor: untyped) -> void
98
+ def run_each_task(task, ctx, step:, cursor: nil)
80
99
  Instrumentation.instrument_task(job, task, ctx) do
81
- ctx._with_task_throttle do
82
- run_hooks(task, ctx) do
83
- data = task.block.call(ctx)
84
- add_task_output(ctx:, task:, each_index: ctx._task_context.index, data:)
100
+ ctx._with_current_step(step, cursor:) do
101
+ ctx._with_task_throttle do
102
+ run_hooks(task, ctx) do
103
+ data = task.block.call(ctx)
104
+ add_task_output(ctx:, task:, each_index: ctx._task_context.index, data:)
105
+ end
85
106
  end
86
107
  end
87
108
  end
@@ -112,7 +133,9 @@ module JobWorkflow
112
133
 
113
134
  #: (Task) -> void
114
135
  def enqueue_task(task)
115
- sub_jobs = context._with_each_value(task).map { |ctx| job.class.from_context(ctx) }
136
+ sub_jobs = context._with_each_value(task).map do |ctx|
137
+ SubTaskJob.from_parent_context(context: ctx)
138
+ end
116
139
  persist_current_job_context
117
140
  ActiveJob.perform_all_later(sub_jobs)
118
141
  context.job_status.update_task_job_statuses_from_jobs(task_name: task.task_name, jobs: sub_jobs)
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JobWorkflow
4
+ class SubTaskJob < ActiveJob::Base
5
+ include ActiveJob::Continuable
6
+
7
+ class << self
8
+ #: (context: Context) -> SubTaskJob
9
+ def from_parent_context(context:)
10
+ validate_sub_task_context!(context)
11
+
12
+ new_context = context.dup
13
+ job = new(context.arguments.to_h)
14
+ new_context._job = job
15
+ job._context = new_context
16
+ task = new_context._task_context.task || (raise "task is not set")
17
+ return job if task.enqueue.queue.nil?
18
+
19
+ job.set(queue: task.enqueue.queue)
20
+ end
21
+
22
+ private
23
+
24
+ #: (Context) -> void
25
+ def validate_sub_task_context!(context)
26
+ task_context = context._task_context
27
+ raise ArgumentError, "task_context.task is required" if task_context.task.nil?
28
+ raise ArgumentError, "task_context.parent_job_id is required" if task_context.parent_job_id.nil?
29
+ end
30
+ end
31
+
32
+ #: (Hash[untyped, untyped]) -> void
33
+ def perform(arguments)
34
+ payload = arguments.symbolize_keys
35
+ self._context = build_context(payload)
36
+ Runner.new(context: _context).run
37
+ end
38
+
39
+ #: () -> Output
40
+ def output
41
+ context = _context
42
+ raise "context is not set." if context.nil?
43
+
44
+ context.output
45
+ end
46
+
47
+ attr_accessor :_context #: Context?
48
+
49
+ #: () -> Hash[String, untyped]
50
+ def serialize
51
+ super.merge({ "job_workflow_context" => _context&.serialize || serialized_job_workflow_context }.compact)
52
+ end
53
+
54
+ #: (Hash[String, untyped]) -> void
55
+ def deserialize(job_data)
56
+ super
57
+ self.serialized_job_workflow_context = job_data["job_workflow_context"]
58
+ end
59
+
60
+ private
61
+
62
+ attr_accessor :serialized_job_workflow_context #: Hash[String, untyped]?
63
+
64
+ #: (Hash[Symbol, untyped]) -> Context
65
+ def build_context(payload)
66
+ context_data = extract_context_data(payload)
67
+ parent_job_id = context_data.fetch("task_context").fetch("parent_job_id")
68
+ parent_job_data = QueueAdapter.current.find_job(parent_job_id)
69
+ raise WorkflowStatus::NotFoundError, "Workflow with job_id '#{parent_job_id}' not found" if parent_job_data.nil?
70
+
71
+ workflow = resolve_workflow(parent_job_data.fetch("class_name"))
72
+ Context.deserialize(context_data.merge("job" => self, "workflow" => workflow))
73
+ ._update_arguments(payload.except(:job_workflow_context))
74
+ end
75
+
76
+ #: (job_class_name: String) -> Workflow
77
+ def resolve_workflow(job_class_name)
78
+ job_class = JobWorkflow::DSL._included_classes.to_a.reverse.find { |klass| klass.name == job_class_name }
79
+ job_class ||= job_class_name.safe_constantize
80
+ raise NameError, "uninitialized constant #{job_class_name}" if job_class.nil?
81
+
82
+ job_class._workflow
83
+ end
84
+
85
+ #: (Hash[Symbol, untyped]) -> Hash[String, untyped]
86
+ def extract_context_data(payload)
87
+ context_data = serialized_job_workflow_context || payload[:job_workflow_context]
88
+ raise "job_workflow_context is not set." if context_data.nil?
89
+
90
+ context_data.deep_stringify_keys
91
+ end
92
+ end
93
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module JobWorkflow
4
4
  class Task
5
+ DEFAULT_EACH = ->(_ctx) { [nil] }
6
+
5
7
  attr_reader :job_name #: String
6
8
  attr_reader :block #: ^(untyped) -> void
7
9
  attr_reader :each #: ^(Context) -> untyped
@@ -72,6 +74,11 @@ module JobWorkflow
72
74
  "#{job_name}:#{task_name}"
73
75
  end
74
76
 
77
+ #: () -> bool
78
+ def each?
79
+ !each.nil? && !each.equal?(DEFAULT_EACH)
80
+ end
81
+
75
82
  private
76
83
 
77
84
  attr_reader :name #: Symbol
@@ -4,7 +4,6 @@ module JobWorkflow
4
4
  class TaskEnqueue
5
5
  attr_reader :condition #: true | false | ^(Context) -> bool
6
6
  attr_reader :queue #: String?
7
- attr_reader :concurrency #: Integer?
8
7
 
9
8
  class << self
10
9
  #: (true | false | ^(Context) -> bool | Hash[Symbol, untyped] | nil) -> TaskEnqueue
@@ -13,26 +12,39 @@ module JobWorkflow
13
12
  when TrueClass, FalseClass, Proc
14
13
  new(condition: value)
15
14
  when Hash
15
+ validate_hash_keys!(value)
16
+
16
17
  new(
17
18
  condition: value.fetch(:condition, !value.empty?),
18
- queue: value[:queue],
19
- concurrency: value[:concurrency]
19
+ queue: value[:queue]
20
20
  )
21
21
  else
22
22
  new
23
23
  end
24
24
  end
25
+
26
+ private
27
+
28
+ #: (Hash[Symbol, untyped]) -> void
29
+ def validate_hash_keys!(value)
30
+ unsupported_keys = value.keys - %i[condition queue]
31
+ return if unsupported_keys.empty?
32
+
33
+ if unsupported_keys == %i[concurrency]
34
+ raise ArgumentError, "enqueue does not support :concurrency; use throttle instead"
35
+ end
36
+
37
+ raise ArgumentError, "enqueue supports only :condition and :queue"
38
+ end
25
39
  end
26
40
 
27
41
  #: (
28
42
  # ?condition: true | false | ^(Context) -> bool,
29
- # ?queue: String?,
30
- # ?concurrency: Integer?
43
+ # ?queue: String?
31
44
  # ) -> void
32
- def initialize(condition: false, queue: nil, concurrency: nil)
45
+ def initialize(condition: false, queue: nil)
33
46
  @condition = condition
34
47
  @queue = queue
35
- @concurrency = concurrency
36
48
  end
37
49
 
38
50
  #: (Context) -> bool
@@ -41,10 +53,5 @@ module JobWorkflow
41
53
 
42
54
  !!condition
43
55
  end
44
-
45
- #: () -> bool
46
- def should_limits_concurrency?
47
- !!condition && !concurrency.nil? && QueueAdapter.current.supports_concurrency_limits?
48
- end
49
56
  end
50
57
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module JobWorkflow
4
- VERSION = "0.4.0" # : String
4
+ VERSION = "0.6.0" # : String
5
5
  end
@@ -24,6 +24,7 @@ module JobWorkflow
24
24
  def find_by(job_id:)
25
25
  data = QueueAdapter.current.find_job(job_id)
26
26
  return if data.nil?
27
+ return if data["class_name"] == JobWorkflow::SubTaskJob.name
27
28
 
28
29
  WorkflowStatus.from_job_data(data)
29
30
  end
@@ -33,15 +34,33 @@ module JobWorkflow
33
34
  job_class_name = data["class_name"]
34
35
  job_class = job_class_name.constantize
35
36
  workflow = job_class._workflow
37
+ context = context_from_job_data(data, workflow)
36
38
 
39
+ new(context:, job_class_name:, status: data["status"])
40
+ end
41
+
42
+ private
43
+
44
+ #: (Hash[String, untyped], Workflow) -> Context
45
+ def context_from_job_data(data, workflow)
37
46
  context_data = data["job_workflow_context"] || data["arguments"]&.first&.dig("job_workflow_context")
38
47
  context = if context_data
39
48
  Context.deserialize(context_data.merge("workflow" => workflow))
40
49
  else
41
50
  Context.from_hash({ workflow: })
42
51
  end
52
+ serialized_arguments = workflow_arguments_data(data)
53
+ return context if serialized_arguments.nil?
43
54
 
44
- new(context:, job_class_name:, status: data["status"])
55
+ context._update_arguments(ActiveJob::Arguments.deserialize([serialized_arguments]).first)
56
+ end
57
+
58
+ #: (Hash[String, untyped]) -> Hash[String, untyped]?
59
+ def workflow_arguments_data(data)
60
+ serialized_arguments = data["arguments"]&.first
61
+ return if serialized_arguments.nil? || serialized_arguments.key?("job_workflow_context")
62
+
63
+ serialized_arguments
45
64
  end
46
65
  end
47
66