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,131 @@
|
|
|
1
|
+
# Instrumentation
|
|
2
|
+
|
|
3
|
+
JobWorkflow provides a comprehensive instrumentation system built on `ActiveSupport::Notifications`. This enables:
|
|
4
|
+
|
|
5
|
+
- **Structured Logging**: Automatic JSON log output for all workflow events
|
|
6
|
+
- **OpenTelemetry Integration**: Distributed tracing with span creation
|
|
7
|
+
- **Custom Subscribers**: Build your own event handlers
|
|
8
|
+
|
|
9
|
+
## Architecture
|
|
10
|
+
|
|
11
|
+
JobWorkflow uses `ActiveSupport::Notifications` as the single event source, with subscribers handling the events:
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
┌─────────────────────┐
|
|
15
|
+
│ JobWorkflow Core │
|
|
16
|
+
│ (Runner/Context) │
|
|
17
|
+
└─────────┬───────────┘
|
|
18
|
+
│ instrument("task.job_workflow", payload)
|
|
19
|
+
▼
|
|
20
|
+
┌─────────────────────────────────────┐
|
|
21
|
+
│ ActiveSupport::Notifications │
|
|
22
|
+
│ (Event Bus) │
|
|
23
|
+
└─────────┬──────────┬────────────────┘
|
|
24
|
+
│ │
|
|
25
|
+
▼ ▼
|
|
26
|
+
┌─────────────┐ ┌──────────────────┐
|
|
27
|
+
│ LogSubscriber│ │ OpenTelemetry │
|
|
28
|
+
│ (built-in) │ │ Subscriber │
|
|
29
|
+
└─────────────┘ └──────────────────┘
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Event Types
|
|
33
|
+
|
|
34
|
+
JobWorkflow emits multiple events for each operation to support both tracing and logging:
|
|
35
|
+
|
|
36
|
+
### Tracing Events (for OpenTelemetry spans)
|
|
37
|
+
|
|
38
|
+
| Event Name | Description | Key Payload Fields |
|
|
39
|
+
|------------|-------------|-------------------|
|
|
40
|
+
| `workflow.job_workflow` | Workflow execution span | `job_name`, `job_id`, `duration_ms` |
|
|
41
|
+
| `task.job_workflow` | Task execution span | `task_name`, `each_index`, `retry_count`, `duration_ms` |
|
|
42
|
+
| `throttle.acquire.job_workflow` | Semaphore acquisition span | `concurrency_key`, `concurrency_limit`, `duration_ms` |
|
|
43
|
+
| `dependent.wait.job_workflow` | Dependency wait span | `dependent_task_name`, `duration_ms` |
|
|
44
|
+
|
|
45
|
+
### Logging Events (for structured logs)
|
|
46
|
+
|
|
47
|
+
| Event Name | Description | Key Payload Fields |
|
|
48
|
+
|------------|-------------|-------------------|
|
|
49
|
+
| `workflow.start.job_workflow` | Workflow started | `job_name`, `job_id` |
|
|
50
|
+
| `workflow.complete.job_workflow` | Workflow completed | `job_name`, `job_id` |
|
|
51
|
+
| `task.start.job_workflow` | Task started | `task_name`, `each_index`, `retry_count` |
|
|
52
|
+
| `task.complete.job_workflow` | Task completed | `task_name`, `each_index`, `retry_count` |
|
|
53
|
+
| `task.error.job_workflow` | Task error (used by runner) | `task_name`, `error_class`, `error_message` |
|
|
54
|
+
| `task.skip.job_workflow` | Task skipped | `task_name`, `reason` |
|
|
55
|
+
| `task.enqueue.job_workflow` | Sub-jobs enqueued | `task_name`, `sub_job_count` |
|
|
56
|
+
| `task.retry.job_workflow` | Task retry | `task_name`, `attempt`, `max_attempts`, `delay_seconds`, `error_class` |
|
|
57
|
+
| `throttle.acquire.start.job_workflow` | Semaphore acquisition started | `concurrency_key`, `concurrency_limit` |
|
|
58
|
+
| `throttle.acquire.complete.job_workflow` | Semaphore acquisition completed | `concurrency_key`, `concurrency_limit` |
|
|
59
|
+
| `throttle.release.job_workflow` | Semaphore released | `concurrency_key`, `concurrency_limit` |
|
|
60
|
+
| `dependent.wait.start.job_workflow` | Dependency wait started | `dependent_task_name` |
|
|
61
|
+
| `dependent.wait.complete.job_workflow` | Dependency wait completed | `dependent_task_name` |
|
|
62
|
+
|
|
63
|
+
## Custom Event Instrumentation
|
|
64
|
+
|
|
65
|
+
Use `ctx.instrument` within tasks to create custom spans:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
class DataProcessingJob < ApplicationJob
|
|
69
|
+
include JobWorkflow::DSL
|
|
70
|
+
|
|
71
|
+
task :fetch_data do |ctx|
|
|
72
|
+
# Create a custom instrumented span for API calls
|
|
73
|
+
ctx.instrument("api_call", endpoint: "/users", method: "GET") do
|
|
74
|
+
HTTP.get("https://api.example.com/users")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
task :process_items, each: -> (ctx) { ctx.args.items } do |ctx|
|
|
79
|
+
ctx.instrument("item_processing", item_id: ctx.each_value[:id]) do
|
|
80
|
+
process_item(ctx.each_value)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Custom events are published as `<operation>.job_workflow` and include:
|
|
87
|
+
- `job_id`, `job_name`, `task_name`, `each_index` (automatic)
|
|
88
|
+
- Any custom fields you provide
|
|
89
|
+
- `duration_ms` (automatic)
|
|
90
|
+
|
|
91
|
+
## Subscribing to Events
|
|
92
|
+
|
|
93
|
+
### Using ActiveSupport::Notifications
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
# config/initializers/job_workflow_monitoring.rb
|
|
97
|
+
|
|
98
|
+
# Subscribe to all JobWorkflow events
|
|
99
|
+
ActiveSupport::Notifications.subscribe(/\.job_workflow$/) do |name, start, finish, id, payload|
|
|
100
|
+
duration = (finish - start) * 1000
|
|
101
|
+
Rails.logger.info("JobWorkflow event: #{name}, duration: #{duration}ms")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Subscribe to specific events
|
|
105
|
+
ActiveSupport::Notifications.subscribe("task.retry.job_workflow") do |*args|
|
|
106
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
107
|
+
Bugsnag.notify("Task retry: #{event.payload[:task_name]}")
|
|
108
|
+
end
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
### Custom Metrics Collection
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
# Send metrics to StatsD/Datadog
|
|
115
|
+
ActiveSupport::Notifications.subscribe("task.job_workflow") do |*args|
|
|
116
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
117
|
+
StatsD.timing(
|
|
118
|
+
"job_workflow.task.duration",
|
|
119
|
+
event.duration,
|
|
120
|
+
tags: ["task:#{event.payload[:task_name]}", "job:#{event.payload[:job_name]}"]
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
ActiveSupport::Notifications.subscribe("task.retry.job_workflow") do |*args|
|
|
125
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
126
|
+
StatsD.increment(
|
|
127
|
+
"job_workflow.task.retry",
|
|
128
|
+
tags: ["task:#{event.payload[:task_name]}", "error:#{event.payload[:error_class]}"]
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
```
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
# Lifecycle Hooks
|
|
2
|
+
|
|
3
|
+
JobWorkflow provides lifecycle hooks to insert processing before and after task execution. Use `before`, `after`, `around`, and `on_error` hooks to implement cross-cutting concerns such as logging, validation, metrics collection, error notification, and external monitoring integration.
|
|
4
|
+
|
|
5
|
+
## Hook Scope
|
|
6
|
+
|
|
7
|
+
Hooks can be applied globally (to all tasks) or to specific tasks.
|
|
8
|
+
|
|
9
|
+
### Global Hooks (No Task Names)
|
|
10
|
+
|
|
11
|
+
When no task names are specified, the hook applies to all tasks:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
class GlobalLoggingJob < ApplicationJob
|
|
15
|
+
include JobWorkflow::DSL
|
|
16
|
+
|
|
17
|
+
# This hook runs before EVERY task
|
|
18
|
+
before do |ctx|
|
|
19
|
+
Rails.logger.info("Starting task execution")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# This hook runs after EVERY task
|
|
23
|
+
after do |ctx|
|
|
24
|
+
Rails.logger.info("Task execution completed")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
task :first_task do |ctx|
|
|
28
|
+
# before and after hooks run here
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
task :second_task do |ctx|
|
|
32
|
+
# before and after hooks also run here
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Task-Specific Hooks (Single Task)
|
|
38
|
+
|
|
39
|
+
Specify a task name to apply the hook only to that task:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
before :validate_order do |ctx|
|
|
43
|
+
# Only runs before :validate_order task
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Multiple Task Hooks (Variable-Length Arguments)
|
|
48
|
+
|
|
49
|
+
Specify multiple task names to apply the same hook to several tasks:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
before :task_a, :task_b, :task_c do |ctx|
|
|
53
|
+
# Runs before each of :task_a, :task_b, and :task_c
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
around :fetch_users, :fetch_orders, :fetch_products do |ctx, task|
|
|
57
|
+
start_time = Time.current
|
|
58
|
+
task.call
|
|
59
|
+
Metrics.timing("api.duration", Time.current - start_time)
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Hook Types
|
|
64
|
+
|
|
65
|
+
### before Hook
|
|
66
|
+
|
|
67
|
+
Execute processing before task execution.
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
class ValidationWorkflowJob < ApplicationJob
|
|
71
|
+
include JobWorkflow::DSL
|
|
72
|
+
|
|
73
|
+
argument :order_id, "Integer"
|
|
74
|
+
|
|
75
|
+
# Run validation in before hook
|
|
76
|
+
before :charge_payment do |ctx|
|
|
77
|
+
order = Order.find(ctx.arguments.order_id)
|
|
78
|
+
|
|
79
|
+
# Check inventory
|
|
80
|
+
raise "Out of stock" unless order.items_in_stock?
|
|
81
|
+
|
|
82
|
+
# Verify credit card
|
|
83
|
+
raise "Invalid card" unless order.valid_credit_card?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
task :charge_payment, output: { payment_id: "String" } do |ctx|
|
|
87
|
+
# Executes after validation passes
|
|
88
|
+
order_id = ctx.arguments.order_id
|
|
89
|
+
{ payment_id: PaymentGateway.charge(order_id) }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### after Hook
|
|
95
|
+
|
|
96
|
+
Execute processing after task execution.
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
class NotificationWorkflowJob < ApplicationJob
|
|
100
|
+
include JobWorkflow::DSL
|
|
101
|
+
|
|
102
|
+
argument :user_id, "Integer"
|
|
103
|
+
|
|
104
|
+
task :perform_action, output: { action_result: "Hash" } do |ctx|
|
|
105
|
+
user_id = ctx.arguments.user_id
|
|
106
|
+
{ action_result: SomeService.perform(user_id) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Send notification in after hook
|
|
110
|
+
after :perform_action do |ctx|
|
|
111
|
+
user_id = ctx.arguments.user_id
|
|
112
|
+
action_result = ctx.output[:perform_action].first.action_result
|
|
113
|
+
|
|
114
|
+
UserMailer.action_completed(
|
|
115
|
+
user_id,
|
|
116
|
+
action_result
|
|
117
|
+
).deliver_later
|
|
118
|
+
|
|
119
|
+
# Record analytics
|
|
120
|
+
Analytics.track('action_completed', {
|
|
121
|
+
user_id: user_id,
|
|
122
|
+
result: action_result
|
|
123
|
+
})
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### around Hook
|
|
129
|
+
|
|
130
|
+
Execute processing that wraps task execution. **Important:** You must call `task.call` to execute the task.
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
class MetricsWorkflowJob < ApplicationJob
|
|
134
|
+
include JobWorkflow::DSL
|
|
135
|
+
|
|
136
|
+
# Measure execution time
|
|
137
|
+
around :expensive_task do |ctx, task|
|
|
138
|
+
start_time = Time.current
|
|
139
|
+
|
|
140
|
+
Rails.logger.info("Starting expensive_task")
|
|
141
|
+
|
|
142
|
+
# Execute task - THIS IS REQUIRED
|
|
143
|
+
task.call
|
|
144
|
+
|
|
145
|
+
duration = Time.current - start_time
|
|
146
|
+
Rails.logger.info("expensive_task completed in #{duration}s")
|
|
147
|
+
|
|
148
|
+
# Send metrics
|
|
149
|
+
Metrics.timing('task.duration', duration, tags: {
|
|
150
|
+
task: 'expensive_task'
|
|
151
|
+
})
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
task :expensive_task, output: { result: "String" } do |ctx|
|
|
155
|
+
{ result: heavy_computation }
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Execution Order
|
|
161
|
+
|
|
162
|
+
Hooks are executed in definition order. When multiple hooks apply to a task, they execute as follows:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
class OrderedHooksJob < ApplicationJob
|
|
166
|
+
include JobWorkflow::DSL
|
|
167
|
+
|
|
168
|
+
before do |ctx|
|
|
169
|
+
puts "1. Global before"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
before :my_task do |ctx|
|
|
173
|
+
puts "2. Task-specific before"
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
around do |ctx, task|
|
|
177
|
+
puts "3. Global around (before)"
|
|
178
|
+
task.call
|
|
179
|
+
puts "6. Global around (after)"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
around :my_task do |ctx, task|
|
|
183
|
+
puts "4. Task-specific around (before)"
|
|
184
|
+
task.call
|
|
185
|
+
puts "5. Task-specific around (after)"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
task :my_task do |ctx|
|
|
189
|
+
puts "--- Task execution ---"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
after :my_task do |ctx|
|
|
193
|
+
puts "7. Task-specific after"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
after do |ctx|
|
|
197
|
+
puts "8. Global after"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Output:
|
|
202
|
+
# 1. Global before
|
|
203
|
+
# 2. Task-specific before
|
|
204
|
+
# 3. Global around (before)
|
|
205
|
+
# 4. Task-specific around (before)
|
|
206
|
+
# --- Task execution ---
|
|
207
|
+
# 5. Task-specific around (after)
|
|
208
|
+
# 6. Global around (after)
|
|
209
|
+
# 7. Task-specific after
|
|
210
|
+
# 8. Global after
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
## around Hook: task.call is Required
|
|
214
|
+
|
|
215
|
+
In around hooks, you **must** call `task.call` to execute the task. If you forget to call it, JobWorkflow raises `TaskCallable::NotCalledError`:
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
# ❌ BAD: Missing task.call
|
|
219
|
+
around :my_task do |ctx, task|
|
|
220
|
+
puts "Before task"
|
|
221
|
+
# Forgot task.call!
|
|
222
|
+
puts "After task"
|
|
223
|
+
end
|
|
224
|
+
# => Raises: JobWorkflow::TaskCallable::NotCalledError:
|
|
225
|
+
# around hook for 'my_task' did not call task.call
|
|
226
|
+
|
|
227
|
+
# ✅ GOOD: Properly calling task.call
|
|
228
|
+
around :my_task do |ctx, task|
|
|
229
|
+
puts "Before task"
|
|
230
|
+
task.call # Required!
|
|
231
|
+
puts "After task"
|
|
232
|
+
end
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Additionally, `task.call` can only be called once. Calling it multiple times raises `TaskCallable::AlreadyCalledError`:
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
# ❌ BAD: Calling task.call multiple times
|
|
239
|
+
around :my_task do |ctx, task|
|
|
240
|
+
task.call
|
|
241
|
+
task.call # => Raises: JobWorkflow::TaskCallable::AlreadyCalledError
|
|
242
|
+
end
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
## on_error Hook
|
|
246
|
+
|
|
247
|
+
Execute processing when a task raises an exception. This hook is ideal for error notification, external monitoring integration, and error tracking.
|
|
248
|
+
|
|
249
|
+
**Important:** `on_error` hooks do not suppress exceptions - they are for notification purposes only. After all hooks execute, the exception is re-raised.
|
|
250
|
+
|
|
251
|
+
```ruby
|
|
252
|
+
class ErrorNotificationWorkflowJob < ApplicationJob
|
|
253
|
+
include JobWorkflow::DSL
|
|
254
|
+
|
|
255
|
+
argument :user_id, "Integer"
|
|
256
|
+
|
|
257
|
+
# Global error hook - called for any task error
|
|
258
|
+
on_error do |ctx, exception, task|
|
|
259
|
+
ErrorNotificationService.notify(
|
|
260
|
+
exception: exception,
|
|
261
|
+
context: {
|
|
262
|
+
workflow: self.class.name,
|
|
263
|
+
task: task.task_name,
|
|
264
|
+
arguments: ctx.arguments.to_h
|
|
265
|
+
}
|
|
266
|
+
)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Task-specific error hook
|
|
270
|
+
on_error :critical_payment do |ctx, exception, task|
|
|
271
|
+
# Critical tasks get special handling
|
|
272
|
+
CriticalErrorHandler.handle(
|
|
273
|
+
task: task.task_name,
|
|
274
|
+
exception: exception,
|
|
275
|
+
severity: :high
|
|
276
|
+
)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
task :fetch_user, output: { user: "Hash" } do |ctx|
|
|
280
|
+
user = User.find(ctx.arguments.user_id)
|
|
281
|
+
{ user: user.attributes }
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
task :critical_payment, output: { payment_id: "String" } do |ctx|
|
|
285
|
+
# If this fails, both global and task-specific hooks run
|
|
286
|
+
payment = PaymentGateway.charge(ctx.arguments.user_id)
|
|
287
|
+
{ payment_id: payment.id }
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
### on_error Hook Parameters
|
|
293
|
+
|
|
294
|
+
The `on_error` hook receives three parameters:
|
|
295
|
+
|
|
296
|
+
| Parameter | Type | Description |
|
|
297
|
+
|-----------|------|-------------|
|
|
298
|
+
| `ctx` | `Context` | The workflow context at the time of failure |
|
|
299
|
+
| `exception` | `StandardError` | The exception that was raised |
|
|
300
|
+
| `task` | `Task` | The task object that failed |
|
|
301
|
+
|
|
302
|
+
### Hook Execution Order
|
|
303
|
+
|
|
304
|
+
When a task fails, error hooks execute in definition order (global first, then task-specific):
|
|
305
|
+
|
|
306
|
+
```ruby
|
|
307
|
+
class MultipleErrorHooksJob < ApplicationJob
|
|
308
|
+
include JobWorkflow::DSL
|
|
309
|
+
|
|
310
|
+
on_error do |ctx, error, task|
|
|
311
|
+
puts "1. Global error handler"
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
on_error :my_task do |ctx, error, task|
|
|
315
|
+
puts "2. Task-specific error handler"
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
task :my_task do |ctx|
|
|
319
|
+
raise "Something went wrong"
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# When :my_task fails, output:
|
|
324
|
+
# 1. Global error handler
|
|
325
|
+
# 2. Task-specific error handler
|
|
326
|
+
# => Then RuntimeError is re-raised
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
### Practical Use Cases
|
|
330
|
+
|
|
331
|
+
**Error Tracking Service:**
|
|
332
|
+
```ruby
|
|
333
|
+
on_error do |ctx, exception, task|
|
|
334
|
+
ErrorTracker.capture(exception, metadata: {
|
|
335
|
+
workflow: self.class.name,
|
|
336
|
+
task: task.task_name,
|
|
337
|
+
job_id: ctx.job_id
|
|
338
|
+
})
|
|
339
|
+
end
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
**Real-time Alert Notification:**
|
|
343
|
+
```ruby
|
|
344
|
+
on_error :critical_task do |ctx, exception, task|
|
|
345
|
+
AlertService.notify(
|
|
346
|
+
severity: :critical,
|
|
347
|
+
message: "Task #{task.task_name} failed: #{exception.message}",
|
|
348
|
+
metadata: { workflow: self.class.name }
|
|
349
|
+
)
|
|
350
|
+
end
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
**Structured Error Logging:**
|
|
354
|
+
```ruby
|
|
355
|
+
on_error do |ctx, exception, task|
|
|
356
|
+
Rails.logger.error({
|
|
357
|
+
event: "task_failure",
|
|
358
|
+
task: task.task_name,
|
|
359
|
+
error_class: exception.class.name,
|
|
360
|
+
error_message: exception.message,
|
|
361
|
+
backtrace: exception.backtrace&.first(10)
|
|
362
|
+
}.to_json)
|
|
363
|
+
end
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
## Error Handling
|
|
367
|
+
|
|
368
|
+
| Hook Type | Behavior on Exception |
|
|
369
|
+
|-----------|----------------------|
|
|
370
|
+
| `before` | Task is skipped, exception is re-raised |
|
|
371
|
+
| `after` | Exception is re-raised (task result is preserved) |
|
|
372
|
+
| `around` | Exception is re-raised |
|
|
373
|
+
| `on_error` | Executes on task failure, then exception is re-raised |
|
|
374
|
+
|
|
375
|
+
```ruby
|
|
376
|
+
class ErrorHandlingJob < ApplicationJob
|
|
377
|
+
include JobWorkflow::DSL
|
|
378
|
+
|
|
379
|
+
# If before hook raises, task won't execute
|
|
380
|
+
before :process_order do |ctx|
|
|
381
|
+
order = Order.find(ctx.arguments.order_id)
|
|
382
|
+
raise "Out of stock" unless order.items_in_stock?
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
task :process_order do |ctx|
|
|
386
|
+
# Only executes if validation passes
|
|
387
|
+
OrderProcessor.process(ctx.arguments.order_id)
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
## Hooks with Map Tasks (each/concurrency)
|
|
393
|
+
|
|
394
|
+
When using hooks with map tasks (`each` or `concurrency`), the hooks execute for **each iteration**:
|
|
395
|
+
|
|
396
|
+
```ruby
|
|
397
|
+
class BatchWithHooksJob < ApplicationJob
|
|
398
|
+
include JobWorkflow::DSL
|
|
399
|
+
|
|
400
|
+
argument :user_ids, "Array[Integer]"
|
|
401
|
+
|
|
402
|
+
# This hook runs for EACH user
|
|
403
|
+
before :fetch_users do |ctx|
|
|
404
|
+
Rails.logger.info("Fetching user: #{ctx.each_value}")
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
task :fetch_users,
|
|
408
|
+
each: ->(ctx) { ctx.arguments.user_ids },
|
|
409
|
+
output: { user: "Hash" } do |ctx|
|
|
410
|
+
{ user: UserAPI.fetch(ctx.each_value) }
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# With user_ids: [1, 2, 3], the before hook runs 3 times
|
|
415
|
+
```
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Namespaces
|
|
2
|
+
|
|
3
|
+
Logically grouping tasks improves readability and maintainability of complex workflows. JobWorkflow provides namespace functionality.
|
|
4
|
+
|
|
5
|
+
## Basic Namespaces
|
|
6
|
+
|
|
7
|
+
### namespace DSL
|
|
8
|
+
|
|
9
|
+
Group related tasks.
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
class ECommerceOrderJob < ApplicationJob
|
|
13
|
+
include JobWorkflow::DSL
|
|
14
|
+
|
|
15
|
+
argument :order, "Order"
|
|
16
|
+
|
|
17
|
+
# Payment-related tasks
|
|
18
|
+
namespace :payment do
|
|
19
|
+
task :validate do |ctx|
|
|
20
|
+
order = ctx.arguments.order
|
|
21
|
+
PaymentValidator.validate(order)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
task :charge, depends_on: [:"payment:validate"], output: { payment_result: "Hash" } do |ctx|
|
|
25
|
+
order = ctx.arguments.order
|
|
26
|
+
{ payment_result: PaymentProcessor.charge(order) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
task :send_receipt, depends_on: [:"payment:charge"] do |ctx|
|
|
30
|
+
order = ctx.arguments.order
|
|
31
|
+
payment_result = ctx.output[:"payment:charge"].first.payment_result
|
|
32
|
+
ReceiptMailer.send(order, payment_result)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Inventory-related tasks
|
|
37
|
+
namespace :inventory do
|
|
38
|
+
task :check_availability do |ctx|
|
|
39
|
+
order = ctx.arguments.order
|
|
40
|
+
InventoryService.check(order.items)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
task :reserve, depends_on: [:"inventory:check_availability"], output: { reserved: "bool" } do |ctx|
|
|
44
|
+
order = ctx.arguments.order
|
|
45
|
+
{ reserved: InventoryService.reserve(order.items) }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Shipping-related tasks
|
|
50
|
+
namespace :shipping do
|
|
51
|
+
task :calculate_cost, output: { shipping_cost: "Float" } do |ctx|
|
|
52
|
+
order = ctx.arguments.order
|
|
53
|
+
{ shipping_cost: ShippingCalculator.calculate(order) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
task :create_label, depends_on: [:"shipping:calculate_cost"], output: { shipping_label: "String" } do |ctx|
|
|
57
|
+
order = ctx.arguments.order
|
|
58
|
+
{ shipping_label: ShippingService.create_label(order) }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Tasks in namespaces are identified as `:namespace:task_name` at runtime:
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
# Executed tasks:
|
|
68
|
+
# - :payment:validate
|
|
69
|
+
# - :payment:charge
|
|
70
|
+
# - :payment:send_receipt
|
|
71
|
+
# - :inventory:check_availability
|
|
72
|
+
# - :inventory:reserve
|
|
73
|
+
# - :shipping:calculate_cost
|
|
74
|
+
# - :shipping:create_label
|
|
75
|
+
```
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# OpenTelemetry Integration
|
|
2
|
+
|
|
3
|
+
JobWorkflow provides optional OpenTelemetry integration for distributed tracing. When enabled, all workflow and task executions create OpenTelemetry spans.
|
|
4
|
+
|
|
5
|
+
## Prerequisites
|
|
6
|
+
|
|
7
|
+
Install the OpenTelemetry gems:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# Gemfile
|
|
11
|
+
gem 'opentelemetry-api'
|
|
12
|
+
gem 'opentelemetry-sdk'
|
|
13
|
+
gem 'opentelemetry-exporter-otlp' # Or your preferred exporter
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Configuration
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
# config/initializers/opentelemetry.rb
|
|
20
|
+
require 'opentelemetry/sdk'
|
|
21
|
+
require 'opentelemetry/exporter/otlp'
|
|
22
|
+
|
|
23
|
+
OpenTelemetry::SDK.configure do |c|
|
|
24
|
+
c.service_name = 'my-application'
|
|
25
|
+
c.use_all # Auto-instrument Rails, HTTP clients, etc.
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Enable JobWorkflow OpenTelemetry integration
|
|
29
|
+
JobWorkflow::Instrumentation::OpenTelemetrySubscriber.subscribe!
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Span Attributes
|
|
33
|
+
|
|
34
|
+
JobWorkflow spans include the following attributes:
|
|
35
|
+
|
|
36
|
+
| Attribute | Description |
|
|
37
|
+
|-----------|-------------|
|
|
38
|
+
| `job_workflow.job.name` | Job class name |
|
|
39
|
+
| `job_workflow.job.id` | Unique job identifier |
|
|
40
|
+
| `job_workflow.task.name` | Task name |
|
|
41
|
+
| `job_workflow.task.each_index` | Index in map task iteration |
|
|
42
|
+
| `job_workflow.task.retry_count` | Current retry attempt |
|
|
43
|
+
| `job_workflow.concurrency.key` | Throttle concurrency key |
|
|
44
|
+
| `job_workflow.concurrency.limit` | Throttle concurrency limit |
|
|
45
|
+
| `job_workflow.error.class` | Exception class (on error) |
|
|
46
|
+
| `job_workflow.error.message` | Exception message (on error) |
|
|
47
|
+
|
|
48
|
+
## Span Naming
|
|
49
|
+
|
|
50
|
+
Spans are named based on the event type:
|
|
51
|
+
|
|
52
|
+
- `DataProcessingJob workflow` - Workflow execution
|
|
53
|
+
- `DataProcessingJob.fetch_data task` - Task execution
|
|
54
|
+
- `DataProcessingJob.process_items task` - Map task execution
|
|
55
|
+
- `JobWorkflow throttle.acquire` - Throttle acquisition
|
|
56
|
+
- `JobWorkflow dependent.wait` - Dependency waiting
|
|
57
|
+
|
|
58
|
+
## Viewing Traces
|
|
59
|
+
|
|
60
|
+
Configure your preferred backend (Jaeger, Zipkin, Honeycomb, Datadog, etc.):
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
# Example: OTLP exporter
|
|
64
|
+
OpenTelemetry::SDK.configure do |c|
|
|
65
|
+
c.add_span_processor(
|
|
66
|
+
OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(
|
|
67
|
+
OpenTelemetry::Exporter::OTLP::Exporter.new(
|
|
68
|
+
endpoint: 'http://localhost:4318/v1/traces'
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Disabling OpenTelemetry
|
|
76
|
+
|
|
77
|
+
To disable OpenTelemetry integration:
|
|
78
|
+
|
|
79
|
+
```ruby
|
|
80
|
+
# Unsubscribe from all events
|
|
81
|
+
JobWorkflow::Instrumentation::OpenTelemetrySubscriber.unsubscribe!
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Error Handling
|
|
85
|
+
|
|
86
|
+
OpenTelemetry subscriber errors are handled gracefully and do not affect workflow execution. Errors are reported via `OpenTelemetry.handle_error` if available.
|