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,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.
|
data/lib/job-workflow.rb
ADDED
|
@@ -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
|