job-workflow 0.1.3

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 (132) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +91 -0
  4. data/CHANGELOG.md +23 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +47 -0
  7. data/Rakefile +55 -0
  8. data/Steepfile +10 -0
  9. data/guides/API_REFERENCE.md +112 -0
  10. data/guides/BEST_PRACTICES.md +113 -0
  11. data/guides/CACHE_STORE_INTEGRATION.md +145 -0
  12. data/guides/CONDITIONAL_EXECUTION.md +66 -0
  13. data/guides/DEPENDENCY_WAIT.md +386 -0
  14. data/guides/DRY_RUN.md +390 -0
  15. data/guides/DSL_BASICS.md +216 -0
  16. data/guides/ERROR_HANDLING.md +187 -0
  17. data/guides/GETTING_STARTED.md +524 -0
  18. data/guides/INSTRUMENTATION.md +131 -0
  19. data/guides/LIFECYCLE_HOOKS.md +415 -0
  20. data/guides/NAMESPACES.md +75 -0
  21. data/guides/OPENTELEMETRY_INTEGRATION.md +86 -0
  22. data/guides/PARALLEL_PROCESSING.md +302 -0
  23. data/guides/PRODUCTION_DEPLOYMENT.md +110 -0
  24. data/guides/QUEUE_MANAGEMENT.md +141 -0
  25. data/guides/README.md +174 -0
  26. data/guides/SCHEDULED_JOBS.md +165 -0
  27. data/guides/STRUCTURED_LOGGING.md +268 -0
  28. data/guides/TASK_OUTPUTS.md +240 -0
  29. data/guides/TESTING_STRATEGY.md +56 -0
  30. data/guides/THROTTLING.md +198 -0
  31. data/guides/TROUBLESHOOTING.md +53 -0
  32. data/guides/WORKFLOW_COMPOSITION.md +675 -0
  33. data/guides/WORKFLOW_STATUS_QUERY.md +288 -0
  34. data/lib/job-workflow.rb +3 -0
  35. data/lib/job_workflow/argument_def.rb +16 -0
  36. data/lib/job_workflow/arguments.rb +40 -0
  37. data/lib/job_workflow/auto_scaling/adapter/aws_adapter.rb +66 -0
  38. data/lib/job_workflow/auto_scaling/adapter.rb +31 -0
  39. data/lib/job_workflow/auto_scaling/configuration.rb +85 -0
  40. data/lib/job_workflow/auto_scaling/executor.rb +43 -0
  41. data/lib/job_workflow/auto_scaling.rb +69 -0
  42. data/lib/job_workflow/cache_store_adapters.rb +46 -0
  43. data/lib/job_workflow/context.rb +352 -0
  44. data/lib/job_workflow/dry_run_config.rb +31 -0
  45. data/lib/job_workflow/dsl.rb +236 -0
  46. data/lib/job_workflow/error_hook.rb +24 -0
  47. data/lib/job_workflow/hook.rb +24 -0
  48. data/lib/job_workflow/hook_registry.rb +66 -0
  49. data/lib/job_workflow/instrumentation/log_subscriber.rb +194 -0
  50. data/lib/job_workflow/instrumentation/opentelemetry_subscriber.rb +221 -0
  51. data/lib/job_workflow/instrumentation.rb +257 -0
  52. data/lib/job_workflow/job_status.rb +92 -0
  53. data/lib/job_workflow/logger.rb +86 -0
  54. data/lib/job_workflow/namespace.rb +36 -0
  55. data/lib/job_workflow/output.rb +81 -0
  56. data/lib/job_workflow/output_def.rb +14 -0
  57. data/lib/job_workflow/queue.rb +74 -0
  58. data/lib/job_workflow/queue_adapter.rb +38 -0
  59. data/lib/job_workflow/queue_adapters/abstract.rb +87 -0
  60. data/lib/job_workflow/queue_adapters/null_adapter.rb +127 -0
  61. data/lib/job_workflow/queue_adapters/solid_queue_adapter.rb +224 -0
  62. data/lib/job_workflow/runner.rb +173 -0
  63. data/lib/job_workflow/schedule.rb +46 -0
  64. data/lib/job_workflow/semaphore.rb +71 -0
  65. data/lib/job_workflow/task.rb +83 -0
  66. data/lib/job_workflow/task_callable.rb +43 -0
  67. data/lib/job_workflow/task_context.rb +70 -0
  68. data/lib/job_workflow/task_dependency_wait.rb +66 -0
  69. data/lib/job_workflow/task_enqueue.rb +50 -0
  70. data/lib/job_workflow/task_graph.rb +43 -0
  71. data/lib/job_workflow/task_job_status.rb +70 -0
  72. data/lib/job_workflow/task_output.rb +51 -0
  73. data/lib/job_workflow/task_retry.rb +64 -0
  74. data/lib/job_workflow/task_throttle.rb +46 -0
  75. data/lib/job_workflow/version.rb +5 -0
  76. data/lib/job_workflow/workflow.rb +87 -0
  77. data/lib/job_workflow/workflow_status.rb +112 -0
  78. data/lib/job_workflow.rb +59 -0
  79. data/rbs_collection.lock.yaml +172 -0
  80. data/rbs_collection.yaml +14 -0
  81. data/sig/generated/job-workflow.rbs +2 -0
  82. data/sig/generated/job_workflow/argument_def.rbs +14 -0
  83. data/sig/generated/job_workflow/arguments.rbs +26 -0
  84. data/sig/generated/job_workflow/auto_scaling/adapter/aws_adapter.rbs +32 -0
  85. data/sig/generated/job_workflow/auto_scaling/adapter.rbs +22 -0
  86. data/sig/generated/job_workflow/auto_scaling/configuration.rbs +50 -0
  87. data/sig/generated/job_workflow/auto_scaling/executor.rbs +29 -0
  88. data/sig/generated/job_workflow/auto_scaling.rbs +47 -0
  89. data/sig/generated/job_workflow/cache_store_adapters.rbs +28 -0
  90. data/sig/generated/job_workflow/context.rbs +155 -0
  91. data/sig/generated/job_workflow/dry_run_config.rbs +16 -0
  92. data/sig/generated/job_workflow/dsl.rbs +117 -0
  93. data/sig/generated/job_workflow/error_hook.rbs +18 -0
  94. data/sig/generated/job_workflow/hook.rbs +18 -0
  95. data/sig/generated/job_workflow/hook_registry.rbs +47 -0
  96. data/sig/generated/job_workflow/instrumentation/log_subscriber.rbs +102 -0
  97. data/sig/generated/job_workflow/instrumentation/opentelemetry_subscriber.rbs +113 -0
  98. data/sig/generated/job_workflow/instrumentation.rbs +138 -0
  99. data/sig/generated/job_workflow/job_status.rbs +46 -0
  100. data/sig/generated/job_workflow/logger.rbs +56 -0
  101. data/sig/generated/job_workflow/namespace.rbs +24 -0
  102. data/sig/generated/job_workflow/output.rbs +39 -0
  103. data/sig/generated/job_workflow/output_def.rbs +12 -0
  104. data/sig/generated/job_workflow/queue.rbs +49 -0
  105. data/sig/generated/job_workflow/queue_adapter.rbs +18 -0
  106. data/sig/generated/job_workflow/queue_adapters/abstract.rbs +56 -0
  107. data/sig/generated/job_workflow/queue_adapters/null_adapter.rbs +73 -0
  108. data/sig/generated/job_workflow/queue_adapters/solid_queue_adapter.rbs +111 -0
  109. data/sig/generated/job_workflow/runner.rbs +66 -0
  110. data/sig/generated/job_workflow/schedule.rbs +34 -0
  111. data/sig/generated/job_workflow/semaphore.rbs +37 -0
  112. data/sig/generated/job_workflow/task.rbs +60 -0
  113. data/sig/generated/job_workflow/task_callable.rbs +30 -0
  114. data/sig/generated/job_workflow/task_context.rbs +52 -0
  115. data/sig/generated/job_workflow/task_dependency_wait.rbs +42 -0
  116. data/sig/generated/job_workflow/task_enqueue.rbs +27 -0
  117. data/sig/generated/job_workflow/task_graph.rbs +27 -0
  118. data/sig/generated/job_workflow/task_job_status.rbs +42 -0
  119. data/sig/generated/job_workflow/task_output.rbs +29 -0
  120. data/sig/generated/job_workflow/task_retry.rbs +30 -0
  121. data/sig/generated/job_workflow/task_throttle.rbs +20 -0
  122. data/sig/generated/job_workflow/version.rbs +5 -0
  123. data/sig/generated/job_workflow/workflow.rbs +48 -0
  124. data/sig/generated/job_workflow/workflow_status.rbs +55 -0
  125. data/sig/generated/job_workflow.rbs +8 -0
  126. data/sig-private/activejob.rbs +35 -0
  127. data/sig-private/activesupport.rbs +23 -0
  128. data/sig-private/aws.rbs +32 -0
  129. data/sig-private/opentelemetry.rbs +40 -0
  130. data/sig-private/solid_queue.rbs +108 -0
  131. data/tmp/.keep +0 -0
  132. metadata +190 -0
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JobWorkflow
4
+ module QueueAdapters
5
+ # rubocop:disable Naming/PredicateMethod
6
+ class Abstract
7
+ #: () -> void
8
+ def initialize_adapter!; end
9
+
10
+ #: () -> bool
11
+ def semaphore_available?
12
+ raise NotImplementedError, "#{self.class}#semaphore_available? must be implemented"
13
+ end
14
+
15
+ #: (Semaphore) -> bool
16
+ def semaphore_wait(semaphore)
17
+ raise NotImplementedError, "#{self.class}#semaphore_wait must be implemented"
18
+ end
19
+
20
+ #: (Semaphore) -> bool
21
+ def semaphore_signal(semaphore)
22
+ raise NotImplementedError, "#{self.class}#semaphore_signal must be implemented"
23
+ end
24
+
25
+ #: (Array[String]) -> Hash[String, untyped]
26
+ def fetch_job_statuses(job_ids)
27
+ raise NotImplementedError, "#{self.class}#fetch_job_statuses must be implemented"
28
+ end
29
+
30
+ #: (untyped) -> Symbol
31
+ def job_status(job)
32
+ raise NotImplementedError, "#{self.class}#job_status must be implemented"
33
+ end
34
+
35
+ #: () -> bool
36
+ def supports_concurrency_limits?
37
+ raise NotImplementedError, "#{self.class}#supports_concurrency_limits? must be implemented"
38
+ end
39
+
40
+ #: (String) -> bool
41
+ def pause_queue(_queue_name)
42
+ raise NotImplementedError, "#{self.class}#pause_queue must be implemented"
43
+ end
44
+
45
+ #: (String) -> bool
46
+ def resume_queue(_queue_name)
47
+ raise NotImplementedError, "#{self.class}#resume_queue must be implemented"
48
+ end
49
+
50
+ #: (String) -> bool
51
+ def queue_paused?(_queue_name)
52
+ raise NotImplementedError, "#{self.class}#queue_paused? must be implemented"
53
+ end
54
+
55
+ #: () -> Array[String]
56
+ def paused_queues
57
+ raise NotImplementedError, "#{self.class}#paused_queues must be implemented"
58
+ end
59
+
60
+ #: (String) -> Integer?
61
+ def queue_latency(_queue_name)
62
+ raise NotImplementedError, "#{self.class}#queue_latency must be implemented"
63
+ end
64
+
65
+ #: (String) -> Integer
66
+ def queue_size(_queue_name)
67
+ raise NotImplementedError, "#{self.class}#queue_size must be implemented"
68
+ end
69
+
70
+ #: (String) -> bool
71
+ def clear_queue(_queue_name)
72
+ raise NotImplementedError, "#{self.class}#clear_queue must be implemented"
73
+ end
74
+
75
+ #: (String) -> Hash[String, untyped]?
76
+ def find_job(_job_id)
77
+ raise NotImplementedError, "#{self.class}#find_job must be implemented"
78
+ end
79
+
80
+ #: (DSL, Numeric) -> bool
81
+ def reschedule_job(_job, _wait)
82
+ false
83
+ end
84
+ end
85
+ # rubocop:enable Naming/PredicateMethod
86
+ end
87
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JobWorkflow
4
+ module QueueAdapters
5
+ # rubocop:disable Naming/PredicateMethod
6
+ class NullAdapter < Abstract
7
+ #: () -> void
8
+ def initialize_adapter!; end
9
+
10
+ def initialize # rubocop:disable Lint/MissingSuper
11
+ @paused_queues = Set.new #: Set[String]
12
+ @queue_jobs = {} #: Hash[String, Array[untyped]]
13
+ @stored_jobs = {} #: Hash[String, Hash[String, untyped]]
14
+ end
15
+
16
+ #: () -> bool
17
+ def semaphore_available?
18
+ false
19
+ end
20
+
21
+ #: (Semaphore) -> bool
22
+ def semaphore_wait(_semaphore)
23
+ true
24
+ end
25
+
26
+ #: (Semaphore) -> bool
27
+ def semaphore_signal(_semaphore)
28
+ true
29
+ end
30
+
31
+ #: (Array[String]) -> Hash[String, untyped]
32
+ def fetch_job_statuses(_job_ids)
33
+ {}
34
+ end
35
+
36
+ #: (untyped) -> Symbol
37
+ def job_status(_job)
38
+ :pending
39
+ end
40
+
41
+ #: () -> bool
42
+ def supports_concurrency_limits?
43
+ false
44
+ end
45
+
46
+ #: (String) -> bool
47
+ def pause_queue(queue_name)
48
+ @paused_queues.add(queue_name)
49
+ true
50
+ end
51
+
52
+ #: (String) -> bool
53
+ def resume_queue(queue_name)
54
+ @paused_queues.delete(queue_name)
55
+ true
56
+ end
57
+
58
+ #: (String) -> bool
59
+ def queue_paused?(queue_name)
60
+ @paused_queues.include?(queue_name)
61
+ end
62
+
63
+ #: () -> Array[String]
64
+ def paused_queues
65
+ @paused_queues.to_a
66
+ end
67
+
68
+ #: (String) -> Integer?
69
+ def queue_latency(_queue_name)
70
+ nil
71
+ end
72
+
73
+ #: (String) -> Integer
74
+ def queue_size(queue_name)
75
+ jobs = @queue_jobs[queue_name]
76
+ return 0 if jobs.nil?
77
+
78
+ jobs.size
79
+ end
80
+
81
+ #: (String) -> bool
82
+ def clear_queue(queue_name)
83
+ empty_jobs = [] #: Array[untyped]
84
+ @queue_jobs[queue_name] = empty_jobs
85
+ true
86
+ end
87
+
88
+ #: (String) -> Hash[String, untyped]?
89
+ def find_job(job_id)
90
+ @stored_jobs[job_id]
91
+ end
92
+
93
+ #: (DSL, Numeric) -> bool
94
+ def reschedule_job(_job, _wait)
95
+ false
96
+ end
97
+
98
+ # @note Test helpers
99
+ #
100
+ #: (String, untyped) -> void
101
+ def enqueue_test_job(queue_name, job)
102
+ unless @queue_jobs.key?(queue_name)
103
+ empty_jobs = [] #: Array[untyped]
104
+ @queue_jobs[queue_name] = empty_jobs
105
+ end
106
+ @queue_jobs[queue_name] << job
107
+ end
108
+
109
+ # @note Test helpers - stores job data for find_job
110
+ #
111
+ #: (String, Hash[String, untyped]) -> void
112
+ def store_job(job_id, job_data)
113
+ @stored_jobs[job_id] = job_data
114
+ end
115
+
116
+ # @note Test helpers
117
+ #
118
+ #: () -> void
119
+ def reset!
120
+ @paused_queues.clear
121
+ @queue_jobs.clear
122
+ @stored_jobs.clear
123
+ end
124
+ end
125
+ # rubocop:enable Naming/PredicateMethod
126
+ end
127
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JobWorkflow
4
+ module QueueAdapters
5
+ # rubocop:disable Naming/PredicateMethod
6
+ class SolidQueueAdapter < Abstract
7
+ # @note
8
+ # - Registry scope: @semaphore_registry is process-scoped (shared across fibers/threads
9
+ # in the same process) and lives for the lifetime of the worker process. It is not
10
+ # serialized to persistent storage; semaphores are transient per worker instance.
11
+ # - Cleanup: The adapter relies on SolidQueue::Worker lifecycle hooks to clean up
12
+ # active semaphores when the worker stops. If a worker crashes, semaphores will
13
+ # leak until the underlying database records expire or are manually cleaned.
14
+ #
15
+ #: () -> void
16
+ def initialize
17
+ @semaphore_registry = {} #: Hash[Object, ^(SolidQueue::Worker) -> void]
18
+ super
19
+ end
20
+
21
+ #: () -> void
22
+ def initialize_adapter!
23
+ SolidQueue::Configuration.prepend(SchedulingPatch) if defined?(SolidQueue::Configuration)
24
+ SolidQueue::ClaimedExecution.prepend(ClaimedExecutionPatch) if defined?(SolidQueue::ClaimedExecution)
25
+ end
26
+
27
+ #: () -> bool
28
+ def semaphore_available?
29
+ defined?(SolidQueue::Semaphore) ? true : false
30
+ end
31
+
32
+ # @note
33
+ # - Thread safety: @semaphore_registry is a non-thread-safe Hash. In multi-threaded workers,
34
+ # concurrent calls to semaphore_wait or semaphore_signal may cause race conditions.
35
+ # Mitigation: SolidQueue workers typically run in single-threaded Fiber mode; verify
36
+ # worker configuration does not enable raw multithreading.
37
+ # - Double-wait behavior: If semaphore_wait is called twice for the same Semaphore
38
+ # (e.g., due to retry or requeue), the second call returns false and does not
39
+ # re-register the hook. This is a fail-fast contract: the semaphore is already
40
+ # being waited and will signal the registered hook.
41
+ #
42
+ #: (Semaphore) -> bool
43
+ def semaphore_wait(semaphore)
44
+ return true unless semaphore_available?
45
+ return false if semaphore_registry.key?(semaphore)
46
+ return false unless SolidQueue::Semaphore.wait(semaphore)
47
+
48
+ hook = ->(_) { SolidQueue::Semaphore.signal(semaphore) }
49
+ semaphore_registry[semaphore] = hook
50
+ SolidQueue::Worker.on_stop(&hook)
51
+ true
52
+ end
53
+
54
+ # @note
55
+ # - Lifecycle management: The adapter is responsible for removing the hook from
56
+ # SolidQueue::Worker.lifecycle_hooks[:stop] before calling signal. The hook must
57
+ # be deleted from the registry and the global lifecycle_hooks to prevent redundant
58
+ # signal calls after the semaphore has already been signaled.
59
+ # - Hook deletion order: The hook is deleted before calling signal to ensure the
60
+ # hook lambda is no longer invoked even if the signal triggers a worker stop.
61
+ #
62
+ #: (Semaphore) -> bool
63
+ def semaphore_signal(semaphore)
64
+ return true unless semaphore_available?
65
+ return true unless semaphore_registry.key?(semaphore)
66
+
67
+ hook = semaphore_registry[semaphore]
68
+ SolidQueue::Worker.lifecycle_hooks[:stop].delete(hook)
69
+ semaphore_registry.delete(semaphore)
70
+ SolidQueue::Semaphore.signal(semaphore)
71
+ end
72
+
73
+ #: (Array[String]) -> Hash[String, untyped]
74
+ def fetch_job_statuses(job_ids)
75
+ return {} unless defined?(SolidQueue::Job)
76
+
77
+ SolidQueue::Job.where(active_job_id: job_ids).index_by(&:active_job_id)
78
+ end
79
+
80
+ #: (untyped) -> Symbol
81
+ def job_status(job)
82
+ return :failed if job.failed?
83
+ return :succeeded if job.finished?
84
+ return :running if job.claimed?
85
+
86
+ :pending
87
+ end
88
+
89
+ #: () -> bool
90
+ def supports_concurrency_limits?
91
+ defined?(SolidQueue) ? true : false
92
+ end
93
+
94
+ #: (String) -> bool
95
+ def pause_queue(queue_name)
96
+ return false unless defined?(SolidQueue::Queue)
97
+
98
+ SolidQueue::Queue.find_by_name(queue_name).pause
99
+ true
100
+ rescue ActiveRecord::RecordNotUnique
101
+ true
102
+ end
103
+
104
+ #: (String) -> bool
105
+ def resume_queue(queue_name)
106
+ return false unless defined?(SolidQueue::Queue)
107
+
108
+ SolidQueue::Queue.find_by_name(queue_name).resume
109
+ true
110
+ end
111
+
112
+ #: (String) -> bool
113
+ def queue_paused?(queue_name)
114
+ return false unless defined?(SolidQueue::Queue)
115
+
116
+ SolidQueue::Queue.find_by_name(queue_name).paused?
117
+ end
118
+
119
+ #: () -> Array[String]
120
+ def paused_queues
121
+ return [] unless defined?(SolidQueue::Pause)
122
+
123
+ SolidQueue::Pause.pluck(:queue_name)
124
+ end
125
+
126
+ #: (String) -> Integer?
127
+ def queue_latency(queue_name)
128
+ return nil unless defined?(SolidQueue::Queue)
129
+
130
+ SolidQueue::Queue.find_by_name(queue_name).latency
131
+ end
132
+
133
+ #: (String) -> Integer
134
+ def queue_size(queue_name)
135
+ return 0 unless defined?(SolidQueue::Queue)
136
+
137
+ SolidQueue::Queue.find_by_name(queue_name).size
138
+ end
139
+
140
+ #: (String) -> bool
141
+ def clear_queue(queue_name)
142
+ return false unless defined?(SolidQueue::Queue)
143
+
144
+ SolidQueue::Queue.find_by_name(queue_name).clear
145
+ true
146
+ end
147
+
148
+ # @note
149
+ # - SolidQueue stores the full ActiveJob serialization in job.arguments
150
+ # - We need to extract the actual arguments array for consistency
151
+ #
152
+ #: (String) -> Hash[String, untyped]?
153
+ def find_job(job_id)
154
+ return unless defined?(SolidQueue::Job)
155
+
156
+ job = SolidQueue::Job.find_by(active_job_id: job_id)
157
+ return if job.nil?
158
+
159
+ {
160
+ "job_id" => job.active_job_id,
161
+ "class_name" => job.class_name,
162
+ "queue_name" => job.queue_name,
163
+ "arguments" => job.arguments.is_a?(Hash) ? job.arguments["arguments"] : job.arguments,
164
+ "status" => job_status(job)
165
+ }
166
+ end
167
+
168
+ #: (DSL, Numeric) -> bool
169
+ def reschedule_job(job, wait)
170
+ return false unless defined?(SolidQueue::Job)
171
+
172
+ solid_queue_job = SolidQueue::Job.find_by(active_job_id: job.job_id)
173
+ return false unless solid_queue_job&.claimed?
174
+
175
+ reschedule_solid_queue_job(solid_queue_job, job, wait)
176
+ rescue ActiveRecord::RecordNotFound
177
+ false
178
+ end
179
+
180
+ private
181
+
182
+ attr_reader :semaphore_registry #: Hash[Object, ^(SolidQueue::Worker) -> void]
183
+
184
+ #: (SolidQueue::Job, DSL, Numeric) -> bool
185
+ def reschedule_solid_queue_job(solid_queue_job, active_job, wait)
186
+ solid_queue_job.with_lock do
187
+ solid_queue_job.claimed_execution&.destroy!
188
+ solid_queue_job.update!(
189
+ scheduled_at: wait.seconds.from_now,
190
+ arguments: active_job.serialize.deep_stringify_keys["arguments"]
191
+ )
192
+ solid_queue_job.prepare_for_execution
193
+ end
194
+ true
195
+ end
196
+
197
+ # @rbs module-self SolidQueue::ClaimedExecution
198
+ module ClaimedExecutionPatch
199
+ private
200
+
201
+ #: () -> SolidQueue::ClaimedExecution
202
+ def finished
203
+ return self unless self.class.exists?(id)
204
+
205
+ super
206
+ end
207
+ end
208
+
209
+ module SchedulingPatch
210
+ private
211
+
212
+ #: () -> Hash[Symbol, Hash[Symbol, untyped]]
213
+ def recurring_tasks_config
214
+ super.merge!(
215
+ DSL._included_classes.to_a.reduce(
216
+ {} #: Hash[Symbol, Hash[Symbol, untyped]]
217
+ ) { |acc, job_class| acc.merge(job_class._workflow.build_schedules_hash) }
218
+ )
219
+ end
220
+ end
221
+ end
222
+ # rubocop:enable Naming/PredicateMethod
223
+ end
224
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JobWorkflow
4
+ class Runner # rubocop:disable Metrics/ClassLength
5
+ attr_reader :context #: Context
6
+
7
+ #: (context: Context) -> void
8
+ def initialize(context:)
9
+ @context = context
10
+ @job = context._job || (raise "current job is not set in context")
11
+ end
12
+
13
+ #: () -> void
14
+ def run
15
+ task = context._task_context.task
16
+ return run_task(task) if !task.nil? && context.sub_job?
17
+
18
+ catch(:rescheduled) { run_workflow }
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :job #: DSL
24
+
25
+ #: () -> Workflow
26
+ def workflow
27
+ job.class._workflow
28
+ end
29
+
30
+ #: () -> Array[Task]
31
+ def tasks
32
+ workflow.tasks
33
+ end
34
+
35
+ #: () -> HookRegistry
36
+ def hooks
37
+ workflow.hooks
38
+ end
39
+
40
+ #: () -> void
41
+ def run_workflow
42
+ Instrumentation.instrument_workflow(job) do
43
+ tasks.each do |task|
44
+ next if skip_task?(task)
45
+
46
+ job.step(task.task_name) do |step|
47
+ wait_for_dependent_tasks(task, step)
48
+ task.enqueue.should_enqueue?(context) ? enqueue_task(task) : run_task(task)
49
+ end
50
+ end
51
+ end
52
+ end
53
+
54
+ #: (Task) -> bool
55
+ def skip_task?(task)
56
+ result = !task.condition.call(context)
57
+ Instrumentation.notify_task_skip(job, task, "condition_not_met") if result
58
+ result
59
+ end
60
+
61
+ #: (Task) -> void
62
+ def run_task(task)
63
+ context._load_parent_task_output
64
+ context._with_each_value(task).each do |ctx|
65
+ run_each_task(task, ctx)
66
+ rescue StandardError => e
67
+ run_error_hooks(task, ctx, e)
68
+ raise
69
+ end
70
+ end
71
+
72
+ #: (Task, Context) -> void
73
+ def run_each_task(task, ctx)
74
+ Instrumentation.instrument_task(job, task, ctx) do
75
+ ctx._with_task_throttle do
76
+ run_hooks(task, ctx) do
77
+ data = task.block.call(ctx)
78
+ add_task_output(ctx:, task:, each_index: ctx._task_context.index, data:)
79
+ end
80
+ end
81
+ end
82
+ end
83
+
84
+ #: (Task, Context) { () -> void } -> void
85
+ def run_hooks(task, ctx, &)
86
+ hooks.before_hooks_for(task.task_name).each { |hook| hook.block.call(ctx) }
87
+ run_around_hooks(task, ctx, hooks.around_hooks_for(task.task_name), &)
88
+ hooks.after_hooks_for(task.task_name).each { |hook| hook.block.call(ctx) }
89
+ end
90
+
91
+ #: (Task, Context, Array[Hook]) { () -> void } -> void
92
+ def run_around_hooks(task, ctx, around_hooks, &)
93
+ return yield if around_hooks.empty?
94
+
95
+ hook = around_hooks.first
96
+ remaining = around_hooks[1..] || []
97
+ callable = TaskCallable.new { run_around_hooks(task, ctx, remaining, &) }
98
+ hook.block.call(ctx, callable)
99
+ raise TaskCallable::NotCalledError, task.task_name unless callable.called?
100
+ end
101
+
102
+ #: (Task, Context, StandardError) -> void
103
+ def run_error_hooks(task, ctx, error)
104
+ hooks.error_hooks_for(task.task_name).each { |hook| hook.block.call(ctx, error, task) }
105
+ end
106
+
107
+ #: (Task) -> void
108
+ def enqueue_task(task)
109
+ sub_jobs = context._with_each_value(task).map { |ctx| job.class.from_context(ctx) }
110
+ ActiveJob.perform_all_later(sub_jobs)
111
+ context.job_status.update_task_job_statuses_from_jobs(task_name: task.task_name, jobs: sub_jobs)
112
+ Instrumentation.notify_task_enqueue(job, task, sub_jobs.size)
113
+ end
114
+
115
+ #: (Task, ActiveJob::Continuation::Step) -> void
116
+ def wait_for_dependent_tasks(waiting_task, step)
117
+ waiting_task.depends_on.each do |dependent_task_name|
118
+ dependent_task = workflow.fetch_task(dependent_task_name)
119
+ next if dependent_task.nil? || context.job_status.needs_waiting?(dependent_task.task_name)
120
+
121
+ Instrumentation.instrument_dependent_wait(job, dependent_task) do
122
+ poll_until_complete_or_reschedule(waiting_task, dependent_task, step)
123
+ end
124
+
125
+ update_task_outputs(dependent_task)
126
+ end
127
+ end
128
+
129
+ #: (Task, Task, ActiveJob::Continuation::Step) -> void
130
+ def poll_until_complete_or_reschedule(waiting_task, dependent_task, step)
131
+ poll_state = { count: 0, started_at: Time.current }
132
+ dependency_wait = waiting_task.dependency_wait
133
+
134
+ loop do
135
+ step.checkpoint!
136
+ context.job_status.update_task_job_statuses_from_db(dependent_task.task_name)
137
+ break if context.job_status.needs_waiting?(dependent_task.task_name)
138
+
139
+ poll_state[:count] += 1
140
+ reschedule_if_needed(dependent_task, dependency_wait, poll_state)
141
+ sleep dependency_wait.poll_interval
142
+ end
143
+ end
144
+
145
+ #: (Task, TaskDependencyWait, Hash[Symbol, untyped]) -> void
146
+ def reschedule_if_needed(dependent_task, dependency_wait, poll_state)
147
+ return if dependency_wait.polling_only?
148
+ return if dependency_wait.polling_keep?(poll_state[:started_at])
149
+ return unless QueueAdapter.current.reschedule_job(job, dependency_wait.reschedule_delay)
150
+
151
+ Instrumentation.notify_dependent_reschedule(
152
+ job,
153
+ dependent_task,
154
+ dependency_wait.reschedule_delay,
155
+ poll_state[:count]
156
+ )
157
+ throw :rescheduled
158
+ end
159
+
160
+ #: (ctx: Context, task: Task, each_index: Integer, data: untyped) -> void
161
+ def add_task_output(ctx:, task:, data:, each_index:)
162
+ return if task.output.empty?
163
+
164
+ ctx._add_task_output(TaskOutput.from_task(task:, each_index:, data:))
165
+ end
166
+
167
+ #: (Task) -> void
168
+ def update_task_outputs(task)
169
+ finished_job_ids = context.job_status.finished_job_ids(task_name: task.task_name)
170
+ context.output.update_task_outputs_from_db(finished_job_ids, context.workflow)
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JobWorkflow
4
+ class Schedule
5
+ attr_reader :key #: Symbol
6
+ attr_reader :class_name #: String
7
+ attr_reader :expression #: String
8
+ attr_reader :queue #: String?
9
+ attr_reader :priority #: Integer?
10
+ attr_reader :args #: Hash[Symbol, untyped]
11
+ attr_reader :description #: String?
12
+
13
+ # rubocop:disable Metrics/ParameterLists
14
+ #: (
15
+ # expression: String,
16
+ # class_name: String,
17
+ # ?key: (String | Symbol)?,
18
+ # ?queue: String?,
19
+ # ?priority: Integer?,
20
+ # ?args: Hash[Symbol, untyped],
21
+ # ?description: String?
22
+ # ) -> void
23
+ def initialize(expression:, class_name:, key: nil, queue: nil, priority: nil, args: {}, description: nil)
24
+ @expression = expression #: String
25
+ @class_name = class_name #: String
26
+ @key = (key || class_name).to_sym #: Symbol
27
+ @queue = queue #: String?
28
+ @priority = priority #: Integer?
29
+ @args = args #: Hash[Symbol, untyped]
30
+ @description = description #: String?
31
+ end
32
+ # rubocop:enable Metrics/ParameterLists
33
+
34
+ #: () -> Hash[Symbol, untyped]
35
+ def to_config
36
+ {
37
+ class: class_name,
38
+ schedule: expression,
39
+ queue: queue,
40
+ priority: priority,
41
+ args: args.empty? ? nil : [args],
42
+ description: description
43
+ }.compact
44
+ end
45
+ end
46
+ end