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,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TaskerCore
|
|
4
|
+
module Worker
|
|
5
|
+
# EventPoller polls for step execution events from Rust via FFI
|
|
6
|
+
#
|
|
7
|
+
# Solves the cross-thread communication issue between Rust and Ruby by
|
|
8
|
+
# actively polling the Rust worker for pending events. Events are forwarded
|
|
9
|
+
# to EventBridge for normal processing.
|
|
10
|
+
#
|
|
11
|
+
# The poller runs in a dedicated background thread, continuously checking
|
|
12
|
+
# for new events from the Rust worker at a configurable interval (default 10ms).
|
|
13
|
+
# This approach allows Ruby to control the thread context and safely process
|
|
14
|
+
# events without running into Ruby's Global Interpreter Lock (GIL) limitations.
|
|
15
|
+
#
|
|
16
|
+
# @example Starting the poller
|
|
17
|
+
# poller = TaskerCore::Worker::EventPoller.instance
|
|
18
|
+
# poller.start!
|
|
19
|
+
# # => Spawns polling thread with 10ms interval
|
|
20
|
+
#
|
|
21
|
+
# # Check if poller started successfully
|
|
22
|
+
# if poller.active?
|
|
23
|
+
# puts "Event poller is running"
|
|
24
|
+
# end
|
|
25
|
+
#
|
|
26
|
+
# @example Checking poller status
|
|
27
|
+
# poller.active?
|
|
28
|
+
# # => true/false
|
|
29
|
+
#
|
|
30
|
+
# if poller.active? && poller.polling_thread&.alive?
|
|
31
|
+
# puts "Poller thread is healthy"
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
# @example Stopping the poller
|
|
35
|
+
# poller.stop!
|
|
36
|
+
# # => Gracefully stops polling thread (max 5 second wait)
|
|
37
|
+
#
|
|
38
|
+
# # Poller will wait for current poll to complete
|
|
39
|
+
# # and then cleanly shutdown
|
|
40
|
+
#
|
|
41
|
+
# Threading Model:
|
|
42
|
+
# - **Main Thread**: Ruby application, handler execution
|
|
43
|
+
# - **Polling Thread**: Dedicated background thread for event polling
|
|
44
|
+
# - **Rust Threads**: Rust worker runtime (separate from Ruby)
|
|
45
|
+
#
|
|
46
|
+
# The polling thread:
|
|
47
|
+
# 1. Calls `TaskerCore::FFI.poll_step_events` to check for events
|
|
48
|
+
# 2. If event found, forwards to EventBridge for processing
|
|
49
|
+
# 3. If no event, sleeps for POLL_INTERVAL
|
|
50
|
+
# 4. On error, logs and sleeps for 10x POLL_INTERVAL
|
|
51
|
+
#
|
|
52
|
+
# Why Polling is Necessary:
|
|
53
|
+
#
|
|
54
|
+
# Ruby's GIL prevents Rust from directly calling Ruby methods from Rust threads.
|
|
55
|
+
# Direct FFI callbacks from Rust to Ruby would either:
|
|
56
|
+
# - Cause segfaults (unsafe thread access)
|
|
57
|
+
# - Block indefinitely (GIL contention)
|
|
58
|
+
# - Require complex unsafe code
|
|
59
|
+
#
|
|
60
|
+
# Polling allows:
|
|
61
|
+
# - Ruby to control thread context
|
|
62
|
+
# - Safe event processing in Ruby thread
|
|
63
|
+
# - Simple, reliable cross-language communication
|
|
64
|
+
# - Predictable performance characteristics
|
|
65
|
+
#
|
|
66
|
+
# Performance Characteristics:
|
|
67
|
+
# - **Poll Interval**: 10ms (0.01 seconds) - configurable via POLL_INTERVAL
|
|
68
|
+
# - **Max Latency**: ~10ms from event generation to processing start
|
|
69
|
+
# - **CPU Usage**: Minimal (yields during sleep)
|
|
70
|
+
# - **Error Recovery**: Automatic with exponential backoff (10x interval)
|
|
71
|
+
#
|
|
72
|
+
# Event Flow:
|
|
73
|
+
# 1. Rust worker detects step ready for execution
|
|
74
|
+
# 2. Rust queues event in internal event queue
|
|
75
|
+
# 3. EventPoller calls poll_step_events via FFI
|
|
76
|
+
# 4. Rust returns next event (or nil if queue empty)
|
|
77
|
+
# 5. EventPoller forwards event to EventBridge
|
|
78
|
+
# 6. EventBridge publishes to Ruby subscribers
|
|
79
|
+
# 7. StepExecutionSubscriber executes handler
|
|
80
|
+
#
|
|
81
|
+
# @see TaskerCore::Worker::EventBridge For event processing
|
|
82
|
+
# @see TaskerCore::FFI For Rust FFI operations
|
|
83
|
+
# @see POLL_INTERVAL For polling frequency configuration
|
|
84
|
+
class EventPoller
|
|
85
|
+
include Singleton
|
|
86
|
+
|
|
87
|
+
attr_reader :logger, :active, :polling_thread
|
|
88
|
+
|
|
89
|
+
# Polling interval in seconds (10ms)
|
|
90
|
+
# Lower values reduce latency but increase CPU usage
|
|
91
|
+
# Higher values reduce CPU usage but increase latency
|
|
92
|
+
POLL_INTERVAL = 0.01
|
|
93
|
+
|
|
94
|
+
# TAS-67 Phase 2: Check for starvation warnings every N poll iterations
|
|
95
|
+
# At 10ms poll interval, 100 iterations = 1 second between starvation checks
|
|
96
|
+
STARVATION_CHECK_INTERVAL = 100
|
|
97
|
+
|
|
98
|
+
def initialize
|
|
99
|
+
@logger = TaskerCore::Logger.instance
|
|
100
|
+
@active = false
|
|
101
|
+
@polling_thread = nil
|
|
102
|
+
@poll_count = 0 # TAS-67 Phase 2: Counter for starvation check interval
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Start polling for events from Rust
|
|
106
|
+
def start!
|
|
107
|
+
return if @active
|
|
108
|
+
|
|
109
|
+
@active = true
|
|
110
|
+
logger.info 'Starting EventPoller - polling for step execution events from Rust'
|
|
111
|
+
|
|
112
|
+
@polling_thread = Thread.new do
|
|
113
|
+
poll_events_loop
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
logger.info '✅ EventPoller started successfully'
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Stop polling
|
|
120
|
+
def stop!
|
|
121
|
+
return unless @active
|
|
122
|
+
|
|
123
|
+
logger.info 'Stopping EventPoller...'
|
|
124
|
+
@active = false
|
|
125
|
+
|
|
126
|
+
if @polling_thread&.alive?
|
|
127
|
+
@polling_thread.join(5.0) # Wait up to 5 seconds for thread to finish
|
|
128
|
+
@polling_thread.kill if @polling_thread.alive? # Force kill if still running
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
@polling_thread = nil
|
|
132
|
+
logger.info '✅ EventPoller stopped'
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Check if poller is active
|
|
136
|
+
def active?
|
|
137
|
+
@active
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# TAS-67 Phase 2: Get FFI dispatch channel metrics for monitoring
|
|
141
|
+
#
|
|
142
|
+
# Returns a hash with the following keys:
|
|
143
|
+
# - :pending_count - Number of events waiting for completion
|
|
144
|
+
# - :oldest_pending_age_ms - Age of the oldest pending event in milliseconds
|
|
145
|
+
# - :newest_pending_age_ms - Age of the newest pending event in milliseconds
|
|
146
|
+
# - :oldest_event_id - UUID of the oldest pending event (for debugging)
|
|
147
|
+
# - :starvation_detected - Boolean indicating if any events exceed starvation threshold
|
|
148
|
+
# - :starving_event_count - Number of events exceeding starvation threshold
|
|
149
|
+
#
|
|
150
|
+
# @return [Hash] FFI dispatch metrics, or empty hash if worker not running
|
|
151
|
+
# @example Check for starvation
|
|
152
|
+
# metrics = EventPoller.instance.ffi_dispatch_metrics
|
|
153
|
+
# if metrics[:starvation_detected]
|
|
154
|
+
# logger.warn "Starvation detected: #{metrics[:starving_event_count]} events waiting"
|
|
155
|
+
# end
|
|
156
|
+
def ffi_dispatch_metrics
|
|
157
|
+
TaskerCore::FFI.get_ffi_dispatch_metrics
|
|
158
|
+
rescue StandardError => e
|
|
159
|
+
logger.debug "Failed to get FFI dispatch metrics: #{e.message}"
|
|
160
|
+
{}
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
# Main polling loop - runs in dedicated thread
|
|
166
|
+
def poll_events_loop
|
|
167
|
+
logger.debug 'EventPoller: Starting poll loop'
|
|
168
|
+
|
|
169
|
+
while @active
|
|
170
|
+
begin
|
|
171
|
+
# TAS-67 Phase 2: Periodically check for starvation warnings
|
|
172
|
+
@poll_count += 1
|
|
173
|
+
check_starvation_periodically
|
|
174
|
+
|
|
175
|
+
# Poll for next event from Rust via FFI
|
|
176
|
+
event_data = TaskerCore::FFI.poll_step_events
|
|
177
|
+
|
|
178
|
+
if event_data.nil?
|
|
179
|
+
# No events available, sleep briefly
|
|
180
|
+
sleep(POLL_INTERVAL)
|
|
181
|
+
next
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Process the event through EventBridge
|
|
185
|
+
process_event(event_data)
|
|
186
|
+
rescue StandardError => e
|
|
187
|
+
logger.error "EventPoller error: #{e.message}"
|
|
188
|
+
logger.error e.backtrace.join("\n")
|
|
189
|
+
|
|
190
|
+
# Sleep longer on error to avoid tight error loops
|
|
191
|
+
sleep(POLL_INTERVAL * 10) if @active
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
logger.debug 'EventPoller: Poll loop terminated'
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# TAS-67 Phase 2: Check for starvation warnings periodically
|
|
199
|
+
# This triggers Rust-side logging of any events exceeding the starvation threshold
|
|
200
|
+
def check_starvation_periodically
|
|
201
|
+
return unless (@poll_count % STARVATION_CHECK_INTERVAL).zero?
|
|
202
|
+
|
|
203
|
+
TaskerCore::FFI.check_starvation_warnings
|
|
204
|
+
rescue StandardError => e
|
|
205
|
+
# Don't let starvation check errors affect polling
|
|
206
|
+
logger.debug "Starvation check error (non-fatal): #{e.message}"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Process a polled event through the EventBridge
|
|
210
|
+
def process_event(event_data)
|
|
211
|
+
logger.debug "EventPoller: Processing event from Rust: #{event_data}"
|
|
212
|
+
|
|
213
|
+
# Forward to EventBridge for normal processing
|
|
214
|
+
EventBridge.instance.publish_step_execution(event_data)
|
|
215
|
+
|
|
216
|
+
logger.debug 'EventPoller: Event forwarded to EventBridge successfully'
|
|
217
|
+
rescue StandardError => e
|
|
218
|
+
logger.error "EventPoller: Failed to process event: #{e.message}"
|
|
219
|
+
logger.error e.backtrace.join("\n")
|
|
220
|
+
raise
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module TaskerCore
|
|
6
|
+
module Worker
|
|
7
|
+
# TAS-65 Phase 4.2: In-Process Domain Event Poller
|
|
8
|
+
#
|
|
9
|
+
# Polls for fast domain events (delivery_mode: fast) from the Rust in-process
|
|
10
|
+
# event bus via FFI. Ruby handlers can subscribe to specific event patterns
|
|
11
|
+
# for integration purposes (Sentry, DataDog, Slack, etc.).
|
|
12
|
+
#
|
|
13
|
+
# This follows the same polling pattern as EventPoller but for domain events
|
|
14
|
+
# rather than step execution events. Events flow through a broadcast channel
|
|
15
|
+
# from Rust to Ruby.
|
|
16
|
+
#
|
|
17
|
+
# Event Flow:
|
|
18
|
+
# 1. Step handler (Rust or Ruby) publishes domain event with delivery_mode: fast
|
|
19
|
+
# 2. EventRouter routes to InProcessEventBus
|
|
20
|
+
# 3. InProcessEventBus broadcasts to all subscribers (including FFI channel)
|
|
21
|
+
# 4. This poller calls poll_in_process_events via FFI
|
|
22
|
+
# 5. Events are dispatched to registered Ruby handlers based on pattern matching
|
|
23
|
+
#
|
|
24
|
+
# @example Starting the poller
|
|
25
|
+
# poller = TaskerCore::Worker::InProcessDomainEventPoller.instance
|
|
26
|
+
# poller.start!
|
|
27
|
+
#
|
|
28
|
+
# @example Subscribing to specific event patterns
|
|
29
|
+
# poller.subscribe('payment.*') do |event|
|
|
30
|
+
# puts "Payment event: #{event[:event_name]}"
|
|
31
|
+
# # Forward to Sentry, DataDog, etc.
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
# @example Subscribing to all events
|
|
35
|
+
# poller.subscribe('*') do |event|
|
|
36
|
+
# MyMetricsCollector.record(event)
|
|
37
|
+
# end
|
|
38
|
+
#
|
|
39
|
+
# @example Stopping the poller
|
|
40
|
+
# poller.stop!
|
|
41
|
+
class InProcessDomainEventPoller
|
|
42
|
+
include Singleton
|
|
43
|
+
|
|
44
|
+
attr_reader :logger, :active, :polling_thread
|
|
45
|
+
|
|
46
|
+
# Polling interval in seconds (50ms - longer than step events since these are async notifications)
|
|
47
|
+
POLL_INTERVAL = 0.05
|
|
48
|
+
|
|
49
|
+
# Maximum events to poll per iteration
|
|
50
|
+
MAX_EVENTS_PER_POLL = 10
|
|
51
|
+
|
|
52
|
+
def initialize
|
|
53
|
+
@logger = TaskerCore::Logger.instance
|
|
54
|
+
@active = false
|
|
55
|
+
@polling_thread = nil
|
|
56
|
+
@subscribers = {} # pattern => array of handlers
|
|
57
|
+
@mutex = Mutex.new
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Subscribe a handler to an event pattern
|
|
61
|
+
#
|
|
62
|
+
# @param pattern [String] Event pattern (exact, wildcard 'payment.*', or global '*')
|
|
63
|
+
# @yield [event] Block called when matching event is received
|
|
64
|
+
# @yieldparam event [Hash] Domain event data
|
|
65
|
+
# @return [void]
|
|
66
|
+
#
|
|
67
|
+
# @example
|
|
68
|
+
# poller.subscribe('payment.processed') { |event| puts event[:event_name] }
|
|
69
|
+
# poller.subscribe('order.*') { |event| notify_slack(event) }
|
|
70
|
+
# poller.subscribe('*') { |event| log_all_events(event) }
|
|
71
|
+
def subscribe(pattern, &handler)
|
|
72
|
+
raise ArgumentError, 'Block required for subscription' unless block_given?
|
|
73
|
+
raise ArgumentError, 'Pattern cannot be empty' if pattern.nil? || pattern.empty?
|
|
74
|
+
|
|
75
|
+
@mutex.synchronize do
|
|
76
|
+
@subscribers[pattern] ||= []
|
|
77
|
+
@subscribers[pattern] << handler
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
logger.info "InProcessDomainEventPoller: Subscribed to pattern '#{pattern}'"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Unsubscribe all handlers for a pattern
|
|
84
|
+
#
|
|
85
|
+
# @param pattern [String] Event pattern to unsubscribe
|
|
86
|
+
# @return [void]
|
|
87
|
+
def unsubscribe(pattern)
|
|
88
|
+
@mutex.synchronize do
|
|
89
|
+
@subscribers.delete(pattern)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
logger.info "InProcessDomainEventPoller: Unsubscribed from pattern '#{pattern}'"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Clear all subscriptions
|
|
96
|
+
def clear_subscriptions!
|
|
97
|
+
@mutex.synchronize do
|
|
98
|
+
@subscribers.clear
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
logger.info 'InProcessDomainEventPoller: Cleared all subscriptions'
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Get count of registered subscribers
|
|
105
|
+
def subscriber_count
|
|
106
|
+
@mutex.synchronize do
|
|
107
|
+
@subscribers.values.flatten.size
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Start polling for in-process domain events
|
|
112
|
+
def start!
|
|
113
|
+
return if @active
|
|
114
|
+
|
|
115
|
+
@active = true
|
|
116
|
+
logger.info 'Starting InProcessDomainEventPoller - polling for fast domain events from Rust'
|
|
117
|
+
|
|
118
|
+
@polling_thread = Thread.new do
|
|
119
|
+
poll_events_loop
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
logger.info '✅ InProcessDomainEventPoller started successfully'
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Stop polling
|
|
126
|
+
def stop!
|
|
127
|
+
return unless @active
|
|
128
|
+
|
|
129
|
+
logger.info 'Stopping InProcessDomainEventPoller...'
|
|
130
|
+
@active = false
|
|
131
|
+
|
|
132
|
+
if @polling_thread&.alive?
|
|
133
|
+
@polling_thread.join(5.0) # Wait up to 5 seconds for thread to finish
|
|
134
|
+
@polling_thread.kill if @polling_thread.alive? # Force kill if still running
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
@polling_thread = nil
|
|
138
|
+
logger.info '✅ InProcessDomainEventPoller stopped'
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Check if poller is active
|
|
142
|
+
def active?
|
|
143
|
+
@active
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Get statistics about the poller
|
|
147
|
+
def stats
|
|
148
|
+
ffi_stats = begin
|
|
149
|
+
TaskerCore::FFI.in_process_event_stats
|
|
150
|
+
rescue StandardError => e
|
|
151
|
+
logger.warn "Failed to get FFI stats: #{e.message}"
|
|
152
|
+
{ enabled: false, status: 'unknown' }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
{
|
|
156
|
+
active: @active,
|
|
157
|
+
subscriber_count: subscriber_count,
|
|
158
|
+
patterns: @subscribers.keys,
|
|
159
|
+
ffi: ffi_stats
|
|
160
|
+
}
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
# Main polling loop - runs in dedicated thread
|
|
166
|
+
def poll_events_loop
|
|
167
|
+
logger.debug 'InProcessDomainEventPoller: Starting poll loop'
|
|
168
|
+
|
|
169
|
+
while @active
|
|
170
|
+
begin
|
|
171
|
+
# Poll for events from Rust via FFI
|
|
172
|
+
events = TaskerCore::FFI.poll_in_process_events(MAX_EVENTS_PER_POLL)
|
|
173
|
+
|
|
174
|
+
if events.nil? || events.empty?
|
|
175
|
+
# No events available, sleep briefly
|
|
176
|
+
sleep(POLL_INTERVAL)
|
|
177
|
+
next
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Process each event through pattern matching
|
|
181
|
+
events.each do |event_data|
|
|
182
|
+
dispatch_event(event_data)
|
|
183
|
+
end
|
|
184
|
+
rescue StandardError => e
|
|
185
|
+
logger.error "InProcessDomainEventPoller error: #{e.message}"
|
|
186
|
+
logger.error e.backtrace.first(5).join("\n")
|
|
187
|
+
|
|
188
|
+
# Sleep longer on error to avoid tight error loops
|
|
189
|
+
sleep(POLL_INTERVAL * 10) if @active
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
logger.debug 'InProcessDomainEventPoller: Poll loop terminated'
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Dispatch event to matching subscribers
|
|
197
|
+
def dispatch_event(event_data)
|
|
198
|
+
event_name = event_data[:event_name] || event_data['event_name']
|
|
199
|
+
return unless event_name
|
|
200
|
+
|
|
201
|
+
logger.debug "InProcessDomainEventPoller: Dispatching event '#{event_name}'"
|
|
202
|
+
|
|
203
|
+
# Parse business_payload if it's a JSON string
|
|
204
|
+
if event_data[:business_payload].is_a?(String)
|
|
205
|
+
begin
|
|
206
|
+
event_data[:business_payload] = JSON.parse(event_data[:business_payload])
|
|
207
|
+
rescue JSON::ParserError => e
|
|
208
|
+
logger.warn "Failed to parse business_payload JSON: #{e.message}"
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Parse error if it's a JSON string
|
|
213
|
+
if event_data.dig(:execution_result, :error).is_a?(String)
|
|
214
|
+
begin
|
|
215
|
+
event_data[:execution_result][:error] = JSON.parse(event_data[:execution_result][:error])
|
|
216
|
+
rescue JSON::ParserError => e
|
|
217
|
+
logger.warn "Failed to parse error JSON: #{e.message}"
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Find and invoke matching handlers
|
|
222
|
+
handlers = find_matching_handlers(event_name)
|
|
223
|
+
|
|
224
|
+
if handlers.empty?
|
|
225
|
+
logger.debug "InProcessDomainEventPoller: No handlers for event '#{event_name}'"
|
|
226
|
+
return
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
handlers.each do |handler|
|
|
230
|
+
invoke_handler_safely(handler, event_data, event_name)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Find handlers that match the event name
|
|
235
|
+
def find_matching_handlers(event_name)
|
|
236
|
+
handlers = []
|
|
237
|
+
|
|
238
|
+
@mutex.synchronize do
|
|
239
|
+
@subscribers.each do |pattern, pattern_handlers|
|
|
240
|
+
handlers.concat(pattern_handlers) if matches_pattern?(event_name, pattern)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
handlers
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Check if event name matches pattern
|
|
248
|
+
def matches_pattern?(event_name, pattern)
|
|
249
|
+
return true if pattern == '*' # Global wildcard
|
|
250
|
+
|
|
251
|
+
if pattern.end_with?('.*')
|
|
252
|
+
# Wildcard pattern: 'payment.*' matches 'payment.processed', 'payment.failed', etc.
|
|
253
|
+
prefix = pattern[0..-3] # Remove '.*'
|
|
254
|
+
event_name.start_with?("#{prefix}.")
|
|
255
|
+
else
|
|
256
|
+
# Exact match
|
|
257
|
+
event_name == pattern
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Safely invoke a handler (fire-and-forget semantics)
|
|
262
|
+
def invoke_handler_safely(handler, event_data, event_name)
|
|
263
|
+
handler.call(event_data)
|
|
264
|
+
rescue StandardError => e
|
|
265
|
+
# Fire-and-forget: log errors but don't propagate
|
|
266
|
+
logger.warn "InProcessDomainEventPoller: Handler failed for '#{event_name}': #{e.message}"
|
|
267
|
+
logger.warn e.backtrace.first(3).join("\n")
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
data/lib/tasker_core.rb
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'tasker_core/version'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'faraday'
|
|
6
|
+
require 'dry-events'
|
|
7
|
+
require 'dry-struct'
|
|
8
|
+
require 'dry-types'
|
|
9
|
+
require 'dry-validation'
|
|
10
|
+
require 'concurrent-ruby'
|
|
11
|
+
require 'timeout'
|
|
12
|
+
require 'dotenv'
|
|
13
|
+
require 'active_support'
|
|
14
|
+
require 'active_support/core_ext'
|
|
15
|
+
|
|
16
|
+
# TaskerCore - High-performance workflow orchestration for Ruby
|
|
17
|
+
#
|
|
18
|
+
# This library provides a Ruby interface to the Rust-powered tasker-core orchestration
|
|
19
|
+
# system, enabling developers to build complex, distributed workflows with automatic
|
|
20
|
+
# retry logic, dependency management, and event-driven execution.
|
|
21
|
+
#
|
|
22
|
+
# The system consists of two main layers:
|
|
23
|
+
# - **Rust Foundation**: High-performance orchestration, state management, and messaging
|
|
24
|
+
# - **Ruby Business Logic**: Step handlers, task handlers, and custom workflow logic
|
|
25
|
+
#
|
|
26
|
+
# These layers communicate via FFI (Foreign Function Interface) and an event-driven
|
|
27
|
+
# architecture, providing the performance of Rust with the expressiveness of Ruby.
|
|
28
|
+
#
|
|
29
|
+
# @example Getting started with TaskerCore
|
|
30
|
+
# # 1. Compile the Rust extension (first time only)
|
|
31
|
+
# bundle exec rake compile
|
|
32
|
+
#
|
|
33
|
+
# # 2. Define a step handler for your business logic
|
|
34
|
+
# class ProcessPaymentHandler < TaskerCore::StepHandler::Base
|
|
35
|
+
# def call(context)
|
|
36
|
+
# # Access task context data
|
|
37
|
+
# amount = context.get_task_field('amount')
|
|
38
|
+
# currency = context.get_task_field('currency')
|
|
39
|
+
#
|
|
40
|
+
# # Your business logic here
|
|
41
|
+
# payment_result = charge_payment(amount, currency)
|
|
42
|
+
#
|
|
43
|
+
# # Return results
|
|
44
|
+
# success(result: { payment_id: payment_result.id, status: 'succeeded' })
|
|
45
|
+
# end
|
|
46
|
+
# end
|
|
47
|
+
#
|
|
48
|
+
# # 3. Start the worker to process tasks
|
|
49
|
+
# TaskerCore::Worker::Bootstrap.start!(
|
|
50
|
+
# namespaces: ['payments']
|
|
51
|
+
# )
|
|
52
|
+
#
|
|
53
|
+
# @example Creating and submitting a task
|
|
54
|
+
# # Build a task request
|
|
55
|
+
# request = TaskerCore::Types::TaskRequest.build_test(
|
|
56
|
+
# namespace: "payments",
|
|
57
|
+
# name: "process_payment",
|
|
58
|
+
# context: { amount: 100.00, currency: "USD" }
|
|
59
|
+
# )
|
|
60
|
+
#
|
|
61
|
+
# # Submit to orchestration system
|
|
62
|
+
# TaskerCore::FFI.submit_task(request)
|
|
63
|
+
#
|
|
64
|
+
# @example Using the API step handler for HTTP calls
|
|
65
|
+
# class FetchUserHandler < TaskerCore::StepHandler::Api
|
|
66
|
+
# def call(context)
|
|
67
|
+
# user_id = context.get_task_field('user_id')
|
|
68
|
+
#
|
|
69
|
+
# # Automatic error classification and retry logic
|
|
70
|
+
# response = get("/users/#{user_id}")
|
|
71
|
+
#
|
|
72
|
+
# success(result: response.body)
|
|
73
|
+
# end
|
|
74
|
+
# end
|
|
75
|
+
#
|
|
76
|
+
# Architecture Overview:
|
|
77
|
+
#
|
|
78
|
+
# 1. **Task State Machine**: 12 states managing overall workflow orchestration
|
|
79
|
+
# - Initial: Pending, Initializing
|
|
80
|
+
# - Active: EnqueuingSteps, StepsInProcess, EvaluatingResults
|
|
81
|
+
# - Waiting: WaitingForDependencies, WaitingForRetry, BlockedByFailures
|
|
82
|
+
# - Terminal: Complete, Error, Cancelled, ResolvedManually
|
|
83
|
+
#
|
|
84
|
+
# 2. **Step State Machine**: 9 states managing individual step execution
|
|
85
|
+
# - Pipeline: Pending, Enqueued, InProgress, EnqueuedForOrchestration
|
|
86
|
+
# - Terminal: Complete, Error, Cancelled, ResolvedManually, WaitingForRetry
|
|
87
|
+
#
|
|
88
|
+
# 3. **Event-Driven Communication**:
|
|
89
|
+
# - Rust → Ruby: Step execution events via EventPoller
|
|
90
|
+
# - Ruby → Rust: Step completion events via EventBridge
|
|
91
|
+
# - PostgreSQL LISTEN/NOTIFY for real-time coordination
|
|
92
|
+
#
|
|
93
|
+
# Load Order (Important):
|
|
94
|
+
# 1. Rust native extension (tasker_rb) - provides FFI and base classes
|
|
95
|
+
# 2. Ruby error classes - error hierarchy for classification
|
|
96
|
+
# 3. Ruby models - data wrappers for FFI types
|
|
97
|
+
# 4. Ruby handlers - step and task handler base classes
|
|
98
|
+
# 5. Event system - EventBridge, EventPoller, Subscriber
|
|
99
|
+
# 6. Bootstrap - worker initialization and lifecycle
|
|
100
|
+
#
|
|
101
|
+
# @see TaskerCore::StepHandler::Base For creating custom step handlers
|
|
102
|
+
# @see TaskerCore::StepHandler::Api For HTTP-based step handlers
|
|
103
|
+
# @see TaskerCore::Worker::Bootstrap For worker initialization
|
|
104
|
+
# @see TaskerCore::Handlers For the public API namespace
|
|
105
|
+
# @see TaskerCore::Errors For error classification and retry logic
|
|
106
|
+
# @see https://github.com/tasker-systems/tasker-core Documentation and examples
|
|
107
|
+
module TaskerCore
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
begin
|
|
111
|
+
Dotenv.load
|
|
112
|
+
# Load the compiled Rust extension first (provides base classes)
|
|
113
|
+
require_relative 'tasker_core/tasker_rb'
|
|
114
|
+
rescue LoadError => e
|
|
115
|
+
raise LoadError, <<~MSG
|
|
116
|
+
|
|
117
|
+
❌ Failed to load tasker-rb native extension!
|
|
118
|
+
|
|
119
|
+
This usually means the Rust extension hasn't been compiled yet.
|
|
120
|
+
|
|
121
|
+
To compile the extension:
|
|
122
|
+
cd #{File.dirname(__FILE__)}/../..
|
|
123
|
+
rake compile
|
|
124
|
+
|
|
125
|
+
Or if you're using this gem in a Rails application:
|
|
126
|
+
bundle exec rake tasker_core:compile
|
|
127
|
+
|
|
128
|
+
Original error: #{e.message}
|
|
129
|
+
|
|
130
|
+
MSG
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Load Ruby modules after Rust extension (they depend on Rust base classes)
|
|
134
|
+
require_relative 'tasker_core/errors'
|
|
135
|
+
require_relative 'tasker_core/logger'
|
|
136
|
+
require_relative 'tasker_core/tracing' # TAS-29 Phase 6: Unified structured logging via FFI
|
|
137
|
+
require_relative 'tasker_core/internal'
|
|
138
|
+
require_relative 'tasker_core/template_discovery'
|
|
139
|
+
require_relative 'tasker_core/test_environment'
|
|
140
|
+
require_relative 'tasker_core/types'
|
|
141
|
+
require_relative 'tasker_core/models'
|
|
142
|
+
require_relative 'tasker_core/handlers'
|
|
143
|
+
require_relative 'tasker_core/batch_processing/batch_worker_context'
|
|
144
|
+
require_relative 'tasker_core/batch_processing/batch_aggregation_scenario'
|
|
145
|
+
require_relative 'tasker_core/registry'
|
|
146
|
+
require_relative 'tasker_core/subscriber'
|
|
147
|
+
require_relative 'tasker_core/event_bridge'
|
|
148
|
+
require_relative 'tasker_core/domain_events' # TAS-65: Domain event publisher infrastructure
|
|
149
|
+
require_relative 'tasker_core/observability' # TAS-77: Observability via FFI
|
|
150
|
+
require_relative 'tasker_core/worker/event_poller'
|
|
151
|
+
require_relative 'tasker_core/worker/in_process_domain_event_poller' # TAS-65: Fast domain events
|
|
152
|
+
require_relative 'tasker_core/bootstrap'
|
|
153
|
+
|
|
154
|
+
module TaskerCore
|
|
155
|
+
module Worker
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Load test environment components if appropriate (before auto-boot)
|
|
160
|
+
TaskerCore::TestEnvironment.load_if_test!
|