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,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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JobWorkflow
4
+ VERSION = "0.1.3" # : String
5
+ end