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
data/guides/DRY_RUN.md
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
# Dry-Run Mode
|
|
2
|
+
|
|
3
|
+
Dry-run mode allows you to test workflows without executing side effects. This is useful for:
|
|
4
|
+
|
|
5
|
+
- Validating workflow logic before production deployment
|
|
6
|
+
- Testing data transformations without modifying external systems
|
|
7
|
+
- Debugging complex workflows safely
|
|
8
|
+
- CI/CD pipeline integration for workflow validation
|
|
9
|
+
|
|
10
|
+
## Basic Usage
|
|
11
|
+
|
|
12
|
+
### Enabling Dry-Run Mode at Workflow Level
|
|
13
|
+
|
|
14
|
+
Use the `dry_run` DSL method to enable dry-run mode for the entire workflow:
|
|
15
|
+
|
|
16
|
+
```ruby
|
|
17
|
+
class MyWorkflowJob < ActiveJob::Base
|
|
18
|
+
include JobWorkflow::DSL
|
|
19
|
+
|
|
20
|
+
# Always dry-run
|
|
21
|
+
dry_run true
|
|
22
|
+
|
|
23
|
+
task :send_email do |ctx|
|
|
24
|
+
if ctx.dry_run?
|
|
25
|
+
Rails.logger.info "[DRY-RUN] Would send email to #{ctx.arguments.email}"
|
|
26
|
+
else
|
|
27
|
+
Mailer.send_email(ctx.arguments.email)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Dynamic Dry-Run Configuration
|
|
34
|
+
|
|
35
|
+
Use a Proc to dynamically determine dry-run mode based on context:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
class MyWorkflowJob < ActiveJob::Base
|
|
39
|
+
include JobWorkflow::DSL
|
|
40
|
+
|
|
41
|
+
argument :dry_run_mode, "bool", default: false
|
|
42
|
+
|
|
43
|
+
# Enable dry-run based on argument
|
|
44
|
+
dry_run { |context| context.arguments.dry_run_mode }
|
|
45
|
+
|
|
46
|
+
task :process_data do |ctx|
|
|
47
|
+
# ctx.dry_run? returns true when dry_run_mode argument is true
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Task-Level Dry-Run
|
|
53
|
+
|
|
54
|
+
You can also configure dry-run at the task level:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
class MyWorkflowJob < ActiveJob::Base
|
|
58
|
+
include JobWorkflow::DSL
|
|
59
|
+
|
|
60
|
+
task :safe_operation do |ctx|
|
|
61
|
+
# Normal execution
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# This task always runs in dry-run mode
|
|
65
|
+
task :risky_operation, dry_run: true do |ctx|
|
|
66
|
+
ctx.skip_in_dry_run do
|
|
67
|
+
ExternalService.dangerous_call
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Priority Rules
|
|
74
|
+
|
|
75
|
+
When both workflow and task have dry-run configuration:
|
|
76
|
+
|
|
77
|
+
1. **Workflow-level `dry_run: true`** takes priority - all tasks run in dry-run mode
|
|
78
|
+
2. **Task-level settings** apply only when workflow doesn't enable dry-run
|
|
79
|
+
|
|
80
|
+
```ruby
|
|
81
|
+
class MyWorkflowJob < ActiveJob::Base
|
|
82
|
+
include JobWorkflow::DSL
|
|
83
|
+
|
|
84
|
+
# Workflow-level: always dry-run
|
|
85
|
+
dry_run true
|
|
86
|
+
|
|
87
|
+
# Even with dry_run: false, this task still runs in dry-run mode
|
|
88
|
+
# because workflow-level setting takes priority
|
|
89
|
+
task :task_one, dry_run: false do |ctx|
|
|
90
|
+
ctx.dry_run? # => true (workflow setting wins)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Checking Dry-Run Status
|
|
96
|
+
|
|
97
|
+
### Using `ctx.dry_run?`
|
|
98
|
+
|
|
99
|
+
The `dry_run?` method returns the current dry-run status:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
task :process_order do |ctx|
|
|
103
|
+
if ctx.dry_run?
|
|
104
|
+
Rails.logger.info "[DRY-RUN] Would process order: #{ctx.arguments.order_id}"
|
|
105
|
+
return
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
Order.process(ctx.arguments.order_id)
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Skipping Side Effects with `skip_in_dry_run`
|
|
113
|
+
|
|
114
|
+
The `skip_in_dry_run` method provides a convenient way to skip side effects in dry-run mode:
|
|
115
|
+
|
|
116
|
+
### Basic Usage
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
task :charge_customer do |ctx|
|
|
120
|
+
ctx.skip_in_dry_run do
|
|
121
|
+
PaymentGateway.charge(ctx.arguments.amount)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
In dry-run mode:
|
|
127
|
+
- The block is **not executed**
|
|
128
|
+
- Returns `nil` by default
|
|
129
|
+
|
|
130
|
+
In normal mode:
|
|
131
|
+
- The block is executed normally
|
|
132
|
+
- Returns the block's return value
|
|
133
|
+
|
|
134
|
+
### With Fallback Value
|
|
135
|
+
|
|
136
|
+
Specify a fallback value to return in dry-run mode:
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
task :get_payment_token do |ctx|
|
|
140
|
+
token = ctx.skip_in_dry_run(fallback: "dry_run_token_#{SecureRandom.hex(8)}") do
|
|
141
|
+
PaymentGateway.create_token(ctx.arguments.card_info)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
ctx.output[:payment_token] = token
|
|
145
|
+
end
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Named Dry-Run Operations
|
|
149
|
+
|
|
150
|
+
Use named operations for better instrumentation and debugging:
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
task :complex_operation do |ctx|
|
|
154
|
+
# Named operation for payment
|
|
155
|
+
ctx.skip_in_dry_run(:payment) do
|
|
156
|
+
PaymentGateway.charge(ctx.arguments.amount)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Named operation for notification
|
|
160
|
+
ctx.skip_in_dry_run(:notification) do
|
|
161
|
+
NotificationService.send(ctx.arguments.user_id, "Payment processed")
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Named Operations with Fallback Values
|
|
167
|
+
|
|
168
|
+
Combine operation names with fallback values for comprehensive testing:
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
task :process_payment do |ctx|
|
|
172
|
+
payment_result = ctx.skip_in_dry_run(
|
|
173
|
+
:payment_processing,
|
|
174
|
+
fallback: { success: true, transaction_id: "dry_run_#{Time.current.to_i}", amount: ctx.arguments.amount }
|
|
175
|
+
) do
|
|
176
|
+
PaymentService.process(
|
|
177
|
+
amount: ctx.arguments.amount,
|
|
178
|
+
customer_id: ctx.arguments.customer_id
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
ctx.output[:payment_result] = payment_result
|
|
183
|
+
end
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Instrumentation
|
|
187
|
+
|
|
188
|
+
Dry-run operations emit ActiveSupport::Notifications events for monitoring:
|
|
189
|
+
|
|
190
|
+
### Event: `dry_run.skip.job_workflow`
|
|
191
|
+
|
|
192
|
+
Emitted for each `skip_in_dry_run` call:
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
ActiveSupport::Notifications.subscribe("dry_run.skip.job_workflow") do |name, start, finish, id, payload|
|
|
196
|
+
puts "Dry-run operation: #{payload[:dry_run_name]}"
|
|
197
|
+
puts "Task: #{payload[:task_name]}"
|
|
198
|
+
puts "Index: #{payload[:dry_run_index]}"
|
|
199
|
+
puts "Skipped: #{payload[:dry_run]}"
|
|
200
|
+
end
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Payload includes:
|
|
204
|
+
- `job_id` - Job identifier
|
|
205
|
+
- `job_name` - Job class name
|
|
206
|
+
- `task_name` - Current task name
|
|
207
|
+
- `each_index` - Index in collection (for `each:` tasks)
|
|
208
|
+
- `dry_run_name` - Operation name (if provided)
|
|
209
|
+
- `dry_run_index` - Sequential index of skip_in_dry_run calls within the task
|
|
210
|
+
- `dry_run` - bool indicating if operation was skipped
|
|
211
|
+
|
|
212
|
+
## Logging
|
|
213
|
+
|
|
214
|
+
When using the default log subscriber, dry-run events are automatically logged:
|
|
215
|
+
|
|
216
|
+
```
|
|
217
|
+
[DRY-RUN] MyWorkflowJob#process_payment skip: payment (index: 0, skipped: true)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
## Dry-Run vs Condition
|
|
221
|
+
|
|
222
|
+
Dry-run mode and task conditions serve different purposes:
|
|
223
|
+
|
|
224
|
+
| Feature | Dry-Run | Condition |
|
|
225
|
+
|---------|---------|-----------|
|
|
226
|
+
| **Purpose** | Skip side effects for safe testing | Control workflow logic flow |
|
|
227
|
+
| **Scope** | Test/debug toggle for entire workflow or task | Control individual task execution |
|
|
228
|
+
| **Usage** | Validate structure without external calls | Branch workflow based on data |
|
|
229
|
+
| **With side effect** | Skips block execution (returns fallback) | Prevents task execution entirely |
|
|
230
|
+
| **Instrumentation** | Emits `dry_run.skip/execute` events | Emits `task.skip` event |
|
|
231
|
+
|
|
232
|
+
**When to use dry-run:**
|
|
233
|
+
```ruby
|
|
234
|
+
# Safe testing of workflow logic
|
|
235
|
+
ctx.skip_in_dry_run do
|
|
236
|
+
PaymentGateway.charge(amount)
|
|
237
|
+
end
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
**When to use condition:**
|
|
241
|
+
```ruby
|
|
242
|
+
# Control workflow flow based on data
|
|
243
|
+
task :send_email, condition: ->(ctx) { ctx.arguments.email_enabled } do |ctx|
|
|
244
|
+
Mailer.send_email(ctx.arguments.email)
|
|
245
|
+
end
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
You can combine both for comprehensive control:
|
|
249
|
+
```ruby
|
|
250
|
+
task :process_order, condition: ->(ctx) { ctx.arguments.order_id } do |ctx|
|
|
251
|
+
# Only runs if condition is true
|
|
252
|
+
# Within this task, you can still use skip_in_dry_run for side effects
|
|
253
|
+
ctx.skip_in_dry_run do
|
|
254
|
+
ExternalService.process(ctx.arguments.order_id)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
## Best Practices
|
|
260
|
+
|
|
261
|
+
### 1. Use Meaningful Operation Names
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
# Good - descriptive names help with debugging
|
|
265
|
+
ctx.skip_in_dry_run(:payment_processing) { ... }
|
|
266
|
+
ctx.skip_in_dry_run(:send_welcome_email) { ... }
|
|
267
|
+
|
|
268
|
+
# Avoid - unnamed operations are harder to trace
|
|
269
|
+
ctx.skip_in_dry_run { ... }
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### 2. Provide Realistic Fallback Values
|
|
273
|
+
|
|
274
|
+
```ruby
|
|
275
|
+
# Good - realistic fallback for testing
|
|
276
|
+
ctx.skip_in_dry_run(fallback: { id: "dry_run_123", status: "simulated" }) do
|
|
277
|
+
ExternalAPI.create_resource(data)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Consider - nil might cause issues in subsequent tasks
|
|
281
|
+
ctx.skip_in_dry_run do
|
|
282
|
+
ExternalAPI.create_resource(data)
|
|
283
|
+
end
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
### 3. Log Dry-Run Actions
|
|
287
|
+
|
|
288
|
+
```ruby
|
|
289
|
+
task :process_data do |ctx|
|
|
290
|
+
if ctx.dry_run?
|
|
291
|
+
Rails.logger.info "[DRY-RUN] Processing: #{ctx.arguments.inspect}"
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
ctx.skip_in_dry_run(:database_write) do
|
|
295
|
+
Database.write(ctx.arguments.data)
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### 4. Use Environment-Based Configuration
|
|
301
|
+
|
|
302
|
+
```ruby
|
|
303
|
+
class ProductionWorkflowJob < ActiveJob::Base
|
|
304
|
+
include JobWorkflow::DSL
|
|
305
|
+
|
|
306
|
+
# Dry-run in non-production environments
|
|
307
|
+
dry_run { |_ctx| !Rails.env.production? }
|
|
308
|
+
|
|
309
|
+
# Or based on feature flags
|
|
310
|
+
dry_run { |_ctx| FeatureFlag.enabled?(:workflow_dry_run) }
|
|
311
|
+
end
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
### 5. Test Both Modes
|
|
315
|
+
|
|
316
|
+
```ruby
|
|
317
|
+
RSpec.describe MyWorkflowJob do
|
|
318
|
+
context "in normal mode" do
|
|
319
|
+
it "executes side effects" do
|
|
320
|
+
expect(ExternalService).to receive(:call)
|
|
321
|
+
described_class.perform_now(dry_run_mode: false)
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
context "in dry-run mode" do
|
|
326
|
+
it "skips side effects" do
|
|
327
|
+
expect(ExternalService).not_to receive(:call)
|
|
328
|
+
described_class.perform_now(dry_run_mode: true)
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
## Example: Complete Workflow
|
|
335
|
+
|
|
336
|
+
```ruby
|
|
337
|
+
class OrderProcessingJob < ActiveJob::Base
|
|
338
|
+
include JobWorkflow::DSL
|
|
339
|
+
|
|
340
|
+
argument :order_id, "Integer"
|
|
341
|
+
argument :dry_run_mode, "bool", default: false
|
|
342
|
+
|
|
343
|
+
# Dynamic dry-run based on argument
|
|
344
|
+
dry_run { |ctx| ctx.arguments.dry_run_mode }
|
|
345
|
+
|
|
346
|
+
task :validate_order, output: { order: "Hash[Symbol, untyped]" } do |ctx|
|
|
347
|
+
order = Order.find(ctx.arguments.order_id)
|
|
348
|
+
{ order: order.attributes }
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
task :charge_payment, depends_on: [:validate_order] do |ctx|
|
|
352
|
+
order = ctx.output[:validate_order].first.order
|
|
353
|
+
|
|
354
|
+
result = ctx.skip_in_dry_run(:payment_processing, fallback: { success: true, transaction_id: "dry_run" }) do
|
|
355
|
+
PaymentService.process(
|
|
356
|
+
amount: order[:total],
|
|
357
|
+
customer_id: order[:customer_id]
|
|
358
|
+
)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
Rails.logger.info "[#{ctx.dry_run? ? 'DRY-RUN' : 'LIVE'}] Payment result: #{result}"
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
task :send_confirmation, depends_on: [:charge_payment] do |ctx|
|
|
365
|
+
order = ctx.output[:validate_order].first.order
|
|
366
|
+
|
|
367
|
+
ctx.skip_in_dry_run(:email_notification) do
|
|
368
|
+
OrderMailer.confirmation(order[:id]).deliver_later
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
task :update_inventory, depends_on: [:charge_payment] do |ctx|
|
|
373
|
+
order = ctx.output[:validate_order].first.order
|
|
374
|
+
|
|
375
|
+
ctx.skip_in_dry_run(:inventory_update) do
|
|
376
|
+
InventoryService.decrement(order[:items])
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Usage
|
|
382
|
+
OrderProcessingJob.perform_later(order_id: 123, dry_run_mode: true) # Dry-run
|
|
383
|
+
OrderProcessingJob.perform_later(order_id: 123, dry_run_mode: false) # Live
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
## See Also
|
|
387
|
+
|
|
388
|
+
- [DSL_BASICS.md](DSL_BASICS.md) - Task configuration basics
|
|
389
|
+
- [INSTRUMENTATION.md](INSTRUMENTATION.md) - Monitoring and observability
|
|
390
|
+
- [TESTING_STRATEGY.md](TESTING_STRATEGY.md) - Testing workflows
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# DSL Basics
|
|
2
|
+
|
|
3
|
+
## Defining Tasks
|
|
4
|
+
|
|
5
|
+
### Simple Task
|
|
6
|
+
|
|
7
|
+
The simplest task requires only a name and a block. Tasks can return outputs that are accessible to dependent tasks:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
task :simple_task, output: { result: "String" } do |ctx|
|
|
11
|
+
{ result: "completed" }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Access the output in another task
|
|
15
|
+
task :next_task, depends_on: [:simple_task] do |ctx|
|
|
16
|
+
result = ctx.output[:simple_task].first.result
|
|
17
|
+
puts result # => "completed"
|
|
18
|
+
end
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Specifying Dependencies
|
|
22
|
+
|
|
23
|
+
#### Single Dependency
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
task :fetch_data, output: { data: "Hash" } do |ctx|
|
|
27
|
+
{ data: API.fetch }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
task :process_data, depends_on: [:fetch_data], output: { result: "String" } do |ctx|
|
|
31
|
+
data = ctx.output[:fetch_data].first.data
|
|
32
|
+
{ result: process(data) }
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
#### Multiple Dependencies
|
|
37
|
+
|
|
38
|
+
```ruby
|
|
39
|
+
task :task_a, output: { a: "Integer" } do |ctx|
|
|
40
|
+
{ a: 1 }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
task :task_b, output: { b: "Integer" } do |ctx|
|
|
44
|
+
{ b: 2 }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
task :task_c, depends_on: [:task_a, :task_b], output: { result: "Integer" } do |ctx|
|
|
48
|
+
a = ctx.output[:task_a].first.a
|
|
49
|
+
b = ctx.output[:task_b].first.b
|
|
50
|
+
{ result: a + b } # => 3
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Dependency Resolution Order
|
|
55
|
+
|
|
56
|
+
JobWorkflow automatically topologically sorts dependencies.
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
# Correct order is executed regardless of definition order
|
|
60
|
+
task :step3, depends_on: [:step2], output: { final: "bool" } do |ctx|
|
|
61
|
+
{ final: true }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
task :step1, output: { initial: "bool" } do |ctx|
|
|
65
|
+
{ initial: true }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
task :step2, depends_on: [:step1], output: { middle: "bool" } do |ctx|
|
|
69
|
+
{ middle: true }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Execution order: step1 → step2 → step3
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Working with Arguments
|
|
76
|
+
|
|
77
|
+
### Defining Arguments
|
|
78
|
+
|
|
79
|
+
Type information is specified as **strings**. This is used for RBS generation and documentation; runtime type checking is not performed.
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
class TypedWorkflowJob < ApplicationJob
|
|
83
|
+
include JobWorkflow::DSL
|
|
84
|
+
|
|
85
|
+
# Type information specified as strings (for RBS generation)
|
|
86
|
+
argument :user_id, "Integer"
|
|
87
|
+
argument :email, "String"
|
|
88
|
+
argument :created_at, "Time"
|
|
89
|
+
argument :metadata, "Hash"
|
|
90
|
+
|
|
91
|
+
# Arrays and generics as strings too
|
|
92
|
+
argument :items, "Array[String]"
|
|
93
|
+
argument :config, "Hash[Symbol, String]"
|
|
94
|
+
|
|
95
|
+
# Fields with default values
|
|
96
|
+
argument :optional_field, "String", default: ""
|
|
97
|
+
end
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Accessing Arguments
|
|
101
|
+
|
|
102
|
+
**Arguments are immutable and read-only**. Access them via `ctx.arguments`:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
task :example do |ctx|
|
|
106
|
+
# Reading arguments
|
|
107
|
+
user_id = ctx.arguments.user_id
|
|
108
|
+
email = ctx.arguments.email
|
|
109
|
+
|
|
110
|
+
# Check if argument has value
|
|
111
|
+
if ctx.arguments.optional_field.present?
|
|
112
|
+
# Process
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**Important**: Arguments cannot be modified. To pass data between tasks, use task outputs:
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
# ✅ Correct: Use outputs to pass data
|
|
121
|
+
task :fetch, output: { result: "String" } do |ctx|
|
|
122
|
+
{ result: "data" }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
task :process, depends_on: [:fetch] do |ctx|
|
|
126
|
+
result = ctx.output[:fetch].first.result
|
|
127
|
+
process_data(result)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# ❌ Wrong: Cannot modify arguments
|
|
131
|
+
task :wrong do |ctx|
|
|
132
|
+
ctx.arguments.user_id = 123 # Error: Arguments are immutable
|
|
133
|
+
end
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Task Options
|
|
137
|
+
|
|
138
|
+
### Retry Configuration
|
|
139
|
+
|
|
140
|
+
```ruby
|
|
141
|
+
argument :api_key, "String"
|
|
142
|
+
|
|
143
|
+
# Simple retry (up to 3 times)
|
|
144
|
+
task :flaky_api, retry: 3, output: { response: "Hash" } do |ctx|
|
|
145
|
+
api_key = ctx.arguments.api_key
|
|
146
|
+
{ response: ExternalAPI.call(api_key) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Advanced retry configuration with exponential backoff
|
|
150
|
+
task :advanced_retry,
|
|
151
|
+
retry: {
|
|
152
|
+
count: 5, # Maximum retry attempts
|
|
153
|
+
strategy: :exponential, # :linear or :exponential
|
|
154
|
+
base_delay: 2, # Initial wait time in seconds
|
|
155
|
+
jitter: true # Add ±randomness to prevent thundering herd
|
|
156
|
+
},
|
|
157
|
+
output: { result: "String" } do |ctx|
|
|
158
|
+
{ result: unreliable_operation }
|
|
159
|
+
# Retry intervals: 2±1s, 4±2s, 8±4s, 16±8s, 32±16s
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Conditional Execution
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
argument :user, "User"
|
|
167
|
+
argument :amount, "Integer"
|
|
168
|
+
argument :verified, "bool"
|
|
169
|
+
|
|
170
|
+
# condition: Execute only if condition returns true
|
|
171
|
+
task :premium_feature,
|
|
172
|
+
condition: ->(ctx) { ctx.arguments.user.premium? },
|
|
173
|
+
output: { premium_result: "String" } do |ctx|
|
|
174
|
+
{ premium_result: premium_process }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Inverse condition using negation
|
|
178
|
+
task :free_tier_limit,
|
|
179
|
+
condition: ->(ctx) { !ctx.arguments.user.premium? },
|
|
180
|
+
output: { limited_result: "String" } do |ctx|
|
|
181
|
+
{ limited_result: limited_process }
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Complex condition
|
|
185
|
+
task :complex,
|
|
186
|
+
condition: ->(ctx) { ctx.arguments.amount > 1000 && ctx.arguments.verified },
|
|
187
|
+
output: { vip_process: "bool" } do |ctx|
|
|
188
|
+
{ vip_process: true }
|
|
189
|
+
end
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Throttling
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
argument :api_params, "Hash"
|
|
196
|
+
|
|
197
|
+
# Simple syntax: Integer (recommended)
|
|
198
|
+
task :api_call,
|
|
199
|
+
throttle: 10, # Max 10 concurrent executions, default key
|
|
200
|
+
output: { response: "Hash" } do |ctx|
|
|
201
|
+
params = ctx.arguments.api_params
|
|
202
|
+
{ response: RateLimitedAPI.call(params) }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Advanced syntax: Hash
|
|
206
|
+
task :api_call_advanced,
|
|
207
|
+
throttle: {
|
|
208
|
+
key: "external_api", # Custom semaphore key
|
|
209
|
+
limit: 10, # Concurrency limit
|
|
210
|
+
ttl: 120 # Lease TTL in seconds (default: 180)
|
|
211
|
+
},
|
|
212
|
+
output: { response: "Hash" } do |ctx|
|
|
213
|
+
params = ctx.arguments.api_params
|
|
214
|
+
{ response: RateLimitedAPI.call(params) }
|
|
215
|
+
end
|
|
216
|
+
```
|