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.
- checksums.yaml +7 -0
- data/DEVELOPMENT.md +548 -0
- data/README.md +87 -0
- data/ext/tasker_core/Cargo.lock +4720 -0
- data/ext/tasker_core/Cargo.toml +76 -0
- data/ext/tasker_core/extconf.rb +38 -0
- data/ext/tasker_core/src/CLAUDE.md +7 -0
- data/ext/tasker_core/src/bootstrap.rs +320 -0
- data/ext/tasker_core/src/bridge.rs +400 -0
- data/ext/tasker_core/src/client_ffi.rs +173 -0
- data/ext/tasker_core/src/conversions.rs +131 -0
- data/ext/tasker_core/src/diagnostics.rs +57 -0
- data/ext/tasker_core/src/event_handler.rs +179 -0
- data/ext/tasker_core/src/event_publisher_ffi.rs +239 -0
- data/ext/tasker_core/src/ffi_logging.rs +245 -0
- data/ext/tasker_core/src/global_event_system.rs +16 -0
- data/ext/tasker_core/src/in_process_event_ffi.rs +319 -0
- data/ext/tasker_core/src/lib.rs +41 -0
- data/ext/tasker_core/src/observability_ffi.rs +339 -0
- data/lib/tasker_core/batch_processing/batch_aggregation_scenario.rb +85 -0
- data/lib/tasker_core/batch_processing/batch_worker_context.rb +238 -0
- data/lib/tasker_core/bootstrap.rb +394 -0
- data/lib/tasker_core/domain_events/base_publisher.rb +220 -0
- data/lib/tasker_core/domain_events/base_subscriber.rb +178 -0
- data/lib/tasker_core/domain_events/publisher_registry.rb +253 -0
- data/lib/tasker_core/domain_events/subscriber_registry.rb +152 -0
- data/lib/tasker_core/domain_events.rb +43 -0
- data/lib/tasker_core/errors/CLAUDE.md +7 -0
- data/lib/tasker_core/errors/common.rb +305 -0
- data/lib/tasker_core/errors/error_classifier.rb +61 -0
- data/lib/tasker_core/errors.rb +4 -0
- data/lib/tasker_core/event_bridge.rb +330 -0
- data/lib/tasker_core/handlers.rb +159 -0
- data/lib/tasker_core/internal.rb +31 -0
- data/lib/tasker_core/logger.rb +234 -0
- data/lib/tasker_core/models.rb +337 -0
- data/lib/tasker_core/observability/types.rb +158 -0
- data/lib/tasker_core/observability.rb +292 -0
- data/lib/tasker_core/registry/handler_registry.rb +453 -0
- data/lib/tasker_core/registry/resolver_chain.rb +258 -0
- data/lib/tasker_core/registry/resolvers/base_resolver.rb +90 -0
- data/lib/tasker_core/registry/resolvers/class_constant_resolver.rb +156 -0
- data/lib/tasker_core/registry/resolvers/explicit_mapping_resolver.rb +146 -0
- data/lib/tasker_core/registry/resolvers/method_dispatch_wrapper.rb +144 -0
- data/lib/tasker_core/registry/resolvers/registry_resolver.rb +229 -0
- data/lib/tasker_core/registry/resolvers.rb +42 -0
- data/lib/tasker_core/registry.rb +12 -0
- data/lib/tasker_core/step_handler/api.rb +48 -0
- data/lib/tasker_core/step_handler/base.rb +354 -0
- data/lib/tasker_core/step_handler/batchable.rb +50 -0
- data/lib/tasker_core/step_handler/decision.rb +53 -0
- data/lib/tasker_core/step_handler/mixins/api.rb +452 -0
- data/lib/tasker_core/step_handler/mixins/batchable.rb +465 -0
- data/lib/tasker_core/step_handler/mixins/decision.rb +252 -0
- data/lib/tasker_core/step_handler/mixins.rb +66 -0
- data/lib/tasker_core/subscriber.rb +212 -0
- data/lib/tasker_core/task_handler/base.rb +254 -0
- data/lib/tasker_core/tasker_rb.so +0 -0
- data/lib/tasker_core/template_discovery.rb +181 -0
- data/lib/tasker_core/tracing.rb +166 -0
- data/lib/tasker_core/types/batch_processing_outcome.rb +301 -0
- data/lib/tasker_core/types/client_types.rb +145 -0
- data/lib/tasker_core/types/decision_point_outcome.rb +177 -0
- data/lib/tasker_core/types/error_types.rb +72 -0
- data/lib/tasker_core/types/simple_message.rb +151 -0
- data/lib/tasker_core/types/step_context.rb +328 -0
- data/lib/tasker_core/types/step_handler_call_result.rb +307 -0
- data/lib/tasker_core/types/step_message.rb +112 -0
- data/lib/tasker_core/types/step_types.rb +207 -0
- data/lib/tasker_core/types/task_template.rb +240 -0
- data/lib/tasker_core/types/task_types.rb +148 -0
- data/lib/tasker_core/types.rb +132 -0
- data/lib/tasker_core/version.rb +13 -0
- data/lib/tasker_core/worker/CLAUDE.md +7 -0
- data/lib/tasker_core/worker/event_poller.rb +224 -0
- data/lib/tasker_core/worker/in_process_domain_event_poller.rb +271 -0
- data/lib/tasker_core.rb +160 -0
- 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
|