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,240 @@
|
|
|
1
|
+
# Task Outputs
|
|
2
|
+
|
|
3
|
+
JobWorkflow allows tasks to define and collect outputs, making it easy to access task execution results. This is particularly useful when you need to use results from previous tasks in subsequent tasks or when collecting results from parallel map tasks.
|
|
4
|
+
|
|
5
|
+
## Defining Task Outputs
|
|
6
|
+
|
|
7
|
+
Use the `output:` option to define the structure of task outputs. Specify output field names and their types as a hash.
|
|
8
|
+
|
|
9
|
+
### Basic Output Definition
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
class DataProcessingJob < ApplicationJob
|
|
13
|
+
include JobWorkflow::DSL
|
|
14
|
+
|
|
15
|
+
argument :input_value, "Integer", default: 0
|
|
16
|
+
|
|
17
|
+
# Define task with outputs
|
|
18
|
+
task :calculate, output: { result: "Integer", message: "String" } do |ctx|
|
|
19
|
+
input_value = ctx.arguments.input_value
|
|
20
|
+
# Return a hash with the defined keys
|
|
21
|
+
{
|
|
22
|
+
result: input_value * 2,
|
|
23
|
+
message: "Calculation complete"
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Access the output from another task
|
|
28
|
+
task :report, depends_on: [:calculate] do |ctx|
|
|
29
|
+
puts "Result: #{ctx.output[:calculate].first.result}"
|
|
30
|
+
puts "Message: #{ctx.output[:calculate].first.message}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### Output with Map Tasks
|
|
36
|
+
|
|
37
|
+
Outputs from map tasks are collected as an array, with one output per iteration.
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
class BatchCalculationJob < ApplicationJob
|
|
41
|
+
include JobWorkflow::DSL
|
|
42
|
+
|
|
43
|
+
argument :numbers, "Array[Integer]", default: []
|
|
44
|
+
|
|
45
|
+
# Map task with output definition
|
|
46
|
+
task :double_numbers,
|
|
47
|
+
each: ->(ctx) { ctx.arguments.numbers },
|
|
48
|
+
output: { doubled: "Integer", original: "Integer" } do |ctx|
|
|
49
|
+
value = ctx.each_value
|
|
50
|
+
{
|
|
51
|
+
doubled: value * 2,
|
|
52
|
+
original: value
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Access all outputs from the map task
|
|
57
|
+
task :summarize, depends_on: [:double_numbers] do |ctx|
|
|
58
|
+
ctx.output[:double_numbers].each do |output|
|
|
59
|
+
puts "Original: #{output.original}, Doubled: #{output.doubled}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Calculate total
|
|
63
|
+
total = ctx.output[:double_numbers].sum(&:doubled)
|
|
64
|
+
puts "Total: #{total}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Execution
|
|
69
|
+
BatchCalculationJob.perform_now(numbers: [1, 2, 3, 4, 5])
|
|
70
|
+
# Output:
|
|
71
|
+
# Original: 1, Doubled: 2
|
|
72
|
+
# Original: 2, Doubled: 4
|
|
73
|
+
# Original: 3, Doubled: 6
|
|
74
|
+
# Original: 4, Doubled: 8
|
|
75
|
+
# Original: 5, Doubled: 10
|
|
76
|
+
# Total: 30
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Accessing Task Outputs
|
|
80
|
+
|
|
81
|
+
Task outputs are accessible through `ctx.output` using `[]` with the task name. It always returns an Array of TaskOutput-like objects.
|
|
82
|
+
|
|
83
|
+
### Regular Task Output
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
task :fetch_data, output: { count: "Integer", items: "Array" } do |ctx|
|
|
87
|
+
data = ExternalAPI.fetch
|
|
88
|
+
{
|
|
89
|
+
count: data.size,
|
|
90
|
+
items: data
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
task :process, depends_on: [:fetch_data] do |ctx|
|
|
95
|
+
# Access output fields directly
|
|
96
|
+
puts "Received #{ctx.output[:fetch_data].first.count} items"
|
|
97
|
+
ctx.output[:fetch_data].first.items.each do |item|
|
|
98
|
+
process_item(item)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Map Task Output Array
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
task :process_items,
|
|
107
|
+
each: ->(ctx) { ctx.arguments.items },
|
|
108
|
+
output: { result: "String", status: "String" } do |ctx|
|
|
109
|
+
item = ctx.each_value
|
|
110
|
+
{
|
|
111
|
+
result: transform(item),
|
|
112
|
+
status: "success"
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
task :verify, depends_on: [:process_items] do |ctx|
|
|
117
|
+
# outputs is an array of TaskOutput objects
|
|
118
|
+
outputs = ctx.output[:process_items]
|
|
119
|
+
|
|
120
|
+
successful = outputs.count { |o| o.status == "success" }
|
|
121
|
+
puts "Processed #{outputs.size} items, #{successful} successful"
|
|
122
|
+
|
|
123
|
+
# Access individual outputs by index
|
|
124
|
+
first_result = outputs[0].result
|
|
125
|
+
last_result = outputs[-1].result
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Output Field Normalization
|
|
130
|
+
|
|
131
|
+
Task outputs are automatically normalized based on the output definition:
|
|
132
|
+
|
|
133
|
+
1. **Only defined fields are collected**: Fields not in the output definition are ignored
|
|
134
|
+
2. **Missing fields default to nil**: If a defined field is not returned, it defaults to `nil`
|
|
135
|
+
3. **Type safety**: Output definitions document expected types for better code clarity
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
task :example, output: { required: "String", optional: "Integer" } do |ctx|
|
|
139
|
+
# Only return one field
|
|
140
|
+
{ required: "value" }
|
|
141
|
+
# optional will be nil
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
task :check_output, depends_on: [:example] do |ctx|
|
|
145
|
+
puts ctx.output[:example].first.required # => "value"
|
|
146
|
+
puts ctx.output[:example].first.optional # => nil
|
|
147
|
+
end
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
## Output Persistence
|
|
151
|
+
|
|
152
|
+
Task outputs are automatically serialized and persisted with the Context, allowing them to:
|
|
153
|
+
|
|
154
|
+
- **Survive job restarts**: Outputs are preserved across job retries
|
|
155
|
+
- **Resume correctly**: When using continuations, outputs from completed tasks are available
|
|
156
|
+
- **Pass between jobs**: In map tasks with concurrency, outputs from subjobs are collected
|
|
157
|
+
|
|
158
|
+
## Output Design Guidelines
|
|
159
|
+
|
|
160
|
+
### When to Use Outputs
|
|
161
|
+
|
|
162
|
+
Use task outputs when you need to:
|
|
163
|
+
|
|
164
|
+
- **Extract structured data** from a task for use in later tasks
|
|
165
|
+
- **Collect results** from parallel map task executions
|
|
166
|
+
- **Document return values** with types for better code clarity
|
|
167
|
+
- **Separate concerns** between task execution and result usage
|
|
168
|
+
|
|
169
|
+
### When to Use Context Instead
|
|
170
|
+
|
|
171
|
+
Use Context fields when you need to:
|
|
172
|
+
|
|
173
|
+
- **Share mutable state** that tasks modify incrementally
|
|
174
|
+
- **Pass configuration** or settings through the workflow
|
|
175
|
+
- **Store final results** that are the primary goal of the workflow
|
|
176
|
+
|
|
177
|
+
### Best Practices
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
class WellDesignedJob < ApplicationJob
|
|
181
|
+
include JobWorkflow::DSL
|
|
182
|
+
|
|
183
|
+
# Arguments for configuration
|
|
184
|
+
argument :user_id, "Integer"
|
|
185
|
+
|
|
186
|
+
# Use outputs for intermediate structured data
|
|
187
|
+
task :fetch_user,
|
|
188
|
+
output: { name: "String", email: "String", role: "String" } do |ctx|
|
|
189
|
+
user = User.find(ctx.arguments.user_id)
|
|
190
|
+
{
|
|
191
|
+
name: user.name,
|
|
192
|
+
email: user.email,
|
|
193
|
+
role: user.role
|
|
194
|
+
}
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
task :fetch_permissions,
|
|
198
|
+
depends_on: [:fetch_user],
|
|
199
|
+
output: { permissions: "Array[String]" } do |ctx|
|
|
200
|
+
role = ctx.output[:fetch_user].first.role
|
|
201
|
+
{
|
|
202
|
+
permissions: PermissionService.get_permissions(role)
|
|
203
|
+
}
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Build final report as output
|
|
207
|
+
task :generate_report,
|
|
208
|
+
depends_on: [:fetch_user, :fetch_permissions],
|
|
209
|
+
output: { final_report: "Hash" } do |ctx|
|
|
210
|
+
user = ctx.output[:fetch_user].first
|
|
211
|
+
perms = ctx.output[:fetch_permissions].first
|
|
212
|
+
|
|
213
|
+
{
|
|
214
|
+
final_report: {
|
|
215
|
+
user: { name: user.name, email: user.email },
|
|
216
|
+
permissions: perms.permissions,
|
|
217
|
+
generated_at: Time.current
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## Limitations
|
|
225
|
+
|
|
226
|
+
### Arguments are Immutable
|
|
227
|
+
|
|
228
|
+
Arguments cannot be modified during workflow execution. To pass data between tasks, use task outputs:
|
|
229
|
+
|
|
230
|
+
```ruby
|
|
231
|
+
# ✅ Correct: Use outputs
|
|
232
|
+
task :process, output: { result: "String" } do |ctx|
|
|
233
|
+
{ result: "processed" }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# ❌ Wrong: Cannot modify arguments
|
|
237
|
+
task :wrong do |ctx|
|
|
238
|
+
ctx.arguments.result = "value" # Error!
|
|
239
|
+
end
|
|
240
|
+
```
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Testing Strategy
|
|
2
|
+
|
|
3
|
+
This section covers effective testing methods for workflows built with JobWorkflow.
|
|
4
|
+
|
|
5
|
+
## Unit Testing
|
|
6
|
+
|
|
7
|
+
### Testing Individual Tasks
|
|
8
|
+
|
|
9
|
+
Test each task as a unit.
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
# spec/jobs/user_registration_job_spec.rb
|
|
13
|
+
RSpec.describe UserRegistrationJob do
|
|
14
|
+
describe 'task: validate_email' do
|
|
15
|
+
it 'validates correct email format' do
|
|
16
|
+
job = described_class.new
|
|
17
|
+
arguments = JobWorkflow::Arguments.new(email: 'user@example.com')
|
|
18
|
+
ctx = JobWorkflow::Context.new(arguments: arguments)
|
|
19
|
+
|
|
20
|
+
task = described_class._workflow_tasks[:validate_email]
|
|
21
|
+
expect { job.instance_exec(ctx, &task[:block]) }.not_to raise_error
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'raises error for invalid email' do
|
|
25
|
+
job = described_class.new
|
|
26
|
+
arguments = JobWorkflow::Arguments.new(email: 'invalid')
|
|
27
|
+
ctx = JobWorkflow::Context.new(arguments: arguments)
|
|
28
|
+
|
|
29
|
+
task = described_class._workflow_tasks[:validate_email]
|
|
30
|
+
expect { job.instance_exec(ctx, &task[:block]) }.to raise_error(/Invalid email/)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
describe 'task: create_user' do
|
|
35
|
+
it 'creates a new user' do
|
|
36
|
+
job = described_class.new
|
|
37
|
+
arguments = JobWorkflow::Arguments.new(
|
|
38
|
+
email: 'user@example.com',
|
|
39
|
+
password: 'password123'
|
|
40
|
+
)
|
|
41
|
+
ctx = JobWorkflow::Context.new(arguments: arguments)
|
|
42
|
+
|
|
43
|
+
task = described_class._workflow_tasks[:create_user]
|
|
44
|
+
|
|
45
|
+
expect {
|
|
46
|
+
job.instance_exec(ctx, &task[:block])
|
|
47
|
+
}.to change(User, :count).by(1)
|
|
48
|
+
|
|
49
|
+
# Verify output
|
|
50
|
+
output = ctx.output[:create_user].first
|
|
51
|
+
expect(output.user).to be_a(User)
|
|
52
|
+
expect(output.user.email).to eq('user@example.com')
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
```
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# Throttling
|
|
2
|
+
|
|
3
|
+
JobWorkflow provides semaphore-based throttling to handle external API rate limits and protect shared resources. Throttling works across multiple jobs and workers, ensuring system-wide rate limiting.
|
|
4
|
+
|
|
5
|
+
## Task-Level Throttling
|
|
6
|
+
|
|
7
|
+
### Simple Integer Syntax (Recommended)
|
|
8
|
+
|
|
9
|
+
For most use cases, specify the concurrency limit as an integer:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
class ExternalAPIJob < ApplicationJob
|
|
13
|
+
include JobWorkflow::DSL
|
|
14
|
+
|
|
15
|
+
argument :user_ids, "Array[Integer]"
|
|
16
|
+
|
|
17
|
+
# Allow up to 10 concurrent executions of this task
|
|
18
|
+
# Default key: "ExternalAPIJob:fetch_user_data"
|
|
19
|
+
# Default TTL: 180 seconds
|
|
20
|
+
task :fetch_user_data, throttle: 10, each: ->(ctx) { ctx.arguments.user_ids }, output: { user_data: "Hash" } do |ctx|
|
|
21
|
+
{ user_data: ExternalAPI.fetch_user(ctx.each_value) }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Hash Syntax (Advanced Configuration)
|
|
27
|
+
|
|
28
|
+
For detailed control, use the hash syntax:
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
task :fetch_user_data,
|
|
32
|
+
throttle: {
|
|
33
|
+
key: "external_user_api", # Custom semaphore key
|
|
34
|
+
limit: 10, # Concurrency limit
|
|
35
|
+
ttl: 120 # Lease TTL in seconds (default: 180)
|
|
36
|
+
},
|
|
37
|
+
output: { api_results: "Hash" } do |ctx|
|
|
38
|
+
{ api_results: ExternalAPI.fetch_user(ctx.arguments.user_id) }
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Sharing Throttle Keys Across Jobs
|
|
43
|
+
|
|
44
|
+
Use the same `key` to share rate limits across different jobs and tasks:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
# Both jobs share the same "payment_api" throttle limit
|
|
48
|
+
class CreateUserJob < ApplicationJob
|
|
49
|
+
include JobWorkflow::DSL
|
|
50
|
+
|
|
51
|
+
argument :user_data, "Hash"
|
|
52
|
+
|
|
53
|
+
task :create_customer, throttle: { key: "payment_api", limit: 5 } do |ctx|
|
|
54
|
+
PaymentService.create_customer(ctx.arguments.user_data)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class UpdateBillingJob < ApplicationJob
|
|
59
|
+
include JobWorkflow::DSL
|
|
60
|
+
|
|
61
|
+
argument :billing_id, "String"
|
|
62
|
+
|
|
63
|
+
task :update_billing, throttle: { key: "payment_api", limit: 5 } do |ctx|
|
|
64
|
+
PaymentService.update_billing(ctx.arguments.billing_id)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Total concurrent calls to payment API: max 5 across both jobs
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Throttling Behavior
|
|
72
|
+
|
|
73
|
+
1. Acquire semaphore lease before task execution
|
|
74
|
+
2. If lease cannot be acquired, wait (automatic polling with 3-second intervals)
|
|
75
|
+
3. Execute task
|
|
76
|
+
4. Release lease after completion (guaranteed by ensure block)
|
|
77
|
+
5. If a worker crashes before releasing, the lease is recovered after `ttl` expires and the SolidQueue dispatcher concurrency maintenance runs (worst case: `ttl + concurrency_maintenance_interval`)
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
argument :data, "Hash"
|
|
81
|
+
|
|
82
|
+
# Example: Task with max 3 concurrent executions
|
|
83
|
+
task :limited_task, throttle: 3, output: { result: "String" } do |ctx|
|
|
84
|
+
data = ctx.arguments.data
|
|
85
|
+
{ result: SharedResource.use(data) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Execution state:
|
|
89
|
+
# Job 1: Acquire lease → Executing
|
|
90
|
+
# Job 2: Acquire lease → Executing
|
|
91
|
+
# Job 3: Acquire lease → Executing
|
|
92
|
+
# Job 4: Waiting (no lease available)
|
|
93
|
+
# Job 1: Complete → Release lease
|
|
94
|
+
# Job 4: Acquire lease → Executing
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Throttling with Map Tasks
|
|
98
|
+
|
|
99
|
+
Throttling is especially useful with map tasks to limit API calls:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
class BatchFetchJob < ApplicationJob
|
|
103
|
+
include JobWorkflow::DSL
|
|
104
|
+
|
|
105
|
+
argument :ids, "Array[Integer]"
|
|
106
|
+
|
|
107
|
+
# Each iteration waits for a throttle slot
|
|
108
|
+
task :fetch_all, throttle: 5, each: ->(ctx) { ctx.arguments.ids }, output: { data: "Hash" } do |ctx|
|
|
109
|
+
{ data: RateLimitedAPI.fetch(ctx.each_value) }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# With 100 IDs and throttle: 5
|
|
114
|
+
# → Max 5 concurrent API calls at any time
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Runtime Throttling
|
|
118
|
+
|
|
119
|
+
For fine-grained control within a task, use the `ctx.throttle` method to wrap specific code blocks. This method can only be called inside a task block; calling it outside will raise an error.
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
class ComplexProcessingJob < ApplicationJob
|
|
123
|
+
include JobWorkflow::DSL
|
|
124
|
+
|
|
125
|
+
argument :data, "Hash"
|
|
126
|
+
|
|
127
|
+
task :process_and_save do |ctx|
|
|
128
|
+
# Read operations - no throttle needed
|
|
129
|
+
data = ExternalAPI.fetch(ctx.arguments.data[:id])
|
|
130
|
+
|
|
131
|
+
# Write operations - throttled
|
|
132
|
+
ctx.throttle(limit: 3, key: "db_write") do
|
|
133
|
+
Model.create!(data)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Multiple Throttle Blocks
|
|
140
|
+
|
|
141
|
+
Apply different rate limits to different operations within the same task:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
task :multi_api_task do |ctx|
|
|
145
|
+
# Payment API: max 5 concurrent
|
|
146
|
+
ctx.throttle(limit: 5, key: "payment_api") do
|
|
147
|
+
PaymentService.process(ctx.arguments.payment_data)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Notification API: max 10 concurrent
|
|
151
|
+
ctx.throttle(limit: 10, key: "notification_api") do
|
|
152
|
+
NotificationService.send(ctx.arguments.message_params)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Auto-Generated Keys
|
|
158
|
+
|
|
159
|
+
When `key` is omitted, a unique key is generated automatically based on the job name, task name, and call index. The index resets to 0 for each task execution:
|
|
160
|
+
|
|
161
|
+
```ruby
|
|
162
|
+
task :sequential_operations do |ctx|
|
|
163
|
+
# Key: "MyJob:sequential_operations:0"
|
|
164
|
+
ctx.throttle(limit: 5) do
|
|
165
|
+
first_operation
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Key: "MyJob:sequential_operations:1"
|
|
169
|
+
ctx.throttle(limit: 5) do
|
|
170
|
+
second_operation
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Combining Task-Level and Runtime Throttling
|
|
176
|
+
|
|
177
|
+
Use both approaches for comprehensive rate limiting:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
class APIIntegrationJob < ApplicationJob
|
|
181
|
+
include JobWorkflow::DSL
|
|
182
|
+
|
|
183
|
+
argument :ids, "Array[Integer]"
|
|
184
|
+
|
|
185
|
+
# Task-level throttle: limits overall task concurrency
|
|
186
|
+
task :process_items, throttle: 10, each: ->(ctx) { ctx.arguments.ids } do |ctx|
|
|
187
|
+
|
|
188
|
+
data = ExternalAPI.fetch(ctx.each_value)
|
|
189
|
+
|
|
190
|
+
# Runtime throttle: limits specific write operations
|
|
191
|
+
ctx.throttle(limit: 3, key: "cache_write") do
|
|
192
|
+
CacheStorage.update(ctx.each_value, data)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
data
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
```
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# Troubleshooting
|
|
2
|
+
|
|
3
|
+
This section covers common issues encountered during JobWorkflow operation and their solutions.
|
|
4
|
+
|
|
5
|
+
## Common Issues
|
|
6
|
+
|
|
7
|
+
### CircularDependencyError
|
|
8
|
+
|
|
9
|
+
**Symptom**: Workflow crashes with `JobWorkflow::CircularDependencyError`
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
# ❌ Circular dependency
|
|
13
|
+
task :a, depends_on: [:b] do |ctx|
|
|
14
|
+
# ...
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
task :b, depends_on: [:a] do |ctx|
|
|
18
|
+
# ...
|
|
19
|
+
end
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**Solution**: Review and remove circular dependency
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
# ✅ Correct dependency
|
|
26
|
+
task :a do |ctx|
|
|
27
|
+
# ...
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
task :b, depends_on: [:a] do |ctx|
|
|
31
|
+
# ...
|
|
32
|
+
end
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### UnknownTaskError
|
|
36
|
+
|
|
37
|
+
**Symptom**: `JobWorkflow::UnknownTaskError: Unknown task: :typo_task`
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
# ❌ Depending on non-existent task
|
|
41
|
+
task :process, depends_on: [:typo_task] do |ctx|
|
|
42
|
+
# ...
|
|
43
|
+
end
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Solution**: Fix task name typo
|
|
47
|
+
|
|
48
|
+
```ruby
|
|
49
|
+
# ✅ Correct task name
|
|
50
|
+
task :process, depends_on: [:correct_task] do |ctx|
|
|
51
|
+
# ...
|
|
52
|
+
end
|
|
53
|
+
```
|