tasker-rb 0.1.1

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 (78) hide show
  1. checksums.yaml +7 -0
  2. data/DEVELOPMENT.md +548 -0
  3. data/README.md +87 -0
  4. data/ext/tasker_core/Cargo.lock +4720 -0
  5. data/ext/tasker_core/Cargo.toml +76 -0
  6. data/ext/tasker_core/extconf.rb +38 -0
  7. data/ext/tasker_core/src/CLAUDE.md +7 -0
  8. data/ext/tasker_core/src/bootstrap.rs +320 -0
  9. data/ext/tasker_core/src/bridge.rs +400 -0
  10. data/ext/tasker_core/src/client_ffi.rs +173 -0
  11. data/ext/tasker_core/src/conversions.rs +131 -0
  12. data/ext/tasker_core/src/diagnostics.rs +57 -0
  13. data/ext/tasker_core/src/event_handler.rs +179 -0
  14. data/ext/tasker_core/src/event_publisher_ffi.rs +239 -0
  15. data/ext/tasker_core/src/ffi_logging.rs +245 -0
  16. data/ext/tasker_core/src/global_event_system.rs +16 -0
  17. data/ext/tasker_core/src/in_process_event_ffi.rs +319 -0
  18. data/ext/tasker_core/src/lib.rs +41 -0
  19. data/ext/tasker_core/src/observability_ffi.rs +339 -0
  20. data/lib/tasker_core/batch_processing/batch_aggregation_scenario.rb +85 -0
  21. data/lib/tasker_core/batch_processing/batch_worker_context.rb +238 -0
  22. data/lib/tasker_core/bootstrap.rb +394 -0
  23. data/lib/tasker_core/domain_events/base_publisher.rb +220 -0
  24. data/lib/tasker_core/domain_events/base_subscriber.rb +178 -0
  25. data/lib/tasker_core/domain_events/publisher_registry.rb +253 -0
  26. data/lib/tasker_core/domain_events/subscriber_registry.rb +152 -0
  27. data/lib/tasker_core/domain_events.rb +43 -0
  28. data/lib/tasker_core/errors/CLAUDE.md +7 -0
  29. data/lib/tasker_core/errors/common.rb +305 -0
  30. data/lib/tasker_core/errors/error_classifier.rb +61 -0
  31. data/lib/tasker_core/errors.rb +4 -0
  32. data/lib/tasker_core/event_bridge.rb +330 -0
  33. data/lib/tasker_core/handlers.rb +159 -0
  34. data/lib/tasker_core/internal.rb +31 -0
  35. data/lib/tasker_core/logger.rb +234 -0
  36. data/lib/tasker_core/models.rb +337 -0
  37. data/lib/tasker_core/observability/types.rb +158 -0
  38. data/lib/tasker_core/observability.rb +292 -0
  39. data/lib/tasker_core/registry/handler_registry.rb +453 -0
  40. data/lib/tasker_core/registry/resolver_chain.rb +258 -0
  41. data/lib/tasker_core/registry/resolvers/base_resolver.rb +90 -0
  42. data/lib/tasker_core/registry/resolvers/class_constant_resolver.rb +156 -0
  43. data/lib/tasker_core/registry/resolvers/explicit_mapping_resolver.rb +146 -0
  44. data/lib/tasker_core/registry/resolvers/method_dispatch_wrapper.rb +144 -0
  45. data/lib/tasker_core/registry/resolvers/registry_resolver.rb +229 -0
  46. data/lib/tasker_core/registry/resolvers.rb +42 -0
  47. data/lib/tasker_core/registry.rb +12 -0
  48. data/lib/tasker_core/step_handler/api.rb +48 -0
  49. data/lib/tasker_core/step_handler/base.rb +354 -0
  50. data/lib/tasker_core/step_handler/batchable.rb +50 -0
  51. data/lib/tasker_core/step_handler/decision.rb +53 -0
  52. data/lib/tasker_core/step_handler/mixins/api.rb +452 -0
  53. data/lib/tasker_core/step_handler/mixins/batchable.rb +465 -0
  54. data/lib/tasker_core/step_handler/mixins/decision.rb +252 -0
  55. data/lib/tasker_core/step_handler/mixins.rb +66 -0
  56. data/lib/tasker_core/subscriber.rb +212 -0
  57. data/lib/tasker_core/task_handler/base.rb +254 -0
  58. data/lib/tasker_core/tasker_rb.so +0 -0
  59. data/lib/tasker_core/template_discovery.rb +181 -0
  60. data/lib/tasker_core/tracing.rb +166 -0
  61. data/lib/tasker_core/types/batch_processing_outcome.rb +301 -0
  62. data/lib/tasker_core/types/client_types.rb +145 -0
  63. data/lib/tasker_core/types/decision_point_outcome.rb +177 -0
  64. data/lib/tasker_core/types/error_types.rb +72 -0
  65. data/lib/tasker_core/types/simple_message.rb +151 -0
  66. data/lib/tasker_core/types/step_context.rb +328 -0
  67. data/lib/tasker_core/types/step_handler_call_result.rb +307 -0
  68. data/lib/tasker_core/types/step_message.rb +112 -0
  69. data/lib/tasker_core/types/step_types.rb +207 -0
  70. data/lib/tasker_core/types/task_template.rb +240 -0
  71. data/lib/tasker_core/types/task_types.rb +148 -0
  72. data/lib/tasker_core/types.rb +132 -0
  73. data/lib/tasker_core/version.rb +13 -0
  74. data/lib/tasker_core/worker/CLAUDE.md +7 -0
  75. data/lib/tasker_core/worker/event_poller.rb +224 -0
  76. data/lib/tasker_core/worker/in_process_domain_event_poller.rb +271 -0
  77. data/lib/tasker_core.rb +160 -0
  78. metadata +322 -0
@@ -0,0 +1,339 @@
1
+ //! # Observability FFI Module
2
+ //!
3
+ //! TAS-77: Exposes worker observability services (Health, Metrics, Templates, Config)
4
+ //! via Ruby FFI, enabling applications to access the same data available through
5
+ //! the HTTP API without running a web server.
6
+ //!
7
+ //! ## Design Approach
8
+ //!
9
+ //! Complex response types are serialized to JSON strings that Ruby can parse with
10
+ //! `JSON.parse`. This provides a clean interface and avoids manual hash building
11
+ //! for deeply nested structures.
12
+ //!
13
+ //! ## Usage
14
+ //!
15
+ //! ```ruby
16
+ //! # Get health status
17
+ //! health = JSON.parse(TaskerCore::FFI.health_basic)
18
+ //! health = JSON.parse(TaskerCore::FFI.health_detailed)
19
+ //! ready = JSON.parse(TaskerCore::FFI.health_ready)
20
+ //! live = JSON.parse(TaskerCore::FFI.health_live)
21
+ //!
22
+ //! # Get metrics
23
+ //! metrics = JSON.parse(TaskerCore::FFI.metrics_worker)
24
+ //! events = JSON.parse(TaskerCore::FFI.metrics_events)
25
+ //! prometheus = TaskerCore::FFI.metrics_prometheus # Already text format
26
+ //!
27
+ //! # Query templates
28
+ //! templates = JSON.parse(TaskerCore::FFI.templates_list(true))
29
+ //! template = JSON.parse(TaskerCore::FFI.template_get("namespace", "name", "version"))
30
+ //! validation = JSON.parse(TaskerCore::FFI.template_validate("namespace", "name", "version"))
31
+ //!
32
+ //! # Query configuration
33
+ //! config = JSON.parse(TaskerCore::FFI.config_runtime)
34
+ //! env = TaskerCore::FFI.config_environment # Simple string
35
+ //! ```
36
+
37
+ use crate::bridge::WORKER_SYSTEM;
38
+ use magnus::{function, prelude::*, Error, ExceptionClass, RModule, Ruby};
39
+ use tracing::{debug, error};
40
+
41
+ /// Helper to get RuntimeError exception class
42
+ fn runtime_error_class() -> ExceptionClass {
43
+ Ruby::get()
44
+ .expect("Ruby runtime should be available")
45
+ .exception_runtime_error()
46
+ }
47
+
48
+ /// Helper to get web_state from worker system, returning error if unavailable
49
+ fn with_web_state<F, T>(f: F) -> Result<T, Error>
50
+ where
51
+ F: FnOnce(
52
+ &crate::bridge::RubyBridgeHandle,
53
+ &tasker_worker::web::WorkerWebState,
54
+ ) -> Result<T, Error>,
55
+ {
56
+ let handle_guard = WORKER_SYSTEM.lock().map_err(|e| {
57
+ error!("Failed to acquire worker system lock: {}", e);
58
+ Error::new(runtime_error_class(), "Lock acquisition failed")
59
+ })?;
60
+
61
+ let handle = handle_guard
62
+ .as_ref()
63
+ .ok_or_else(|| Error::new(runtime_error_class(), "Worker system not running"))?;
64
+
65
+ let web_state = handle.system_handle.web_state.as_ref().ok_or_else(|| {
66
+ Error::new(
67
+ runtime_error_class(),
68
+ "Web state not available (web API may be disabled)",
69
+ )
70
+ })?;
71
+
72
+ f(handle, web_state)
73
+ }
74
+
75
+ /// Helper to serialize a value to JSON, with error conversion
76
+ fn to_json<T: serde::Serialize>(value: &T) -> Result<String, Error> {
77
+ serde_json::to_string(value).map_err(|e| {
78
+ Error::new(
79
+ runtime_error_class(),
80
+ format!("Failed to serialize response: {}", e),
81
+ )
82
+ })
83
+ }
84
+
85
+ // =============================================================================
86
+ // Health Service FFI Functions
87
+ // =============================================================================
88
+
89
+ /// Get basic health status as JSON string
90
+ ///
91
+ /// Returns JSON with:
92
+ /// - `status`: "healthy" or "unhealthy"
93
+ /// - `worker_id`: Worker identifier
94
+ /// - `timestamp`: ISO-8601 timestamp
95
+ pub fn health_basic() -> Result<String, Error> {
96
+ debug!("FFI: health_basic called");
97
+
98
+ with_web_state(|_handle, web_state| {
99
+ let response = web_state.health_service().basic_health();
100
+ to_json(&response)
101
+ })
102
+ }
103
+
104
+ /// Get liveness status as JSON (Kubernetes liveness probe equivalent)
105
+ pub fn health_live() -> Result<String, Error> {
106
+ debug!("FFI: health_live called");
107
+
108
+ with_web_state(|_handle, web_state| {
109
+ let response = web_state.health_service().liveness();
110
+ to_json(&response)
111
+ })
112
+ }
113
+
114
+ /// Get readiness status as JSON (Kubernetes readiness probe equivalent)
115
+ ///
116
+ /// Returns JSON with detailed health checks.
117
+ /// Check the `status` field to determine if the worker is ready.
118
+ pub fn health_ready() -> Result<String, Error> {
119
+ debug!("FFI: health_ready called");
120
+
121
+ with_web_state(|handle, web_state| {
122
+ let response = handle
123
+ .runtime
124
+ .block_on(async { web_state.health_service().readiness().await });
125
+
126
+ // Return the response regardless of Ok/Err - both contain DetailedHealthResponse
127
+ match response {
128
+ Ok(detailed) => to_json(&detailed),
129
+ Err(detailed) => to_json(&detailed),
130
+ }
131
+ })
132
+ }
133
+
134
+ /// Get detailed health information as JSON
135
+ pub fn health_detailed() -> Result<String, Error> {
136
+ debug!("FFI: health_detailed called");
137
+
138
+ with_web_state(|handle, web_state| {
139
+ let response = handle
140
+ .runtime
141
+ .block_on(async { web_state.health_service().detailed_health().await });
142
+
143
+ to_json(&response)
144
+ })
145
+ }
146
+
147
+ // =============================================================================
148
+ // Metrics Service FFI Functions
149
+ // =============================================================================
150
+
151
+ /// Get worker metrics as JSON string
152
+ pub fn metrics_worker() -> Result<String, Error> {
153
+ debug!("FFI: metrics_worker called");
154
+
155
+ with_web_state(|handle, web_state| {
156
+ let response = handle
157
+ .runtime
158
+ .block_on(async { web_state.metrics_service().worker_metrics().await });
159
+
160
+ to_json(&response)
161
+ })
162
+ }
163
+
164
+ /// Get domain event statistics as JSON
165
+ pub fn metrics_events() -> Result<String, Error> {
166
+ debug!("FFI: metrics_events called");
167
+
168
+ with_web_state(|handle, web_state| {
169
+ let response = handle
170
+ .runtime
171
+ .block_on(async { web_state.metrics_service().domain_event_stats().await });
172
+
173
+ to_json(&response)
174
+ })
175
+ }
176
+
177
+ /// Get Prometheus-formatted metrics string
178
+ ///
179
+ /// This returns the raw Prometheus text format, not JSON.
180
+ pub fn metrics_prometheus() -> Result<String, Error> {
181
+ debug!("FFI: metrics_prometheus called");
182
+
183
+ with_web_state(|handle, web_state| {
184
+ let response = handle
185
+ .runtime
186
+ .block_on(async { web_state.metrics_service().prometheus_format().await });
187
+
188
+ Ok(response)
189
+ })
190
+ }
191
+
192
+ // =============================================================================
193
+ // Template Query Service FFI Functions
194
+ // =============================================================================
195
+
196
+ /// List all available templates as JSON
197
+ ///
198
+ /// # Arguments
199
+ /// * `include_cache_stats` - Whether to include cache statistics
200
+ pub fn templates_list(include_cache_stats: bool) -> Result<String, Error> {
201
+ debug!("FFI: templates_list called");
202
+
203
+ with_web_state(|handle, web_state| {
204
+ let response = handle.runtime.block_on(async {
205
+ web_state
206
+ .template_query_service()
207
+ .list_templates(include_cache_stats)
208
+ .await
209
+ });
210
+
211
+ to_json(&response)
212
+ })
213
+ }
214
+
215
+ /// Get a specific template by namespace/name/version as JSON
216
+ pub fn template_get(namespace: String, name: String, version: String) -> Result<String, Error> {
217
+ debug!(
218
+ "FFI: template_get called for {}/{}/{}",
219
+ namespace, name, version
220
+ );
221
+
222
+ with_web_state(|handle, web_state| {
223
+ let response = handle.runtime.block_on(async {
224
+ web_state
225
+ .template_query_service()
226
+ .get_template(&namespace, &name, &version)
227
+ .await
228
+ });
229
+
230
+ match response {
231
+ Ok(template) => to_json(&template),
232
+ Err(e) => Err(Error::new(runtime_error_class(), e.to_string())),
233
+ }
234
+ })
235
+ }
236
+
237
+ /// Validate a template for worker execution as JSON
238
+ pub fn template_validate(
239
+ namespace: String,
240
+ name: String,
241
+ version: String,
242
+ ) -> Result<String, Error> {
243
+ debug!(
244
+ "FFI: template_validate called for {}/{}/{}",
245
+ namespace, name, version
246
+ );
247
+
248
+ with_web_state(|handle, web_state| {
249
+ let response = handle.runtime.block_on(async {
250
+ web_state
251
+ .template_query_service()
252
+ .validate_template(&namespace, &name, &version)
253
+ .await
254
+ });
255
+
256
+ match response {
257
+ Ok(validation) => to_json(&validation),
258
+ Err(e) => Err(Error::new(runtime_error_class(), e.to_string())),
259
+ }
260
+ })
261
+ }
262
+
263
+ /// Get template cache statistics as JSON
264
+ pub fn templates_cache_stats() -> Result<String, Error> {
265
+ debug!("FFI: templates_cache_stats called");
266
+
267
+ with_web_state(|handle, web_state| {
268
+ let stats = handle
269
+ .runtime
270
+ .block_on(async { web_state.template_query_service().cache_stats().await });
271
+
272
+ to_json(&stats)
273
+ })
274
+ }
275
+
276
+ // TAS-169: Removed templates_cache_clear and template_refresh FFI functions.
277
+ // Cache operations are now internal-only. Restart worker to refresh templates.
278
+ // Distributed cache status is available via health_detailed().
279
+
280
+ // =============================================================================
281
+ // Config Query Service FFI Functions
282
+ // =============================================================================
283
+
284
+ /// Get runtime configuration as JSON (safe fields only, no secrets)
285
+ pub fn config_runtime() -> Result<String, Error> {
286
+ debug!("FFI: config_runtime called");
287
+
288
+ with_web_state(|_handle, web_state| {
289
+ let response = web_state.config_query_service().runtime_config();
290
+
291
+ match response {
292
+ Ok(config) => to_json(&config),
293
+ Err(e) => Err(Error::new(runtime_error_class(), e.to_string())),
294
+ }
295
+ })
296
+ }
297
+
298
+ /// Get the current environment name (simple string, not JSON)
299
+ pub fn config_environment() -> Result<String, Error> {
300
+ debug!("FFI: config_environment called");
301
+
302
+ with_web_state(|_handle, web_state| {
303
+ Ok(web_state.config_query_service().environment().to_string())
304
+ })
305
+ }
306
+
307
+ // =============================================================================
308
+ // Module Initialization
309
+ // =============================================================================
310
+
311
+ /// Initialize the observability FFI module
312
+ pub fn init_observability_ffi(module: &RModule) -> Result<(), Error> {
313
+ tracing::info!("🔍 Initializing observability FFI module (TAS-77)");
314
+
315
+ // Health endpoints
316
+ module.define_singleton_method("health_basic", function!(health_basic, 0))?;
317
+ module.define_singleton_method("health_live", function!(health_live, 0))?;
318
+ module.define_singleton_method("health_ready", function!(health_ready, 0))?;
319
+ module.define_singleton_method("health_detailed", function!(health_detailed, 0))?;
320
+
321
+ // Metrics endpoints
322
+ module.define_singleton_method("metrics_worker", function!(metrics_worker, 0))?;
323
+ module.define_singleton_method("metrics_events", function!(metrics_events, 0))?;
324
+ module.define_singleton_method("metrics_prometheus", function!(metrics_prometheus, 0))?;
325
+
326
+ // Template endpoints
327
+ // TAS-169: Removed templates_cache_clear and template_refresh (cache ops are internal-only)
328
+ module.define_singleton_method("templates_list", function!(templates_list, 1))?;
329
+ module.define_singleton_method("template_get", function!(template_get, 3))?;
330
+ module.define_singleton_method("template_validate", function!(template_validate, 3))?;
331
+ module.define_singleton_method("templates_cache_stats", function!(templates_cache_stats, 0))?;
332
+
333
+ // Config endpoints
334
+ module.define_singleton_method("config_runtime", function!(config_runtime, 0))?;
335
+ module.define_singleton_method("config_environment", function!(config_environment, 0))?;
336
+
337
+ tracing::info!("✅ Observability FFI module initialized");
338
+ Ok(())
339
+ }
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TaskerCore
4
+ module BatchProcessing
5
+ # Ruby equivalent of Rust's BatchAggregationScenario
6
+ #
7
+ # Detects whether a batchable step created batch workers or returned NoBatches outcome.
8
+ # Uses DependencyResultsWrapper to access parent step results.
9
+ #
10
+ # This helper distinguishes two scenarios:
11
+ # - **NoBatches**: Batchable step returned NoBatches outcome, only placeholder worker exists
12
+ # - **WithBatches**: Batchable step created real batch workers for parallel processing
13
+ #
14
+ # @example Detecting aggregation scenario
15
+ # scenario = BatchAggregationScenario.detect(
16
+ # sequence_step,
17
+ # 'analyze_dataset',
18
+ # 'process_batch_'
19
+ # )
20
+ #
21
+ # if scenario.no_batches?
22
+ # # Handle NoBatches scenario - use batchable result directly
23
+ # return scenario.batchable_result
24
+ # else
25
+ # # Handle WithBatches scenario - aggregate worker results
26
+ # total = scenario.batch_results.values.sum
27
+ # return { total: total, worker_count: scenario.worker_count }
28
+ # end
29
+ class BatchAggregationScenario
30
+ attr_reader :type, :batchable_result, :batch_results, :worker_count
31
+
32
+ # Detect aggregation scenario from step dependencies
33
+ #
34
+ # @param dependency_results [DependencyResultsWrapper] Dependency results wrapper
35
+ # @param batchable_step_name [String] Name of the batchable step
36
+ # @param batch_worker_prefix [String] Prefix for batch worker step names
37
+ # @return [BatchAggregationScenario] Detected scenario
38
+ def self.detect(dependency_results, batchable_step_name, batch_worker_prefix)
39
+ # dependency_results is already a DependencyResultsWrapper - pass it directly
40
+ new(dependency_results, batchable_step_name, batch_worker_prefix)
41
+ end
42
+
43
+ # Initialize aggregation scenario
44
+ #
45
+ # @param dependency_results [DependencyResultsWrapper] Dependency results wrapper
46
+ # @param batchable_step_name [String] Name of the batchable step
47
+ # @param batch_worker_prefix [String] Prefix for batch worker step names
48
+ def initialize(dependency_results, batchable_step_name, batch_worker_prefix)
49
+ # Get the batchable step result (extract the 'result' field)
50
+ @batchable_result = dependency_results.get_results(batchable_step_name)
51
+
52
+ # Find all batch worker results by prefix matching (extract 'result' field from each)
53
+ @batch_results = {}
54
+ dependency_results.each_key do |step_name|
55
+ if step_name.start_with?(batch_worker_prefix)
56
+ @batch_results[step_name] = dependency_results.get_results(step_name)
57
+ end
58
+ end
59
+
60
+ # Determine scenario type based on batch worker count
61
+ if @batch_results.empty?
62
+ @type = :no_batches
63
+ @worker_count = 0
64
+ else
65
+ @type = :with_batches
66
+ @worker_count = @batch_results.size
67
+ end
68
+ end
69
+
70
+ # Check if scenario is NoBatches
71
+ #
72
+ # @return [Boolean] True if batchable step returned NoBatches outcome
73
+ def no_batches?
74
+ @type == :no_batches
75
+ end
76
+
77
+ # Check if scenario is WithBatches
78
+ #
79
+ # @return [Boolean] True if batch workers were created
80
+ def with_batches?
81
+ @type == :with_batches
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TaskerCore
4
+ module BatchProcessing
5
+ # Ruby equivalent of Rust's BatchWorkerContext
6
+ #
7
+ # Extracts cursor config from workflow_step.inputs via WorkflowStepWrapper.
8
+ # This mirrors the Rust implementation which deserializes BatchWorkerInputs
9
+ # from the workflow_step.inputs field.
10
+ #
11
+ # @example Checking if worker is no-op
12
+ # context = BatchWorkerContext.from_step_data(sequence_step)
13
+ # if context.no_op?
14
+ # # Handle placeholder worker for NoBatches scenario
15
+ # return success_result(message: 'No-op worker completed')
16
+ # end
17
+ #
18
+ # @example Accessing cursor configuration
19
+ # context = BatchWorkerContext.from_step_data(sequence_step)
20
+ # start = context.start_cursor # => 0
21
+ # end_pos = context.end_cursor # => 1000
22
+ # batch_id = context.batch_id # => "001"
23
+ #
24
+ # == Cursor Flexibility
25
+ #
26
+ # Cursors are intentionally flexible to support diverse business logic scenarios.
27
+ # The system validates numeric cursors when both values are integers, but cursors
28
+ # can also be alphanumeric strings, timestamps, UUIDs, or any comparable type that
29
+ # makes sense for your data partitioning strategy.
30
+ #
31
+ # With great power comes great responsibility: ensure your cursor implementation
32
+ # matches your data source's capabilities and that your handler logic correctly
33
+ # interprets the cursor boundaries.
34
+ #
35
+ # @example Numeric cursors (validated for ordering)
36
+ # cursor: {
37
+ # batch_id: '001',
38
+ # start_cursor: 0, # Integer cursors are validated
39
+ # end_cursor: 1000
40
+ # }
41
+ #
42
+ # @example Alphanumeric cursors (developer responsibility)
43
+ # cursor: {
44
+ # batch_id: '002',
45
+ # start_cursor: 'A', # String cursors for alphabetical ranges
46
+ # end_cursor: 'M'
47
+ # }
48
+ #
49
+ # @example UUID-based cursors (developer responsibility)
50
+ # cursor: {
51
+ # batch_id: '003',
52
+ # start_cursor: '00000000-0000-0000-0000-000000000000',
53
+ # end_cursor: '88888888-8888-8888-8888-888888888888'
54
+ # }
55
+ #
56
+ # @example Timestamp cursors (developer responsibility)
57
+ # cursor: {
58
+ # batch_id: '004',
59
+ # start_cursor: '2024-01-01T00:00:00Z',
60
+ # end_cursor: '2024-01-31T23:59:59Z'
61
+ # }
62
+ class BatchWorkerContext
63
+ attr_reader :cursor, :batch_metadata, :is_no_op, :checkpoint
64
+
65
+ # Extract context from WorkflowStepWrapper
66
+ #
67
+ # @param workflow_step [WorkflowStepWrapper] Workflow step wrapper
68
+ # @return [BatchWorkerContext] Extracted context
69
+ def self.from_step_data(workflow_step)
70
+ new(workflow_step)
71
+ end
72
+
73
+ # Initialize batch worker context from workflow step
74
+ #
75
+ # Reads BatchWorkerInputs from workflow_step.inputs (instance data)
76
+ # not from step_definition.handler.initialization (template data).
77
+ #
78
+ # @param workflow_step [WorkflowStepWrapper] Workflow step wrapper
79
+ def initialize(workflow_step)
80
+ # Access inputs via WorkflowStepWrapper
81
+ # Use ActiveSupport deep_symbolize_keys for clean key access
82
+ inputs = (workflow_step.inputs || {}).deep_symbolize_keys
83
+
84
+ @is_no_op = inputs[:is_no_op] == true
85
+
86
+ # TAS-125: Extract checkpoint data from workflow step
87
+ # Checkpoint contains persisted progress from previous yields
88
+ # Use with_indifferent_access for flexible string/symbol key access
89
+ @checkpoint = (workflow_step.checkpoint || {}).with_indifferent_access
90
+
91
+ if @is_no_op
92
+ # Placeholder worker - minimal context
93
+ @cursor = {}
94
+ @batch_metadata = {}
95
+ else
96
+ @cursor = inputs[:cursor] || {}
97
+ @batch_metadata = inputs[:batch_metadata] || {}
98
+
99
+ validate_cursor!
100
+ end
101
+ end
102
+
103
+ # Get the starting cursor in the dataset (inclusive)
104
+ #
105
+ # @return [Integer] Starting cursor position
106
+ def start_cursor
107
+ cursor[:start_cursor].to_i
108
+ end
109
+
110
+ # Get the ending cursor in the dataset (exclusive)
111
+ #
112
+ # @return [Integer] Ending cursor position
113
+ def end_cursor
114
+ cursor[:end_cursor].to_i
115
+ end
116
+
117
+ # Get the batch identifier
118
+ #
119
+ # @return [String] Batch ID (e.g., "001", "002", "000" for no-op)
120
+ def batch_id
121
+ cursor[:batch_id] || 'unknown'
122
+ end
123
+
124
+ # Check if this is a no-op/placeholder worker
125
+ #
126
+ # @return [Boolean] True if this worker should skip processing
127
+ def no_op?
128
+ @is_no_op
129
+ end
130
+
131
+ # TAS-125: Get checkpoint cursor from previous yield
132
+ #
133
+ # When a handler yields a checkpoint, the cursor position is persisted.
134
+ # On re-dispatch, this returns that cursor position to resume from.
135
+ #
136
+ # @return [Integer, String, Hash, nil] Last persisted cursor position, or nil if no checkpoint
137
+ #
138
+ # @example Resume from checkpoint
139
+ # start = batch_ctx.checkpoint_cursor || batch_ctx.start_cursor
140
+ def checkpoint_cursor
141
+ checkpoint[:cursor]
142
+ end
143
+
144
+ # TAS-125: Get accumulated results from previous checkpoint yield
145
+ #
146
+ # When a handler yields a checkpoint with accumulated_results, those
147
+ # partial aggregations are persisted. On re-dispatch, this returns
148
+ # them so the handler can continue accumulating.
149
+ #
150
+ # @return [Hash, nil] Partial aggregations from previous yields
151
+ #
152
+ # @example Continue accumulating totals
153
+ # accumulated = batch_ctx.accumulated_results || { 'total' => 0 }
154
+ # accumulated['total'] += item.value
155
+ def accumulated_results
156
+ checkpoint[:accumulated_results]
157
+ end
158
+
159
+ # TAS-125: Check if checkpoint exists
160
+ #
161
+ # Use this to determine if this is a fresh execution or a resumption
162
+ # from a previous checkpoint yield.
163
+ #
164
+ # @return [Boolean] True if checkpoint data exists
165
+ #
166
+ # @example Conditional initialization
167
+ # if batch_ctx.has_checkpoint?
168
+ # # Resuming from previous checkpoint
169
+ # start = batch_ctx.checkpoint_cursor
170
+ # else
171
+ # # Fresh start
172
+ # start = batch_ctx.start_cursor
173
+ # end
174
+ def has_checkpoint?
175
+ checkpoint.present? && checkpoint[:cursor].present?
176
+ end
177
+
178
+ # TAS-125: Get items processed count from checkpoint
179
+ #
180
+ # Returns the cumulative count of items processed across all yields.
181
+ #
182
+ # @return [Integer] Items processed so far, or 0 if no checkpoint
183
+ def checkpoint_items_processed
184
+ checkpoint[:items_processed] || 0
185
+ end
186
+
187
+ private
188
+
189
+ # Validate cursor configuration for real workers
190
+ #
191
+ # @raise [ArgumentError] If cursor configuration is invalid
192
+ def validate_cursor!
193
+ return if @is_no_op
194
+
195
+ raise ArgumentError, 'Missing cursor configuration' if cursor.empty?
196
+ raise ArgumentError, 'Missing batch_id' unless cursor[:batch_id]
197
+ raise ArgumentError, 'Missing start_cursor' unless cursor.key?(:start_cursor)
198
+ raise ArgumentError, 'Missing end_cursor' unless cursor.key?(:end_cursor)
199
+
200
+ # Extract cursor values for validation
201
+ start_val = cursor[:start_cursor]
202
+ end_val = cursor[:end_cursor]
203
+
204
+ # Validate numeric cursors if they're integers
205
+ # (supports both integer and other types like timestamps/UUIDs)
206
+ return unless start_val.is_a?(Integer) || end_val.is_a?(Integer)
207
+
208
+ # If one is integer, both should be integers
209
+ unless start_val.is_a?(Integer) && end_val.is_a?(Integer)
210
+ raise ArgumentError,
211
+ "Mixed cursor types not allowed: start=#{start_val.class}, end=#{end_val.class}"
212
+ end
213
+
214
+ # Validate non-negative values
215
+ if start_val.negative?
216
+ raise ArgumentError,
217
+ "start_cursor must be non-negative, got #{start_val}"
218
+ end
219
+ if end_val.negative?
220
+ raise ArgumentError,
221
+ "end_cursor must be non-negative, got #{end_val}"
222
+ end
223
+
224
+ # Validate logical ordering
225
+ if start_val > end_val
226
+ raise ArgumentError,
227
+ "start_cursor (#{start_val}) must be <= end_cursor (#{end_val})"
228
+ end
229
+
230
+ # Warn about zero-length ranges (likely a bug)
231
+ return unless start_val == end_val
232
+
233
+ warn "WARNING: Zero-length cursor range (#{start_val} == #{end_val}) " \
234
+ "- worker will process no data. batch_id=#{cursor[:batch_id]}"
235
+ end
236
+ end
237
+ end
238
+ end