job-workflow 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +91 -0
  4. data/CHANGELOG.md +23 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +47 -0
  7. data/Rakefile +55 -0
  8. data/Steepfile +10 -0
  9. data/guides/API_REFERENCE.md +112 -0
  10. data/guides/BEST_PRACTICES.md +113 -0
  11. data/guides/CACHE_STORE_INTEGRATION.md +145 -0
  12. data/guides/CONDITIONAL_EXECUTION.md +66 -0
  13. data/guides/DEPENDENCY_WAIT.md +386 -0
  14. data/guides/DRY_RUN.md +390 -0
  15. data/guides/DSL_BASICS.md +216 -0
  16. data/guides/ERROR_HANDLING.md +187 -0
  17. data/guides/GETTING_STARTED.md +524 -0
  18. data/guides/INSTRUMENTATION.md +131 -0
  19. data/guides/LIFECYCLE_HOOKS.md +415 -0
  20. data/guides/NAMESPACES.md +75 -0
  21. data/guides/OPENTELEMETRY_INTEGRATION.md +86 -0
  22. data/guides/PARALLEL_PROCESSING.md +302 -0
  23. data/guides/PRODUCTION_DEPLOYMENT.md +110 -0
  24. data/guides/QUEUE_MANAGEMENT.md +141 -0
  25. data/guides/README.md +174 -0
  26. data/guides/SCHEDULED_JOBS.md +165 -0
  27. data/guides/STRUCTURED_LOGGING.md +268 -0
  28. data/guides/TASK_OUTPUTS.md +240 -0
  29. data/guides/TESTING_STRATEGY.md +56 -0
  30. data/guides/THROTTLING.md +198 -0
  31. data/guides/TROUBLESHOOTING.md +53 -0
  32. data/guides/WORKFLOW_COMPOSITION.md +675 -0
  33. data/guides/WORKFLOW_STATUS_QUERY.md +288 -0
  34. data/lib/job-workflow.rb +3 -0
  35. data/lib/job_workflow/argument_def.rb +16 -0
  36. data/lib/job_workflow/arguments.rb +40 -0
  37. data/lib/job_workflow/auto_scaling/adapter/aws_adapter.rb +66 -0
  38. data/lib/job_workflow/auto_scaling/adapter.rb +31 -0
  39. data/lib/job_workflow/auto_scaling/configuration.rb +85 -0
  40. data/lib/job_workflow/auto_scaling/executor.rb +43 -0
  41. data/lib/job_workflow/auto_scaling.rb +69 -0
  42. data/lib/job_workflow/cache_store_adapters.rb +46 -0
  43. data/lib/job_workflow/context.rb +352 -0
  44. data/lib/job_workflow/dry_run_config.rb +31 -0
  45. data/lib/job_workflow/dsl.rb +236 -0
  46. data/lib/job_workflow/error_hook.rb +24 -0
  47. data/lib/job_workflow/hook.rb +24 -0
  48. data/lib/job_workflow/hook_registry.rb +66 -0
  49. data/lib/job_workflow/instrumentation/log_subscriber.rb +194 -0
  50. data/lib/job_workflow/instrumentation/opentelemetry_subscriber.rb +221 -0
  51. data/lib/job_workflow/instrumentation.rb +257 -0
  52. data/lib/job_workflow/job_status.rb +92 -0
  53. data/lib/job_workflow/logger.rb +86 -0
  54. data/lib/job_workflow/namespace.rb +36 -0
  55. data/lib/job_workflow/output.rb +81 -0
  56. data/lib/job_workflow/output_def.rb +14 -0
  57. data/lib/job_workflow/queue.rb +74 -0
  58. data/lib/job_workflow/queue_adapter.rb +38 -0
  59. data/lib/job_workflow/queue_adapters/abstract.rb +87 -0
  60. data/lib/job_workflow/queue_adapters/null_adapter.rb +127 -0
  61. data/lib/job_workflow/queue_adapters/solid_queue_adapter.rb +224 -0
  62. data/lib/job_workflow/runner.rb +173 -0
  63. data/lib/job_workflow/schedule.rb +46 -0
  64. data/lib/job_workflow/semaphore.rb +71 -0
  65. data/lib/job_workflow/task.rb +83 -0
  66. data/lib/job_workflow/task_callable.rb +43 -0
  67. data/lib/job_workflow/task_context.rb +70 -0
  68. data/lib/job_workflow/task_dependency_wait.rb +66 -0
  69. data/lib/job_workflow/task_enqueue.rb +50 -0
  70. data/lib/job_workflow/task_graph.rb +43 -0
  71. data/lib/job_workflow/task_job_status.rb +70 -0
  72. data/lib/job_workflow/task_output.rb +51 -0
  73. data/lib/job_workflow/task_retry.rb +64 -0
  74. data/lib/job_workflow/task_throttle.rb +46 -0
  75. data/lib/job_workflow/version.rb +5 -0
  76. data/lib/job_workflow/workflow.rb +87 -0
  77. data/lib/job_workflow/workflow_status.rb +112 -0
  78. data/lib/job_workflow.rb +59 -0
  79. data/rbs_collection.lock.yaml +172 -0
  80. data/rbs_collection.yaml +14 -0
  81. data/sig/generated/job-workflow.rbs +2 -0
  82. data/sig/generated/job_workflow/argument_def.rbs +14 -0
  83. data/sig/generated/job_workflow/arguments.rbs +26 -0
  84. data/sig/generated/job_workflow/auto_scaling/adapter/aws_adapter.rbs +32 -0
  85. data/sig/generated/job_workflow/auto_scaling/adapter.rbs +22 -0
  86. data/sig/generated/job_workflow/auto_scaling/configuration.rbs +50 -0
  87. data/sig/generated/job_workflow/auto_scaling/executor.rbs +29 -0
  88. data/sig/generated/job_workflow/auto_scaling.rbs +47 -0
  89. data/sig/generated/job_workflow/cache_store_adapters.rbs +28 -0
  90. data/sig/generated/job_workflow/context.rbs +155 -0
  91. data/sig/generated/job_workflow/dry_run_config.rbs +16 -0
  92. data/sig/generated/job_workflow/dsl.rbs +117 -0
  93. data/sig/generated/job_workflow/error_hook.rbs +18 -0
  94. data/sig/generated/job_workflow/hook.rbs +18 -0
  95. data/sig/generated/job_workflow/hook_registry.rbs +47 -0
  96. data/sig/generated/job_workflow/instrumentation/log_subscriber.rbs +102 -0
  97. data/sig/generated/job_workflow/instrumentation/opentelemetry_subscriber.rbs +113 -0
  98. data/sig/generated/job_workflow/instrumentation.rbs +138 -0
  99. data/sig/generated/job_workflow/job_status.rbs +46 -0
  100. data/sig/generated/job_workflow/logger.rbs +56 -0
  101. data/sig/generated/job_workflow/namespace.rbs +24 -0
  102. data/sig/generated/job_workflow/output.rbs +39 -0
  103. data/sig/generated/job_workflow/output_def.rbs +12 -0
  104. data/sig/generated/job_workflow/queue.rbs +49 -0
  105. data/sig/generated/job_workflow/queue_adapter.rbs +18 -0
  106. data/sig/generated/job_workflow/queue_adapters/abstract.rbs +56 -0
  107. data/sig/generated/job_workflow/queue_adapters/null_adapter.rbs +73 -0
  108. data/sig/generated/job_workflow/queue_adapters/solid_queue_adapter.rbs +111 -0
  109. data/sig/generated/job_workflow/runner.rbs +66 -0
  110. data/sig/generated/job_workflow/schedule.rbs +34 -0
  111. data/sig/generated/job_workflow/semaphore.rbs +37 -0
  112. data/sig/generated/job_workflow/task.rbs +60 -0
  113. data/sig/generated/job_workflow/task_callable.rbs +30 -0
  114. data/sig/generated/job_workflow/task_context.rbs +52 -0
  115. data/sig/generated/job_workflow/task_dependency_wait.rbs +42 -0
  116. data/sig/generated/job_workflow/task_enqueue.rbs +27 -0
  117. data/sig/generated/job_workflow/task_graph.rbs +27 -0
  118. data/sig/generated/job_workflow/task_job_status.rbs +42 -0
  119. data/sig/generated/job_workflow/task_output.rbs +29 -0
  120. data/sig/generated/job_workflow/task_retry.rbs +30 -0
  121. data/sig/generated/job_workflow/task_throttle.rbs +20 -0
  122. data/sig/generated/job_workflow/version.rbs +5 -0
  123. data/sig/generated/job_workflow/workflow.rbs +48 -0
  124. data/sig/generated/job_workflow/workflow_status.rbs +55 -0
  125. data/sig/generated/job_workflow.rbs +8 -0
  126. data/sig-private/activejob.rbs +35 -0
  127. data/sig-private/activesupport.rbs +23 -0
  128. data/sig-private/aws.rbs +32 -0
  129. data/sig-private/opentelemetry.rbs +40 -0
  130. data/sig-private/solid_queue.rbs +108 -0
  131. data/tmp/.keep +0 -0
  132. metadata +190 -0
@@ -0,0 +1,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.