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,288 @@
1
+ # Workflow Status Query
2
+
3
+ JobWorkflow provides a robust API for querying the execution status of workflows. This allows you to monitor running workflows, inspect their state, and build observability dashboards.
4
+
5
+ ## Basic Usage
6
+
7
+ ### Finding a Workflow
8
+
9
+ ```ruby
10
+ # Find by job_id (raises NotFoundError if not found)
11
+ status = JobWorkflow::WorkflowStatus.find("job-123")
12
+
13
+ # Find by job_id (returns nil if not found)
14
+ status = JobWorkflow::WorkflowStatus.find_by(job_id: "job-123")
15
+ return unless status
16
+
17
+ # Check workflow status
18
+ status.status # => :pending, :running, :succeeded, or :failed
19
+ ```
20
+
21
+ ### Status Check Methods
22
+
23
+ ```ruby
24
+ # Convenient predicate methods
25
+ status.pending? # => true if not yet started
26
+ status.running? # => true if currently executing
27
+ status.completed? # => true if finished successfully
28
+ status.failed? # => true if execution failed
29
+ ```
30
+
31
+ ## Accessing Workflow Information
32
+
33
+ ### Basic Information
34
+
35
+ ```ruby
36
+ status = JobWorkflow::WorkflowStatus.find("job-123")
37
+
38
+ # Job class name
39
+ status.job_class_name # => "OrderProcessingJob"
40
+
41
+ # Current task being executed (nil if not running)
42
+ status.current_task_name # => :validate_payment
43
+
44
+ # Workflow execution status
45
+ status.status # => :running
46
+ ```
47
+
48
+ ### Arguments
49
+
50
+ Access the immutable input arguments that were passed to the workflow:
51
+
52
+ ```ruby
53
+ # Get Arguments object
54
+ args = status.arguments
55
+
56
+ # Access argument values
57
+ args.order_id # => 12345
58
+ args.user_id # => 789
59
+ args.to_h # => { order_id: 12345, user_id: 789 }
60
+ ```
61
+
62
+ ### Output
63
+
64
+ Access the accumulated outputs from completed tasks:
65
+
66
+ ```ruby
67
+ # Get Output object
68
+ output = status.output
69
+
70
+ # Access outputs by task name
71
+ validation_output = output[:validate_payment].first
72
+ validation_output.data # => { valid: true, amount: 1000 }
73
+
74
+ # Iterate over all outputs
75
+ output.flat_task_outputs.each do |task_output|
76
+ puts "#{task_output.task_name}: #{task_output.data}"
77
+ end
78
+ ```
79
+
80
+ ### Job Status
81
+
82
+ Access detailed information about individual task executions:
83
+
84
+ ```ruby
85
+ # Get JobStatus object
86
+ job_status = status.job_status
87
+
88
+ # Access task statuses by name
89
+ task_statuses = job_status[:validate_payment]
90
+ task_statuses.each do |task_status|
91
+ puts "Job ID: #{task_status.job_id}"
92
+ puts "Status: #{task_status.status}" # :pending, :running, :succeeded, :failed
93
+ puts "Finished: #{task_status.finished?}"
94
+ end
95
+
96
+ # Iterate over all task statuses
97
+ job_status.flat_task_job_statuses.each do |task_status|
98
+ puts "Task: #{task_status.task_name}, Status: #{task_status.status}"
99
+ end
100
+ ```
101
+
102
+ ## Hash Representation
103
+
104
+ Convert workflow status to a hash for serialization or API responses:
105
+
106
+ ```ruby
107
+ status_hash = status.to_h
108
+ # => {
109
+ # status: :running,
110
+ # job_class_name: "OrderProcessingJob",
111
+ # current_task_name: :validate_payment,
112
+ # arguments: { order_id: 12345, user_id: 789 },
113
+ # output: [
114
+ # {
115
+ # task_name: :fetch_order,
116
+ # each_index: 0,
117
+ # data: { order: {...} }
118
+ # }
119
+ # ]
120
+ # }
121
+ ```
122
+
123
+ ## Practical Examples
124
+
125
+ ### REST API Endpoint
126
+
127
+ ```ruby
128
+ # app/controllers/api/workflows_controller.rb
129
+ class Api::WorkflowsController < ApplicationController
130
+ def show
131
+ status = JobWorkflow::WorkflowStatus.find_by(job_id: params[:id])
132
+
133
+ if status
134
+ render json: {
135
+ id: params[:id],
136
+ status: status.status,
137
+ job_class: status.job_class_name,
138
+ current_task: status.current_task_name,
139
+ completed: status.completed?,
140
+ failed: status.failed?,
141
+ arguments: status.arguments.to_h,
142
+ outputs: status.output.flat_task_outputs.map do |output|
143
+ {
144
+ task: output.task_name,
145
+ data: output.data
146
+ }
147
+ end
148
+ }
149
+ else
150
+ render json: { error: "Workflow not found" }, status: :not_found
151
+ end
152
+ end
153
+ end
154
+ ```
155
+
156
+ ### Progress Tracking
157
+
158
+ ```ruby
159
+ # Track workflow progress
160
+ class WorkflowProgressTracker
161
+ def self.track(job_id)
162
+ status = JobWorkflow::WorkflowStatus.find(job_id)
163
+
164
+ # Calculate progress based on completed tasks
165
+ total_tasks = count_total_tasks(status.job_class_name)
166
+ completed_tasks = status.job_status.flat_task_job_statuses.count do |task_status|
167
+ task_status.succeeded?
168
+ end
169
+
170
+ progress = (completed_tasks.to_f / total_tasks * 100).round(2)
171
+
172
+ {
173
+ job_id: job_id,
174
+ status: status.status,
175
+ progress_percentage: progress,
176
+ current_task: status.current_task_name,
177
+ completed_tasks: completed_tasks,
178
+ total_tasks: total_tasks
179
+ }
180
+ end
181
+
182
+ def self.count_total_tasks(job_class_name)
183
+ job_class = job_class_name.constantize
184
+ job_class._workflow.tasks.count
185
+ end
186
+ end
187
+
188
+ # Usage
189
+ progress = WorkflowProgressTracker.track("job-123")
190
+ # => {
191
+ # job_id: "job-123",
192
+ # status: :running,
193
+ # progress_percentage: 60.0,
194
+ # current_task: :process_payment,
195
+ # completed_tasks: 3,
196
+ # total_tasks: 5
197
+ # }
198
+ ```
199
+
200
+ ### Monitoring Dashboard
201
+
202
+ ```ruby
203
+ # Monitor all running workflows
204
+ class WorkflowMonitor
205
+ def self.running_workflows
206
+ # Get all running job IDs from your queue adapter
207
+ # (Implementation depends on your queue backend)
208
+ running_job_ids = fetch_running_job_ids
209
+
210
+ running_job_ids.map do |job_id|
211
+ status = JobWorkflow::WorkflowStatus.find_by(job_id: job_id)
212
+ next unless status&.running?
213
+
214
+ {
215
+ job_id: job_id,
216
+ workflow: status.job_class_name,
217
+ current_task: status.current_task_name,
218
+ started_at: extract_start_time(status)
219
+ }
220
+ end.compact
221
+ end
222
+
223
+ def self.failed_workflows(since: 1.hour.ago)
224
+ # Implementation depends on your queue backend
225
+ # Query failed jobs and return their status
226
+ end
227
+ end
228
+ ```
229
+
230
+ ### Retry Failed Workflows
231
+
232
+ ```ruby
233
+ # Check if workflow failed and retry with same arguments
234
+ def retry_workflow_if_failed(job_id)
235
+ status = JobWorkflow::WorkflowStatus.find(job_id)
236
+
237
+ if status.failed?
238
+ # Get the original arguments
239
+ original_args = status.arguments.to_h
240
+
241
+ # Re-enqueue with same arguments
242
+ job_class = status.job_class_name.constantize
243
+ job_class.perform_later(**original_args)
244
+
245
+ puts "Retried workflow: #{status.job_class_name} with args: #{original_args}"
246
+ end
247
+ end
248
+ ```
249
+
250
+ ## Error Handling
251
+
252
+ ### NotFoundError
253
+
254
+ When using `find`, a `JobWorkflow::WorkflowStatus::NotFoundError` is raised if the job is not found:
255
+
256
+ ```ruby
257
+ begin
258
+ status = JobWorkflow::WorkflowStatus.find("invalid-job-id")
259
+ rescue JobWorkflow::WorkflowStatus::NotFoundError => e
260
+ Rails.logger.error "Workflow not found: #{e.message}"
261
+ # Handle the error appropriately
262
+ end
263
+ ```
264
+
265
+ ### Safe Queries
266
+
267
+ Use `find_by` for safe queries that return `nil` instead of raising:
268
+
269
+ ```ruby
270
+ status = JobWorkflow::WorkflowStatus.find_by(job_id: params[:job_id])
271
+
272
+ if status.nil?
273
+ render json: { error: "Workflow not found" }, status: :not_found
274
+ return
275
+ end
276
+
277
+ # Process status...
278
+ ```
279
+
280
+ ## Limitations and Considerations
281
+
282
+ 1. **Context Restoration**: Status information is restored from serialized job data. Only information stored in the job's context is available.
283
+
284
+ 2. **completed_tasks Not Included**: The `completed_tasks` field is not included in the status response as it can become stale due to dynamic updates during workflow execution. Use `job_status` to track task completion.
285
+
286
+ 3. **Queue Adapter Dependency**: The `find_job` functionality depends on the queue adapter implementation. Ensure your queue adapter supports job lookup by ID.
287
+
288
+ 4. **Performance**: Querying workflow status involves deserializing job data. For high-frequency status checks, consider caching or implementing a dedicated status tracking system.
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "job_workflow"
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JobWorkflow
4
+ class ArgumentDef
5
+ attr_reader :name #: Symbol
6
+ attr_reader :type #: String
7
+ attr_reader :default #: untyped
8
+
9
+ #: (name: Symbol, type: String, default: untyped) -> void
10
+ def initialize(name:, type:, default:)
11
+ @name = name
12
+ @type = type
13
+ @default = default
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JobWorkflow
4
+ class Arguments
5
+ #: (data: Hash[Symbol, untyped]) -> void
6
+ def initialize(data:)
7
+ @data = data.freeze
8
+ @reader_names = data.keys.to_set
9
+ end
10
+
11
+ #: (Hash[Symbol, untyped] other_data) -> Arguments
12
+ def merge(other_data)
13
+ merged = data.merge(other_data.slice(*reader_names.to_a))
14
+ self.class.new(data: merged)
15
+ end
16
+
17
+ #: (Symbol name, *untyped args, **untyped kwargs) ?{ () -> untyped } -> untyped
18
+ def method_missing(name, *args, **kwargs, &block)
19
+ return super unless args.empty? && kwargs.empty? && block.nil?
20
+ return super unless reader_names.include?(name.to_sym)
21
+
22
+ data[name.to_sym]
23
+ end
24
+
25
+ #: (Symbol sym, bool include_private) -> bool
26
+ def respond_to_missing?(sym, include_private)
27
+ reader_names.include?(sym.to_sym) || super
28
+ end
29
+
30
+ #: () -> Hash[Symbol, untyped]
31
+ def to_h
32
+ data
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :data #: Hash[Symbol, untyped]
38
+ attr_reader :reader_names #: Set[Symbol]
39
+ end
40
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module JobWorkflow
8
+ module AutoScaling
9
+ module Adapter
10
+ class AwsAdapter
11
+ #: (?ecs_client: Aws::ECS::Client?) -> void
12
+ def initialize(ecs_client: nil)
13
+ unless defined?(Aws::ECS::Client)
14
+ raise Error, "aws-sdk-ecs is required for JobWorkflow::AutoScaling::Adapter::AwsAdapter"
15
+ end
16
+
17
+ metadata_uri = ENV.fetch("ECS_CONTAINER_METADATA_URI_V4", nil)
18
+ raise Error, "ECS_CONTAINER_METADATA_URI_V4 is required on ECS" if metadata_uri.nil?
19
+
20
+ task_meta = JSON.parse(Net::HTTP.get(URI.parse("#{metadata_uri}/task")))
21
+
22
+ @ecs_client = ecs_client || Aws::ECS::Client.new
23
+ @cluster = task_meta.fetch("Cluster")
24
+ @task_arn = task_meta.fetch("TaskARN")
25
+ end
26
+
27
+ #: (Integer) -> Integer?
28
+ def update_desired_count(desired_count)
29
+ service = describe_service
30
+ return if service.desired_count == desired_count
31
+
32
+ update_service(service: service, desired_count: desired_count)
33
+ desired_count
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :ecs_client #: Aws::ECS::Client
39
+ attr_reader :cluster #: String
40
+ attr_reader :task_arn #: String
41
+
42
+ #: () -> String
43
+ def describe_service_name
44
+ task = ecs_client.describe_tasks({ cluster: cluster, tasks: [task_arn] }).tasks.first
45
+ raise Error, "Task(#{task_arn}) does not exist in cluster!" if task.nil?
46
+
47
+ task.group.delete_prefix("service:")
48
+ end
49
+
50
+ #: () -> Aws::ECS::Types::Service
51
+ def describe_service
52
+ service_name = describe_service_name
53
+ response = ecs_client.describe_services({ cluster: cluster, services: [service_name] })
54
+ response.services.first || (raise Error, "Service(#{service_name}) does not exist in cluster!")
55
+ end
56
+
57
+ #: (service: Aws::ECS::Types::Service, desired_count: Integer) -> Aws::ECS::Types::UpdateServiceResponse
58
+ def update_service(service:, desired_count:)
59
+ ecs_client.update_service(
60
+ { cluster: service.cluster_arn, service: service.service_name, desired_count: desired_count }
61
+ )
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "adapter/aws_adapter"
4
+
5
+ module JobWorkflow
6
+ module AutoScaling
7
+ module Adapter
8
+ # @rbs!
9
+ # interface _ClassMethods
10
+ # def new: () -> _InstanceMethods
11
+ # end
12
+ #
13
+ # interface _InstanceMethods
14
+ # def class: () -> _ClassMethods
15
+ # def update_desired_count: (Integer) -> Integer?
16
+ # end
17
+
18
+ ADAPTERS = {
19
+ aws: AwsAdapter
20
+ }.freeze #: Hash[Symbol, _ClassMethods]
21
+ private_constant :ADAPTERS
22
+
23
+ class << self
24
+ #: (Symbol) -> _ClassMethods
25
+ def fetch(adapter_name)
26
+ ADAPTERS.fetch(adapter_name)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JobWorkflow
4
+ module AutoScaling
5
+ class Configuration
6
+ attr_reader :queue_name #: String
7
+ attr_reader :min_count #: Integer
8
+ attr_reader :max_count #: Integer
9
+ attr_reader :step_count #: Integer
10
+ attr_reader :max_latency #: Integer
11
+
12
+ #
13
+ #: (
14
+ # ?queue_name: String,
15
+ # ?min_count: Integer,
16
+ # ?max_count: Integer,
17
+ # ?step_count: Integer,
18
+ # ?max_latency: Integer
19
+ # ) -> void
20
+ def initialize(
21
+ queue_name: "default",
22
+ min_count: 1,
23
+ max_count: 1,
24
+ step_count: 1,
25
+ max_latency: 3_600
26
+ )
27
+ self.queue_name = queue_name
28
+ self.min_count = min_count
29
+ self.max_count = max_count
30
+ self.step_count = step_count
31
+ self.max_latency = max_latency
32
+ end
33
+
34
+ #: () -> Integer
35
+ def latency_per_step_count
36
+ (max_latency / ((1 + max_count - min_count).to_f / step_count).ceil).tap do |value|
37
+ value.positive? || (raise Error, "latency per count isn't positive!")
38
+ end
39
+ end
40
+
41
+ #: (String) -> void
42
+ def queue_name=(queue_name)
43
+ raise ArgumentError unless queue_name.instance_of?(String)
44
+
45
+ @queue_name = queue_name
46
+ end
47
+
48
+ #: (Integer) -> void
49
+ def min_count=(min_count)
50
+ assert_positive_number!(min_count)
51
+
52
+ @min_count = min_count
53
+ end
54
+
55
+ #: (Integer) -> void
56
+ def max_count=(max_count)
57
+ assert_positive_number!(max_count)
58
+
59
+ @max_count = max_count
60
+ @min_count = max_count if max_count < min_count
61
+ end
62
+
63
+ #: (Integer) -> void
64
+ def step_count=(step_count)
65
+ assert_positive_number!(step_count)
66
+
67
+ @step_count = step_count
68
+ end
69
+
70
+ #: (Integer) -> void
71
+ def max_latency=(max_latency)
72
+ assert_positive_number!(max_latency)
73
+
74
+ @max_latency = max_latency
75
+ end
76
+
77
+ private
78
+
79
+ #: (Integer) -> void
80
+ def assert_positive_number!(number)
81
+ raise ArgumentError unless number.positive?
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JobWorkflow
4
+ module AutoScaling
5
+ class Executor
6
+ #: (Configuration) -> void
7
+ def initialize(config)
8
+ @config = config
9
+ end
10
+
11
+ #: () -> Integer?
12
+ def update_desired_count
13
+ adapter.update_desired_count(desired_count_by_latency)
14
+ end
15
+
16
+ private
17
+
18
+ attr_reader :config #: Configuration
19
+
20
+ #: () -> Adapter::_InstanceMethods
21
+ def adapter
22
+ @adapter ||= Adapter.fetch(:aws).new
23
+ end
24
+
25
+ #: () -> Integer?
26
+ def queue_latency
27
+ Queue.latency(config.queue_name)
28
+ end
29
+
30
+ #: () -> Integer
31
+ def desired_count_by_latency
32
+ latency = queue_latency || 0
33
+
34
+ desired_count_list.at((latency.to_f / config.latency_per_step_count).floor.to_i) || config.max_count
35
+ end
36
+
37
+ #: () -> Array[Integer]
38
+ def desired_count_list
39
+ config.min_count.step(config.max_count, config.step_count).to_a
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "auto_scaling/adapter"
4
+ require_relative "auto_scaling/configuration"
5
+ require_relative "auto_scaling/executor"
6
+
7
+ module JobWorkflow
8
+ # @example
9
+ # ```ruby
10
+ # class AutoScalingJob < ApplicationJob
11
+ # include JobWorkflow::AutoScaling
12
+ #
13
+ # target_queue_name "my_queue"
14
+ # min_count 2
15
+ # max_count 10
16
+ # step_count 2
17
+ # max_latency 1800
18
+ # end
19
+ # ```
20
+ module AutoScaling
21
+ extend ActiveSupport::Concern
22
+
23
+ # @rbs! extend ClassMethods
24
+
25
+ # @rbs!
26
+ # def class: () -> ClassMethods
27
+
28
+ included do
29
+ class_attribute :_config, instance_writer: false, default: Configuration.new
30
+ end
31
+
32
+ #: () -> void
33
+ def perform
34
+ Executor.new(self.class._config).update_desired_count
35
+ end
36
+
37
+ module ClassMethods
38
+ # @rbs!
39
+ # def class_attribute: (Symbol, ?instance_writer: bool, default: untyped) -> void
40
+ #
41
+ # def _config: () -> Configuration
42
+
43
+ #: (String) -> void
44
+ def target_queue_name(queue_name)
45
+ _config.queue_name = queue_name
46
+ end
47
+
48
+ #: (Integer) -> void
49
+ def min_count(min_count)
50
+ _config.min_count = min_count
51
+ end
52
+
53
+ #: (Integer) -> void
54
+ def max_count(max_count)
55
+ _config.max_count = max_count
56
+ end
57
+
58
+ #: (Integer) -> void
59
+ def step_count(step_count)
60
+ _config.step_count = step_count
61
+ end
62
+
63
+ #: (Integer) -> void
64
+ def max_latency(max_latency)
65
+ _config.max_latency = max_latency
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module JobWorkflow
4
+ module CacheStoreAdapters
5
+ NAMESPACE = "job_workflow" #: String
6
+ private_constant :NAMESPACE
7
+
8
+ DEFAULT_OPTIONS = { namespace: NAMESPACE }.freeze #: Hash[Symbol, untyped]
9
+ private_constant :DEFAULT_OPTIONS
10
+
11
+ # @rbs!
12
+ # def self._current: () -> ActiveSupport::Cache::Store
13
+ # def self._current=: (ActiveSupport::Cache::Store?) -> void
14
+
15
+ mattr_accessor :_current
16
+
17
+ class << self
18
+ #: () -> ActiveSupport::Cache::Store
19
+ def current
20
+ self._current ||= detect_adapter
21
+ end
22
+
23
+ #: () -> void
24
+ def reset!
25
+ self._current = nil
26
+ end
27
+
28
+ private
29
+
30
+ # @note
31
+ # - Rails.cache is NOT used directly because JobWorkflow requires namespace isolation from the Rails application's cache store.
32
+ # - JobWorkflow caches are namespaced with "job_workflow" prefix to prevent key collisions with application-level caches.
33
+ # - Using Rails.cache would share namespace configuration with the Rails app, which could lead to conflicts or unintended cache invalidations.
34
+ # - Instead, JobWorkflow creates dedicated ActiveSupport::Cache::Store instances with explicit namespace options.
35
+ #
36
+ #: () -> ActiveSupport::Cache::Store
37
+ def detect_adapter
38
+ if defined?(ActiveSupport::Cache::SolidCacheStore)
39
+ return ActiveSupport::Cache::SolidCacheStore.new(DEFAULT_OPTIONS.dup)
40
+ end
41
+
42
+ ActiveSupport::Cache::MemoryStore.new(DEFAULT_OPTIONS.dup)
43
+ end
44
+ end
45
+ end
46
+ end