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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +91 -0
- data/CHANGELOG.md +23 -0
- data/LICENSE.txt +21 -0
- data/README.md +47 -0
- data/Rakefile +55 -0
- data/Steepfile +10 -0
- data/guides/API_REFERENCE.md +112 -0
- data/guides/BEST_PRACTICES.md +113 -0
- data/guides/CACHE_STORE_INTEGRATION.md +145 -0
- data/guides/CONDITIONAL_EXECUTION.md +66 -0
- data/guides/DEPENDENCY_WAIT.md +386 -0
- data/guides/DRY_RUN.md +390 -0
- data/guides/DSL_BASICS.md +216 -0
- data/guides/ERROR_HANDLING.md +187 -0
- data/guides/GETTING_STARTED.md +524 -0
- data/guides/INSTRUMENTATION.md +131 -0
- data/guides/LIFECYCLE_HOOKS.md +415 -0
- data/guides/NAMESPACES.md +75 -0
- data/guides/OPENTELEMETRY_INTEGRATION.md +86 -0
- data/guides/PARALLEL_PROCESSING.md +302 -0
- data/guides/PRODUCTION_DEPLOYMENT.md +110 -0
- data/guides/QUEUE_MANAGEMENT.md +141 -0
- data/guides/README.md +174 -0
- data/guides/SCHEDULED_JOBS.md +165 -0
- data/guides/STRUCTURED_LOGGING.md +268 -0
- data/guides/TASK_OUTPUTS.md +240 -0
- data/guides/TESTING_STRATEGY.md +56 -0
- data/guides/THROTTLING.md +198 -0
- data/guides/TROUBLESHOOTING.md +53 -0
- data/guides/WORKFLOW_COMPOSITION.md +675 -0
- data/guides/WORKFLOW_STATUS_QUERY.md +288 -0
- data/lib/job-workflow.rb +3 -0
- data/lib/job_workflow/argument_def.rb +16 -0
- data/lib/job_workflow/arguments.rb +40 -0
- data/lib/job_workflow/auto_scaling/adapter/aws_adapter.rb +66 -0
- data/lib/job_workflow/auto_scaling/adapter.rb +31 -0
- data/lib/job_workflow/auto_scaling/configuration.rb +85 -0
- data/lib/job_workflow/auto_scaling/executor.rb +43 -0
- data/lib/job_workflow/auto_scaling.rb +69 -0
- data/lib/job_workflow/cache_store_adapters.rb +46 -0
- data/lib/job_workflow/context.rb +352 -0
- data/lib/job_workflow/dry_run_config.rb +31 -0
- data/lib/job_workflow/dsl.rb +236 -0
- data/lib/job_workflow/error_hook.rb +24 -0
- data/lib/job_workflow/hook.rb +24 -0
- data/lib/job_workflow/hook_registry.rb +66 -0
- data/lib/job_workflow/instrumentation/log_subscriber.rb +194 -0
- data/lib/job_workflow/instrumentation/opentelemetry_subscriber.rb +221 -0
- data/lib/job_workflow/instrumentation.rb +257 -0
- data/lib/job_workflow/job_status.rb +92 -0
- data/lib/job_workflow/logger.rb +86 -0
- data/lib/job_workflow/namespace.rb +36 -0
- data/lib/job_workflow/output.rb +81 -0
- data/lib/job_workflow/output_def.rb +14 -0
- data/lib/job_workflow/queue.rb +74 -0
- data/lib/job_workflow/queue_adapter.rb +38 -0
- data/lib/job_workflow/queue_adapters/abstract.rb +87 -0
- data/lib/job_workflow/queue_adapters/null_adapter.rb +127 -0
- data/lib/job_workflow/queue_adapters/solid_queue_adapter.rb +224 -0
- data/lib/job_workflow/runner.rb +173 -0
- data/lib/job_workflow/schedule.rb +46 -0
- data/lib/job_workflow/semaphore.rb +71 -0
- data/lib/job_workflow/task.rb +83 -0
- data/lib/job_workflow/task_callable.rb +43 -0
- data/lib/job_workflow/task_context.rb +70 -0
- data/lib/job_workflow/task_dependency_wait.rb +66 -0
- data/lib/job_workflow/task_enqueue.rb +50 -0
- data/lib/job_workflow/task_graph.rb +43 -0
- data/lib/job_workflow/task_job_status.rb +70 -0
- data/lib/job_workflow/task_output.rb +51 -0
- data/lib/job_workflow/task_retry.rb +64 -0
- data/lib/job_workflow/task_throttle.rb +46 -0
- data/lib/job_workflow/version.rb +5 -0
- data/lib/job_workflow/workflow.rb +87 -0
- data/lib/job_workflow/workflow_status.rb +112 -0
- data/lib/job_workflow.rb +59 -0
- data/rbs_collection.lock.yaml +172 -0
- data/rbs_collection.yaml +14 -0
- data/sig/generated/job-workflow.rbs +2 -0
- data/sig/generated/job_workflow/argument_def.rbs +14 -0
- data/sig/generated/job_workflow/arguments.rbs +26 -0
- data/sig/generated/job_workflow/auto_scaling/adapter/aws_adapter.rbs +32 -0
- data/sig/generated/job_workflow/auto_scaling/adapter.rbs +22 -0
- data/sig/generated/job_workflow/auto_scaling/configuration.rbs +50 -0
- data/sig/generated/job_workflow/auto_scaling/executor.rbs +29 -0
- data/sig/generated/job_workflow/auto_scaling.rbs +47 -0
- data/sig/generated/job_workflow/cache_store_adapters.rbs +28 -0
- data/sig/generated/job_workflow/context.rbs +155 -0
- data/sig/generated/job_workflow/dry_run_config.rbs +16 -0
- data/sig/generated/job_workflow/dsl.rbs +117 -0
- data/sig/generated/job_workflow/error_hook.rbs +18 -0
- data/sig/generated/job_workflow/hook.rbs +18 -0
- data/sig/generated/job_workflow/hook_registry.rbs +47 -0
- data/sig/generated/job_workflow/instrumentation/log_subscriber.rbs +102 -0
- data/sig/generated/job_workflow/instrumentation/opentelemetry_subscriber.rbs +113 -0
- data/sig/generated/job_workflow/instrumentation.rbs +138 -0
- data/sig/generated/job_workflow/job_status.rbs +46 -0
- data/sig/generated/job_workflow/logger.rbs +56 -0
- data/sig/generated/job_workflow/namespace.rbs +24 -0
- data/sig/generated/job_workflow/output.rbs +39 -0
- data/sig/generated/job_workflow/output_def.rbs +12 -0
- data/sig/generated/job_workflow/queue.rbs +49 -0
- data/sig/generated/job_workflow/queue_adapter.rbs +18 -0
- data/sig/generated/job_workflow/queue_adapters/abstract.rbs +56 -0
- data/sig/generated/job_workflow/queue_adapters/null_adapter.rbs +73 -0
- data/sig/generated/job_workflow/queue_adapters/solid_queue_adapter.rbs +111 -0
- data/sig/generated/job_workflow/runner.rbs +66 -0
- data/sig/generated/job_workflow/schedule.rbs +34 -0
- data/sig/generated/job_workflow/semaphore.rbs +37 -0
- data/sig/generated/job_workflow/task.rbs +60 -0
- data/sig/generated/job_workflow/task_callable.rbs +30 -0
- data/sig/generated/job_workflow/task_context.rbs +52 -0
- data/sig/generated/job_workflow/task_dependency_wait.rbs +42 -0
- data/sig/generated/job_workflow/task_enqueue.rbs +27 -0
- data/sig/generated/job_workflow/task_graph.rbs +27 -0
- data/sig/generated/job_workflow/task_job_status.rbs +42 -0
- data/sig/generated/job_workflow/task_output.rbs +29 -0
- data/sig/generated/job_workflow/task_retry.rbs +30 -0
- data/sig/generated/job_workflow/task_throttle.rbs +20 -0
- data/sig/generated/job_workflow/version.rbs +5 -0
- data/sig/generated/job_workflow/workflow.rbs +48 -0
- data/sig/generated/job_workflow/workflow_status.rbs +55 -0
- data/sig/generated/job_workflow.rbs +8 -0
- data/sig-private/activejob.rbs +35 -0
- data/sig-private/activesupport.rbs +23 -0
- data/sig-private/aws.rbs +32 -0
- data/sig-private/opentelemetry.rbs +40 -0
- data/sig-private/solid_queue.rbs +108 -0
- data/tmp/.keep +0 -0
- 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
|