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,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobWorkflow
|
|
4
|
+
class Semaphore
|
|
5
|
+
DEFAULT_POLLING_INTERVAL = 3.0 #: Float
|
|
6
|
+
private_constant :DEFAULT_POLLING_INTERVAL
|
|
7
|
+
|
|
8
|
+
attr_reader :concurrency_key #: String
|
|
9
|
+
attr_reader :concurrency_limit #: Integer
|
|
10
|
+
attr_reader :concurrency_duration #: ActiveSupport::Duration
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
#: () -> bool
|
|
14
|
+
def available?
|
|
15
|
+
QueueAdapter.current.semaphore_available?
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
#: (
|
|
20
|
+
# concurrency_key: String,
|
|
21
|
+
# concurrency_duration: ActiveSupport::Duration,
|
|
22
|
+
# ?concurrency_limit: Integer,
|
|
23
|
+
# ?polling_interval: Float
|
|
24
|
+
# ) -> void
|
|
25
|
+
def initialize(
|
|
26
|
+
concurrency_key:,
|
|
27
|
+
concurrency_duration:,
|
|
28
|
+
concurrency_limit: 1,
|
|
29
|
+
polling_interval: DEFAULT_POLLING_INTERVAL
|
|
30
|
+
)
|
|
31
|
+
@concurrency_key = concurrency_key
|
|
32
|
+
@concurrency_duration = concurrency_duration
|
|
33
|
+
@concurrency_limit = concurrency_limit
|
|
34
|
+
@polling_interval = polling_interval
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
#: () -> bool
|
|
38
|
+
def wait
|
|
39
|
+
return true unless self.class.available?
|
|
40
|
+
|
|
41
|
+
Instrumentation.instrument_throttle(self) do
|
|
42
|
+
loop do
|
|
43
|
+
return true if QueueAdapter.current.semaphore_wait(self)
|
|
44
|
+
|
|
45
|
+
sleep(polling_interval)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
#: () -> bool
|
|
51
|
+
def signal
|
|
52
|
+
return true unless self.class.available?
|
|
53
|
+
|
|
54
|
+
result = QueueAdapter.current.semaphore_signal(self)
|
|
55
|
+
Instrumentation.notify_throttle_release(self)
|
|
56
|
+
result
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
#: [T] () { () -> T } -> T
|
|
60
|
+
def with(&)
|
|
61
|
+
wait
|
|
62
|
+
yield
|
|
63
|
+
ensure
|
|
64
|
+
signal
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
attr_reader :polling_interval #: Float
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobWorkflow
|
|
4
|
+
class Task
|
|
5
|
+
attr_reader :job_name #: String
|
|
6
|
+
attr_reader :namespace #: Namespace
|
|
7
|
+
attr_reader :block #: ^(untyped) -> void
|
|
8
|
+
attr_reader :each #: ^(Context) -> untyped
|
|
9
|
+
attr_reader :enqueue #: TaskEnqueue
|
|
10
|
+
attr_reader :output #: Array[OutputDef]
|
|
11
|
+
attr_reader :depends_on #: Array[Symbol]
|
|
12
|
+
attr_reader :condition #: ^(Context) -> bool
|
|
13
|
+
attr_reader :task_retry #: TaskRetry
|
|
14
|
+
attr_reader :throttle #: TaskThrottle
|
|
15
|
+
attr_reader :timeout #: Numeric?
|
|
16
|
+
attr_reader :dependency_wait #: TaskDependencyWait
|
|
17
|
+
attr_reader :dry_run_config #: DryRunConfig
|
|
18
|
+
|
|
19
|
+
# rubocop:disable Metrics/ParameterLists, Metrics/MethodLength, Metrics/AbcSize
|
|
20
|
+
#: (
|
|
21
|
+
# job_name: String,
|
|
22
|
+
# name: Symbol,
|
|
23
|
+
# namespace: Namespace,
|
|
24
|
+
# block: ^(untyped) -> void,
|
|
25
|
+
# ?each: ^(Context) -> untyped,
|
|
26
|
+
# ?enqueue: true | false | ^(Context) -> bool | Hash[Symbol, untyped],
|
|
27
|
+
# ?output: Hash[Symbol, String],
|
|
28
|
+
# ?depends_on: Array[Symbol],
|
|
29
|
+
# condition: ^(Context) -> bool,
|
|
30
|
+
# ?task_retry: Integer | Hash[Symbol, untyped],
|
|
31
|
+
# ?throttle: Integer | Hash[Symbol, untyped],
|
|
32
|
+
# ?timeout: Numeric?,
|
|
33
|
+
# ?dependency_wait: Hash[Symbol, untyped],
|
|
34
|
+
# ?dry_run: bool | ^(Context) -> bool
|
|
35
|
+
# ) -> void
|
|
36
|
+
def initialize(
|
|
37
|
+
job_name:,
|
|
38
|
+
name:,
|
|
39
|
+
namespace:,
|
|
40
|
+
block:,
|
|
41
|
+
each: nil,
|
|
42
|
+
enqueue: nil,
|
|
43
|
+
output: {},
|
|
44
|
+
depends_on: [],
|
|
45
|
+
condition: ->(_ctx) { true },
|
|
46
|
+
task_retry: 0,
|
|
47
|
+
throttle: {},
|
|
48
|
+
timeout: nil,
|
|
49
|
+
dependency_wait: {},
|
|
50
|
+
dry_run: false
|
|
51
|
+
)
|
|
52
|
+
@job_name = job_name
|
|
53
|
+
@name = name
|
|
54
|
+
@namespace = namespace #: Namespace
|
|
55
|
+
@block = block
|
|
56
|
+
@each = each
|
|
57
|
+
@enqueue = TaskEnqueue.from_primitive_value(enqueue)
|
|
58
|
+
@output = output.map { |name, type| OutputDef.new(name:, type:) }
|
|
59
|
+
@depends_on = depends_on
|
|
60
|
+
@condition = condition
|
|
61
|
+
@task_retry = TaskRetry.from_primitive_value(task_retry)
|
|
62
|
+
@throttle = TaskThrottle.from_primitive_value_with_task(value: throttle, task: self)
|
|
63
|
+
@timeout = timeout
|
|
64
|
+
@dependency_wait = TaskDependencyWait.from_primitive_value(dependency_wait)
|
|
65
|
+
@dry_run_config = DryRunConfig.from_primitive_value(dry_run)
|
|
66
|
+
end
|
|
67
|
+
# rubocop:enable Metrics/ParameterLists, Metrics/MethodLength, Metrics/AbcSize
|
|
68
|
+
|
|
69
|
+
#: () -> Symbol
|
|
70
|
+
def task_name
|
|
71
|
+
[namespace.full_name.to_s, name.to_s].reject(&:empty?).join(":").to_sym
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
#: () -> String
|
|
75
|
+
def throttle_prefix_key
|
|
76
|
+
"#{job_name}:#{task_name}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
attr_reader :name #: Symbol
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobWorkflow
|
|
4
|
+
class TaskCallable
|
|
5
|
+
class NotCalledError < StandardError
|
|
6
|
+
#: (Symbol) -> void
|
|
7
|
+
def initialize(task_name)
|
|
8
|
+
super("around hook for '#{task_name}' did not call task.call")
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class AlreadyCalledError < StandardError
|
|
13
|
+
#: () -> void
|
|
14
|
+
def initialize
|
|
15
|
+
super("task.call has already been called")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
#: () { () -> void } -> void
|
|
20
|
+
def initialize(&block)
|
|
21
|
+
@block = block #: () -> void
|
|
22
|
+
@called = false #: bool
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
#: () -> void
|
|
26
|
+
def call
|
|
27
|
+
raise AlreadyCalledError if called
|
|
28
|
+
|
|
29
|
+
self.called = true
|
|
30
|
+
block.call
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
#: () -> bool
|
|
34
|
+
def called?
|
|
35
|
+
called
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
attr_reader :block #: () -> void
|
|
41
|
+
attr_accessor :called #: bool
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobWorkflow
|
|
4
|
+
class TaskContext
|
|
5
|
+
NULL_VALUE = nil #: nil
|
|
6
|
+
public_constant :NULL_VALUE
|
|
7
|
+
|
|
8
|
+
attr_reader :task #: Task?
|
|
9
|
+
attr_reader :parent_job_id #: String?
|
|
10
|
+
attr_reader :index #: Integer
|
|
11
|
+
attr_reader :value #: untyped
|
|
12
|
+
attr_reader :retry_count #: Integer
|
|
13
|
+
attr_reader :dry_run #: bool
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
#: (Hash[String, untyped]) -> TaskContext
|
|
17
|
+
def deserialize(hash)
|
|
18
|
+
new(
|
|
19
|
+
task: hash["task"],
|
|
20
|
+
parent_job_id: hash["parent_job_id"],
|
|
21
|
+
index: hash["index"],
|
|
22
|
+
value: ActiveJob::Arguments.deserialize([hash["value"]]).first,
|
|
23
|
+
retry_count: hash.fetch("retry_count", 0)
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
#: (
|
|
29
|
+
# ?task: Task?,
|
|
30
|
+
# ?parent_job_id: String?,
|
|
31
|
+
# ?index: Integer,
|
|
32
|
+
# ?value: untyped,
|
|
33
|
+
# ?retry_count: Integer,
|
|
34
|
+
# ?dry_run: bool
|
|
35
|
+
# ) -> void
|
|
36
|
+
def initialize(task: nil, parent_job_id: nil, index: 0, value: nil, retry_count: 0, dry_run: false) # rubocop:disable Metrics/ParameterLists
|
|
37
|
+
self.task = task
|
|
38
|
+
self.parent_job_id = parent_job_id
|
|
39
|
+
self.index = index
|
|
40
|
+
self.value = value
|
|
41
|
+
self.retry_count = retry_count
|
|
42
|
+
self.dry_run = dry_run
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
#: () -> bool
|
|
46
|
+
def enabled?
|
|
47
|
+
!parent_job_id.nil?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
#: () -> Hash[String, untyped]
|
|
51
|
+
def serialize
|
|
52
|
+
{
|
|
53
|
+
"task_name" => task&.task_name,
|
|
54
|
+
"parent_job_id" => parent_job_id,
|
|
55
|
+
"index" => index,
|
|
56
|
+
"value" => ActiveJob::Arguments.serialize([value]).first,
|
|
57
|
+
"retry_count" => retry_count
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
attr_writer :task #: Task?
|
|
64
|
+
attr_writer :parent_job_id #: String?
|
|
65
|
+
attr_writer :index #: Integer
|
|
66
|
+
attr_writer :value #: untyped
|
|
67
|
+
attr_writer :retry_count #: Integer
|
|
68
|
+
attr_writer :dry_run #: bool
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobWorkflow
|
|
4
|
+
# TaskDependencyWait holds configuration for waiting on dependent tasks.
|
|
5
|
+
#
|
|
6
|
+
# When a task has dependencies (depends_on:), the runner waits for those tasks to complete.
|
|
7
|
+
# This class configures how long to poll before rescheduling the job.
|
|
8
|
+
#
|
|
9
|
+
# @example Default behavior (polling only, no reschedule)
|
|
10
|
+
# task :process, depends_on: [:fetch]
|
|
11
|
+
#
|
|
12
|
+
# @example Wait up to 30 seconds with polling, then reschedule
|
|
13
|
+
# task :process, depends_on: [:fetch], dependency_wait: { poll_timeout: 30 }
|
|
14
|
+
#
|
|
15
|
+
# @example Wait 60 seconds total, poll every 10 seconds, reschedule after 5 seconds
|
|
16
|
+
# task :process, depends_on: [:fetch], dependency_wait: { poll_timeout: 60, poll_interval: 10, reschedule_delay: 5 }
|
|
17
|
+
class TaskDependencyWait
|
|
18
|
+
DEFAULT_POLL_TIMEOUT = 0 #: Integer
|
|
19
|
+
DEFAULT_POLL_INTERVAL = 5 #: Integer
|
|
20
|
+
DEFAULT_RESCHEDULE_DELAY = 5 #: Integer
|
|
21
|
+
|
|
22
|
+
attr_reader :poll_timeout #: Integer
|
|
23
|
+
attr_reader :poll_interval #: Integer
|
|
24
|
+
attr_reader :reschedule_delay #: Integer
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
#: (Integer | Hash[Symbol, untyped] | nil) -> TaskDependencyWait
|
|
28
|
+
def from_primitive_value(value)
|
|
29
|
+
case value
|
|
30
|
+
when Integer
|
|
31
|
+
new(poll_timeout: value)
|
|
32
|
+
when Hash
|
|
33
|
+
new(
|
|
34
|
+
poll_timeout: value[:poll_timeout] || DEFAULT_POLL_TIMEOUT,
|
|
35
|
+
poll_interval: value[:poll_interval] || DEFAULT_POLL_INTERVAL,
|
|
36
|
+
reschedule_delay: value[:reschedule_delay] || DEFAULT_RESCHEDULE_DELAY
|
|
37
|
+
)
|
|
38
|
+
else
|
|
39
|
+
new
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
#: (?poll_timeout: Integer, ?poll_interval: Integer, ?reschedule_delay: Integer) -> void
|
|
45
|
+
def initialize(
|
|
46
|
+
poll_timeout: DEFAULT_POLL_TIMEOUT,
|
|
47
|
+
poll_interval: DEFAULT_POLL_INTERVAL,
|
|
48
|
+
reschedule_delay: DEFAULT_RESCHEDULE_DELAY
|
|
49
|
+
)
|
|
50
|
+
@poll_timeout = poll_timeout #: Integer
|
|
51
|
+
@poll_interval = poll_interval #: Integer
|
|
52
|
+
@reschedule_delay = reschedule_delay #: Integer
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
#: () -> bool
|
|
56
|
+
def polling_only?
|
|
57
|
+
poll_timeout <= 0
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
#: (Time) -> bool
|
|
61
|
+
def polling_keep?(started_at)
|
|
62
|
+
elapsed = Time.current - started_at
|
|
63
|
+
elapsed < poll_timeout
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobWorkflow
|
|
4
|
+
class TaskEnqueue
|
|
5
|
+
attr_reader :condition #: true | false | ^(Context) -> bool
|
|
6
|
+
attr_reader :queue #: String?
|
|
7
|
+
attr_reader :concurrency #: Integer?
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
#: (true | false | ^(Context) -> bool | Hash[Symbol, untyped] | nil) -> TaskEnqueue
|
|
11
|
+
def from_primitive_value(value)
|
|
12
|
+
case value
|
|
13
|
+
when TrueClass, FalseClass, Proc
|
|
14
|
+
new(condition: value)
|
|
15
|
+
when Hash
|
|
16
|
+
new(
|
|
17
|
+
condition: value.fetch(:condition, !value.empty?),
|
|
18
|
+
queue: value[:queue],
|
|
19
|
+
concurrency: value[:concurrency]
|
|
20
|
+
)
|
|
21
|
+
else
|
|
22
|
+
new
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
#: (
|
|
28
|
+
# ?condition: true | false | ^(Context) -> bool,
|
|
29
|
+
# ?queue: String?,
|
|
30
|
+
# ?concurrency: Integer?
|
|
31
|
+
# ) -> void
|
|
32
|
+
def initialize(condition: false, queue: nil, concurrency: nil)
|
|
33
|
+
@condition = condition
|
|
34
|
+
@queue = queue
|
|
35
|
+
@concurrency = concurrency
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
#: (Context) -> bool
|
|
39
|
+
def should_enqueue?(context)
|
|
40
|
+
return condition.call(context) if condition.is_a?(Proc)
|
|
41
|
+
|
|
42
|
+
!!condition
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
#: () -> bool
|
|
46
|
+
def should_limits_concurrency?
|
|
47
|
+
!!condition && !concurrency.nil? && QueueAdapter.current.supports_concurrency_limits?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobWorkflow
|
|
4
|
+
class TaskGraph
|
|
5
|
+
include TSort #[Task]
|
|
6
|
+
include Enumerable #[Task]
|
|
7
|
+
|
|
8
|
+
#: () -> void
|
|
9
|
+
def initialize
|
|
10
|
+
@tasks = {} #: Hash[Symbol, Task]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
#: (Task) -> void
|
|
14
|
+
def add(task)
|
|
15
|
+
@tasks[task.task_name] = task
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
#: (Symbol?) -> Task?
|
|
19
|
+
def fetch(task_name)
|
|
20
|
+
@tasks[task_name]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
#: () { (Task) -> void } -> void
|
|
24
|
+
def each(&)
|
|
25
|
+
tsort.each(&)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
#: () { (Task) -> void } -> void
|
|
29
|
+
def tsort_each_node(&)
|
|
30
|
+
@tasks.values.each(&)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
#: (Task task) { (Task) -> void } -> void
|
|
34
|
+
def tsort_each_child(task)
|
|
35
|
+
task.depends_on.each do |dep_task_name|
|
|
36
|
+
dep_task = @tasks[dep_task_name]
|
|
37
|
+
raise ArgumentError, "Task '#{task.task_name}' depends on missing task '#{dep_task_name}'" if dep_task.nil?
|
|
38
|
+
|
|
39
|
+
yield(dep_task)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobWorkflow
|
|
4
|
+
class TaskJobStatus
|
|
5
|
+
attr_reader :task_name #: Symbol
|
|
6
|
+
attr_reader :job_id #: String
|
|
7
|
+
attr_reader :each_index #: Integer
|
|
8
|
+
attr_reader :status #: Symbol
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
#: (Hash[Symbol, untyped]) -> TaskJobStatus
|
|
12
|
+
def from_hash(hash)
|
|
13
|
+
new(
|
|
14
|
+
task_name: hash[:task_name],
|
|
15
|
+
job_id: hash[:job_id],
|
|
16
|
+
each_index: hash[:each_index],
|
|
17
|
+
status: hash[:status]
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
#: (Hash[String, untyped]) -> TaskJobStatus
|
|
22
|
+
def deserialize(hash)
|
|
23
|
+
new(
|
|
24
|
+
task_name: hash["task_name"].to_sym,
|
|
25
|
+
job_id: hash["job_id"],
|
|
26
|
+
each_index: hash["each_index"],
|
|
27
|
+
status: hash["status"].to_sym
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
#: (
|
|
33
|
+
# task_name: Symbol,
|
|
34
|
+
# job_id: String,
|
|
35
|
+
# each_index: Integer,
|
|
36
|
+
# ?status: Symbol
|
|
37
|
+
# ) -> void
|
|
38
|
+
def initialize(task_name:, job_id:, each_index:, status: :pending)
|
|
39
|
+
@task_name = task_name
|
|
40
|
+
@job_id = job_id
|
|
41
|
+
@each_index = each_index
|
|
42
|
+
@status = status
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
#: (Symbol) -> void
|
|
46
|
+
def update_status(status)
|
|
47
|
+
@status = status
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
#: () -> bool
|
|
51
|
+
def finished?
|
|
52
|
+
%i[succeeded failed].include?(status)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
#: () -> bool
|
|
56
|
+
def succeeded?
|
|
57
|
+
status == :succeeded
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
#: () -> bool
|
|
61
|
+
def failed?
|
|
62
|
+
status == :failed
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
#: () -> Hash[String, untyped]
|
|
66
|
+
def serialize
|
|
67
|
+
{ "task_name" => task_name.to_s, "job_id" => job_id, "each_index" => each_index, "status" => status.to_s }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobWorkflow
|
|
4
|
+
class TaskOutput
|
|
5
|
+
attr_reader :task_name #: Symbol
|
|
6
|
+
attr_reader :each_index #: Integer
|
|
7
|
+
attr_reader :data #: Hash[Symbol, untyped]
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
#: (task: Task, each_index: Integer, data: Hash[Symbol, untyped]) -> TaskOutput
|
|
11
|
+
def from_task(task:, data:, each_index:)
|
|
12
|
+
normalized_data = task.output.to_h { |output_def| [output_def.name, nil] }
|
|
13
|
+
normalized_data.merge!(data.slice(*normalized_data.keys))
|
|
14
|
+
new(task_name: task.task_name, each_index:, data: normalized_data)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
#: (Hash[String, untyped]) -> TaskOutput
|
|
18
|
+
def deserialize(hash)
|
|
19
|
+
new(
|
|
20
|
+
task_name: hash["task_name"].to_sym,
|
|
21
|
+
each_index: hash["each_index"],
|
|
22
|
+
data: ActiveJob::Arguments.deserialize([hash["data"]]).first
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
#: (task_name: Symbol, each_index: Integer, ?data: Hash[Symbol, untyped]) -> void
|
|
28
|
+
def initialize(task_name:, each_index:, data: {})
|
|
29
|
+
@task_name = task_name
|
|
30
|
+
@each_index = each_index
|
|
31
|
+
@data = data
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
#: () -> Hash[String, untyped]
|
|
35
|
+
def serialize
|
|
36
|
+
{ "task_name" => task_name.to_s, "each_index" => each_index, "data" => ActiveJob::Arguments.serialize([data]).first }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
#: ...
|
|
40
|
+
def method_missing(name, *args, **kwargs, &block)
|
|
41
|
+
return data[name.to_sym] if data.key?(name.to_sym) && args.empty? && kwargs.empty? && block.nil?
|
|
42
|
+
|
|
43
|
+
super
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
#: (Symbol, bool) -> bool
|
|
47
|
+
def respond_to_missing?(sym, include_private)
|
|
48
|
+
data.key?(sym) || super
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobWorkflow
|
|
4
|
+
class TaskRetry
|
|
5
|
+
attr_reader :count #: Integer
|
|
6
|
+
attr_reader :strategy #: Symbol
|
|
7
|
+
attr_reader :base_delay #: Integer
|
|
8
|
+
attr_reader :jitter #: bool
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
#: (Integer | Hash[Symbol, untyped]) -> TaskRetry
|
|
12
|
+
def from_primitive_value(value)
|
|
13
|
+
case value
|
|
14
|
+
when Integer
|
|
15
|
+
new(count: value)
|
|
16
|
+
when Hash
|
|
17
|
+
new(
|
|
18
|
+
count: value.fetch(:count, 3),
|
|
19
|
+
strategy: value.fetch(:strategy, :exponential),
|
|
20
|
+
base_delay: value.fetch(:base_delay, 1),
|
|
21
|
+
jitter: value.fetch(:jitter, false)
|
|
22
|
+
)
|
|
23
|
+
else
|
|
24
|
+
raise ArgumentError, "retry must be Integer or Hash"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
#: (?count: Integer, ?strategy: Symbol, ?base_delay: Integer, ?jitter: bool) -> void
|
|
30
|
+
def initialize(count: 0, strategy: :exponential, base_delay: 1, jitter: false)
|
|
31
|
+
@count = count
|
|
32
|
+
@strategy = strategy
|
|
33
|
+
@base_delay = base_delay
|
|
34
|
+
@jitter = jitter
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
#: (Integer) -> Float
|
|
38
|
+
def delay_for(retry_attempt)
|
|
39
|
+
delay = calculate_base_delay(retry_attempt)
|
|
40
|
+
apply_jitter(delay)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
#: (Integer) -> Integer
|
|
46
|
+
def calculate_base_delay(retry_attempt)
|
|
47
|
+
case strategy
|
|
48
|
+
when :exponential
|
|
49
|
+
exponent = retry_attempt - 1 #: Integer
|
|
50
|
+
base_delay * (1 << exponent)
|
|
51
|
+
else
|
|
52
|
+
base_delay
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
#: (Integer) -> Float
|
|
57
|
+
def apply_jitter(delay)
|
|
58
|
+
return delay.to_f unless jitter
|
|
59
|
+
|
|
60
|
+
randomness = delay * 0.5
|
|
61
|
+
delay + (rand * randomness) - (randomness / 2)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JobWorkflow
|
|
4
|
+
class TaskThrottle
|
|
5
|
+
attr_reader :key #: String
|
|
6
|
+
attr_reader :limit #: Integer?
|
|
7
|
+
attr_reader :ttl #: Integer
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
#: (value: Integer | Hash[Symbol, untyped], task: Task) -> TaskThrottle
|
|
11
|
+
def from_primitive_value_with_task(value:, task:)
|
|
12
|
+
case value
|
|
13
|
+
when Integer
|
|
14
|
+
new(key: task.throttle_prefix_key, limit: value)
|
|
15
|
+
when Hash
|
|
16
|
+
new(
|
|
17
|
+
key: value[:key] || task.throttle_prefix_key,
|
|
18
|
+
limit: value[:limit],
|
|
19
|
+
ttl: value[:ttl] || 180
|
|
20
|
+
)
|
|
21
|
+
else
|
|
22
|
+
raise ArgumentError, "throttle must be Integer or Hash"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
#: (key: String, ?limit: Integer?, ?ttl: Integer) -> void
|
|
28
|
+
def initialize(key:, limit: nil, ttl: 180)
|
|
29
|
+
@key = key
|
|
30
|
+
@limit = limit
|
|
31
|
+
@ttl = ttl
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
#: () -> Semaphore?
|
|
35
|
+
def semaphore
|
|
36
|
+
local_limit = limit
|
|
37
|
+
return if local_limit.nil?
|
|
38
|
+
|
|
39
|
+
Semaphore.new(
|
|
40
|
+
concurrency_key: key,
|
|
41
|
+
concurrency_duration: ttl.seconds,
|
|
42
|
+
concurrency_limit: local_limit
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|