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,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TaskerCore
|
|
4
|
+
module DomainEvents
|
|
5
|
+
# TAS-65: Base class for domain event subscribers
|
|
6
|
+
#
|
|
7
|
+
# Domain event subscribers handle business events published by steps.
|
|
8
|
+
# They subscribe to event patterns and receive events via the in-process
|
|
9
|
+
# event bus (for fast events) or can poll PGMQ (for durable events).
|
|
10
|
+
#
|
|
11
|
+
# @example Creating a subscriber for payment events
|
|
12
|
+
# class PaymentEventSubscriber < TaskerCore::DomainEvents::BaseSubscriber
|
|
13
|
+
# # Match all payment.* events
|
|
14
|
+
# subscribes_to 'payment.*'
|
|
15
|
+
#
|
|
16
|
+
# def handle(event)
|
|
17
|
+
# case event[:event_name]
|
|
18
|
+
# when 'payment.processed'
|
|
19
|
+
# notify_accounting(event[:business_payload])
|
|
20
|
+
# when 'payment.failed'
|
|
21
|
+
# alert_support(event[:business_payload])
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# private
|
|
26
|
+
#
|
|
27
|
+
# def notify_accounting(payload)
|
|
28
|
+
# # Send to accounting system
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# def alert_support(payload)
|
|
32
|
+
# # Alert support team
|
|
33
|
+
# end
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# @example Creating a subscriber for metrics collection
|
|
37
|
+
# class MetricsSubscriber < TaskerCore::DomainEvents::BaseSubscriber
|
|
38
|
+
# # Match all events
|
|
39
|
+
# subscribes_to '*'
|
|
40
|
+
#
|
|
41
|
+
# def handle(event)
|
|
42
|
+
# StatsD.increment("domain_events.#{event[:event_name].gsub('.', '_')}")
|
|
43
|
+
# end
|
|
44
|
+
# end
|
|
45
|
+
#
|
|
46
|
+
# @example Registering and starting subscribers
|
|
47
|
+
# # In bootstrap
|
|
48
|
+
# subscribers = [
|
|
49
|
+
# PaymentEventSubscriber.new,
|
|
50
|
+
# MetricsSubscriber.new
|
|
51
|
+
# ]
|
|
52
|
+
#
|
|
53
|
+
# subscribers.each(&:start!)
|
|
54
|
+
#
|
|
55
|
+
class BaseSubscriber
|
|
56
|
+
class << self
|
|
57
|
+
# DSL method to declare event pattern subscriptions
|
|
58
|
+
#
|
|
59
|
+
# @param patterns [Array<String>] Event patterns to subscribe to
|
|
60
|
+
def subscribes_to(*patterns)
|
|
61
|
+
@patterns = patterns.flatten
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Get declared patterns
|
|
65
|
+
#
|
|
66
|
+
# @return [Array<String>]
|
|
67
|
+
def patterns
|
|
68
|
+
@patterns || ['*']
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
attr_reader :logger, :active
|
|
73
|
+
|
|
74
|
+
def initialize
|
|
75
|
+
@logger = TaskerCore::Logger.instance
|
|
76
|
+
@active = false
|
|
77
|
+
@subscriptions = []
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Start listening for events
|
|
81
|
+
def start!
|
|
82
|
+
return if @active
|
|
83
|
+
|
|
84
|
+
@active = true
|
|
85
|
+
poller = TaskerCore::Worker::InProcessDomainEventPoller.instance
|
|
86
|
+
|
|
87
|
+
self.class.patterns.each do |pattern|
|
|
88
|
+
# Subscribe to the poller with this subscriber's handler
|
|
89
|
+
poller.subscribe(pattern) do |event|
|
|
90
|
+
handle_event_safely(event)
|
|
91
|
+
end
|
|
92
|
+
@subscriptions << pattern
|
|
93
|
+
logger.info "#{self.class.name}: Subscribed to pattern '#{pattern}'"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
logger.info "#{self.class.name}: Started with #{@subscriptions.size} subscriptions"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Stop listening for events
|
|
100
|
+
def stop!
|
|
101
|
+
return unless @active
|
|
102
|
+
|
|
103
|
+
@active = false
|
|
104
|
+
poller = TaskerCore::Worker::InProcessDomainEventPoller.instance
|
|
105
|
+
|
|
106
|
+
@subscriptions.each do |pattern|
|
|
107
|
+
poller.unsubscribe(pattern)
|
|
108
|
+
end
|
|
109
|
+
@subscriptions.clear
|
|
110
|
+
|
|
111
|
+
logger.info "#{self.class.name}: Stopped"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Check if subscriber is active
|
|
115
|
+
def active?
|
|
116
|
+
@active
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Handle a domain event
|
|
120
|
+
#
|
|
121
|
+
# Subclasses MUST implement this method.
|
|
122
|
+
#
|
|
123
|
+
# @param event [Hash] The domain event
|
|
124
|
+
# - :event_name [String] The event name (e.g., "order.processed")
|
|
125
|
+
# - :business_payload [Hash] The business data from the step
|
|
126
|
+
# - :metadata [Hash] Event metadata (task_uuid, step_uuid, correlation_id, etc.)
|
|
127
|
+
# - :execution_result [Hash] The step execution result
|
|
128
|
+
def handle(event)
|
|
129
|
+
raise NotImplementedError, "#{self.class} must implement #handle"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Hook called before handling an event
|
|
133
|
+
#
|
|
134
|
+
# Override for pre-processing, validation, or filtering.
|
|
135
|
+
# Return false to skip handling this event.
|
|
136
|
+
#
|
|
137
|
+
# @param event [Hash] The domain event
|
|
138
|
+
# @return [Boolean] true to continue handling, false to skip
|
|
139
|
+
def before_handle(event) # rubocop:disable Lint/UnusedMethodArgument
|
|
140
|
+
true
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Hook called after successful handling
|
|
144
|
+
#
|
|
145
|
+
# Override for post-processing, metrics, or cleanup.
|
|
146
|
+
#
|
|
147
|
+
# @param event [Hash] The domain event
|
|
148
|
+
def after_handle(event)
|
|
149
|
+
# Default: no-op
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Hook called if handling fails
|
|
153
|
+
#
|
|
154
|
+
# Override for custom error handling, alerts, or retry logic.
|
|
155
|
+
# Note: Domain event handling uses fire-and-forget semantics - errors
|
|
156
|
+
# are logged but not propagated.
|
|
157
|
+
#
|
|
158
|
+
# @param event [Hash] The domain event
|
|
159
|
+
# @param error [Exception] The error that occurred
|
|
160
|
+
def on_handle_error(event, error)
|
|
161
|
+
logger.error "#{self.class.name}: Failed to handle #{event[:event_name]}: #{error.message}"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
# Safely handle an event with error capture
|
|
167
|
+
def handle_event_safely(event)
|
|
168
|
+
return unless @active
|
|
169
|
+
return unless before_handle(event)
|
|
170
|
+
|
|
171
|
+
handle(event)
|
|
172
|
+
after_handle(event)
|
|
173
|
+
rescue StandardError => e
|
|
174
|
+
on_handle_error(event, e)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'singleton'
|
|
4
|
+
|
|
5
|
+
module TaskerCore
|
|
6
|
+
module DomainEvents
|
|
7
|
+
# TAS-65: Registry for custom domain event publishers
|
|
8
|
+
#
|
|
9
|
+
# Maps publisher names (from YAML configuration) to their Ruby implementations.
|
|
10
|
+
# Publishers are registered at bootstrap time and validated against task templates.
|
|
11
|
+
#
|
|
12
|
+
# @example Registering publishers at bootstrap
|
|
13
|
+
# registry = TaskerCore::DomainEvents::PublisherRegistry.instance
|
|
14
|
+
#
|
|
15
|
+
# # Register custom publishers
|
|
16
|
+
# registry.register(PaymentEventPublisher.new)
|
|
17
|
+
# registry.register(InventoryEventPublisher.new)
|
|
18
|
+
#
|
|
19
|
+
# # Validate against loaded templates
|
|
20
|
+
# required = ['PaymentEventPublisher', 'InventoryEventPublisher', 'MissingPublisher']
|
|
21
|
+
# registry.validate_required!(required)
|
|
22
|
+
# # => raises ValidationError: Missing publishers: MissingPublisher
|
|
23
|
+
#
|
|
24
|
+
# @example Looking up publishers
|
|
25
|
+
# publisher = registry.get('PaymentEventPublisher')
|
|
26
|
+
# publisher.transform_payload(step_result, event_declaration)
|
|
27
|
+
#
|
|
28
|
+
# @example Using default publisher
|
|
29
|
+
# # Returns DefaultPublisher for unregistered names
|
|
30
|
+
# publisher = registry.get_or_default('UnknownPublisher')
|
|
31
|
+
#
|
|
32
|
+
class PublisherRegistry
|
|
33
|
+
include Singleton
|
|
34
|
+
|
|
35
|
+
# Error classes nested in PublisherRegistry for cleaner namespacing
|
|
36
|
+
class ValidationError < StandardError
|
|
37
|
+
attr_reader :missing_publishers, :registered_publishers
|
|
38
|
+
|
|
39
|
+
def initialize(missing, registered)
|
|
40
|
+
@missing_publishers = missing
|
|
41
|
+
@registered_publishers = registered
|
|
42
|
+
super("Missing publishers: #{missing.join(', ')}. Registered: #{registered.join(', ')}")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class PublisherNotFoundError < StandardError
|
|
47
|
+
attr_reader :publisher_name, :registered_publishers
|
|
48
|
+
|
|
49
|
+
def initialize(name, registered)
|
|
50
|
+
@publisher_name = name
|
|
51
|
+
@registered_publishers = registered
|
|
52
|
+
super("Publisher '#{name}' not found. Registered: #{registered.join(', ')}")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
class RegistryFrozenError < StandardError; end
|
|
57
|
+
|
|
58
|
+
class DuplicatePublisherError < StandardError
|
|
59
|
+
attr_reader :publisher_name
|
|
60
|
+
|
|
61
|
+
def initialize(name)
|
|
62
|
+
@publisher_name = name
|
|
63
|
+
super("Publisher '#{name}' is already registered")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Default publisher that passes step result through unchanged
|
|
68
|
+
class DefaultPublisher < BasePublisher
|
|
69
|
+
def name
|
|
70
|
+
'default'
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def transform_payload(step_result, _event_declaration, _step_context = nil)
|
|
74
|
+
step_result[:result] || {}
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
attr_reader :logger, :publishers, :default_publisher
|
|
79
|
+
|
|
80
|
+
def initialize
|
|
81
|
+
@logger = TaskerCore::Logger.instance
|
|
82
|
+
@publishers = {}
|
|
83
|
+
@default_publisher = DefaultPublisher.new
|
|
84
|
+
@frozen = false
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Register a custom publisher
|
|
88
|
+
#
|
|
89
|
+
# @param publisher [BasePublisher] The publisher instance to register
|
|
90
|
+
# @return [BasePublisher, nil] The previous publisher with the same name, if any
|
|
91
|
+
# @raise [ArgumentError] If publisher does not inherit from BasePublisher
|
|
92
|
+
# @raise [DuplicatePublisherError] If a publisher with the same name is already registered
|
|
93
|
+
# @raise [RegistryFrozenError] If the registry has been frozen
|
|
94
|
+
def register(publisher)
|
|
95
|
+
# Type validation first
|
|
96
|
+
raise ArgumentError, "Expected BasePublisher, got #{publisher.class}" unless publisher.is_a?(BasePublisher)
|
|
97
|
+
|
|
98
|
+
raise RegistryFrozenError, 'Registry is frozen after validation' if @frozen
|
|
99
|
+
|
|
100
|
+
name = publisher.name
|
|
101
|
+
|
|
102
|
+
# Check for duplicates
|
|
103
|
+
raise DuplicatePublisherError, name if @publishers.key?(name)
|
|
104
|
+
|
|
105
|
+
logger.info "Registering domain event publisher: #{name}"
|
|
106
|
+
@publishers[name] = publisher
|
|
107
|
+
nil # No previous since we now disallow duplicates
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Get a publisher by name
|
|
111
|
+
#
|
|
112
|
+
# @param name [String] The publisher name
|
|
113
|
+
# @return [BasePublisher, nil] The publisher, or nil if not found
|
|
114
|
+
def get(name)
|
|
115
|
+
@publishers[name]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Get a publisher by name, or return the default if not found
|
|
119
|
+
#
|
|
120
|
+
# @param name [String, nil] The publisher name
|
|
121
|
+
# @return [BasePublisher] The publisher or default
|
|
122
|
+
def get_or_default(name)
|
|
123
|
+
return @default_publisher if name.nil? || name == 'default'
|
|
124
|
+
|
|
125
|
+
@publishers[name] || begin
|
|
126
|
+
logger.warn "Publisher #{name} not found, using default"
|
|
127
|
+
@default_publisher
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Get a publisher by name with strict mode (no fallback)
|
|
132
|
+
#
|
|
133
|
+
# @param name [String] The publisher name
|
|
134
|
+
# @return [BasePublisher] The publisher
|
|
135
|
+
# @raise [PublisherNotFoundError] If the publisher is not registered
|
|
136
|
+
def get_strict(name)
|
|
137
|
+
return @default_publisher if name == 'default'
|
|
138
|
+
|
|
139
|
+
@publishers[name] || raise(
|
|
140
|
+
PublisherNotFoundError.new(name, registered_names)
|
|
141
|
+
)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Check if a publisher is registered
|
|
145
|
+
#
|
|
146
|
+
# @param name [String] The publisher name
|
|
147
|
+
# @return [Boolean]
|
|
148
|
+
def registered?(name)
|
|
149
|
+
@publishers.key?(name) || name == 'default'
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Get all registered publisher names
|
|
153
|
+
#
|
|
154
|
+
# @return [Array<String>]
|
|
155
|
+
def registered_names
|
|
156
|
+
@publishers.keys
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Get count of registered publishers
|
|
160
|
+
#
|
|
161
|
+
# @return [Integer]
|
|
162
|
+
def count
|
|
163
|
+
@publishers.size
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Check if registry has no custom publishers
|
|
167
|
+
#
|
|
168
|
+
# @return [Boolean]
|
|
169
|
+
def empty?
|
|
170
|
+
@publishers.empty?
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Unregister a publisher by name
|
|
174
|
+
#
|
|
175
|
+
# @param name [String] The publisher name
|
|
176
|
+
# @return [BasePublisher, nil] The removed publisher, if any
|
|
177
|
+
# @raise [RegistryFrozenError] If the registry has been frozen
|
|
178
|
+
def unregister(name)
|
|
179
|
+
raise RegistryFrozenError, 'Registry is frozen after validation' if @frozen
|
|
180
|
+
|
|
181
|
+
logger.info "Unregistering domain event publisher: #{name}"
|
|
182
|
+
@publishers.delete(name)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Clear all registered publishers
|
|
186
|
+
#
|
|
187
|
+
# @raise [RegistryFrozenError] If the registry has been frozen
|
|
188
|
+
def clear
|
|
189
|
+
raise RegistryFrozenError, 'Registry is frozen after validation' if @frozen
|
|
190
|
+
|
|
191
|
+
logger.info 'Clearing all domain event publishers'
|
|
192
|
+
@publishers.clear
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# TAS-65: Validate that all required publishers are registered
|
|
196
|
+
#
|
|
197
|
+
# Implements "loud failure validation" - validates at init time that all
|
|
198
|
+
# publisher names referenced in task templates exist in the registry.
|
|
199
|
+
# After validation, the registry is frozen to prevent changes.
|
|
200
|
+
#
|
|
201
|
+
# @param required_publishers [Array<String>] Publisher names from YAML configs
|
|
202
|
+
# @return [true] If all required publishers are registered
|
|
203
|
+
# @raise [ValidationError] If some publishers are missing
|
|
204
|
+
def validate_required!(required_publishers)
|
|
205
|
+
missing = []
|
|
206
|
+
|
|
207
|
+
required_publishers.each do |name|
|
|
208
|
+
next if name == 'default'
|
|
209
|
+
next if registered?(name)
|
|
210
|
+
|
|
211
|
+
missing << name
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
raise ValidationError.new(missing, registered_names) if missing.any?
|
|
215
|
+
|
|
216
|
+
@frozen = true
|
|
217
|
+
logger.info "Publisher validation passed. Registered: #{registered_names.join(', ')}"
|
|
218
|
+
true
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Check if the registry is frozen
|
|
222
|
+
#
|
|
223
|
+
# @return [Boolean]
|
|
224
|
+
def frozen?
|
|
225
|
+
@frozen
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Freeze the registry to prevent further changes
|
|
229
|
+
#
|
|
230
|
+
# @return [void]
|
|
231
|
+
def freeze!
|
|
232
|
+
@frozen = true
|
|
233
|
+
logger.info 'Publisher registry frozen'
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Reset the registry (for testing)
|
|
237
|
+
#
|
|
238
|
+
# @note This unfreezes the registry - use only in tests
|
|
239
|
+
def reset!
|
|
240
|
+
@publishers.clear
|
|
241
|
+
@frozen = false
|
|
242
|
+
logger.info 'Publisher registry reset'
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Backwards-compatible aliases for classes that were moved into PublisherRegistry
|
|
247
|
+
# These allow existing code to use the old namespace while we transition
|
|
248
|
+
DefaultPublisher = PublisherRegistry::DefaultPublisher
|
|
249
|
+
ValidationError = PublisherRegistry::ValidationError
|
|
250
|
+
PublisherNotFoundError = PublisherRegistry::PublisherNotFoundError
|
|
251
|
+
RegistryFrozenError = PublisherRegistry::RegistryFrozenError
|
|
252
|
+
end
|
|
253
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'singleton'
|
|
4
|
+
|
|
5
|
+
module TaskerCore
|
|
6
|
+
module DomainEvents
|
|
7
|
+
# TAS-65: Registry for domain event subscribers
|
|
8
|
+
#
|
|
9
|
+
# Manages the lifecycle of domain event subscribers. Subscribers are registered
|
|
10
|
+
# at bootstrap time and started/stopped together with the worker.
|
|
11
|
+
#
|
|
12
|
+
# @example Registering subscribers at bootstrap
|
|
13
|
+
# registry = TaskerCore::DomainEvents::SubscriberRegistry.instance
|
|
14
|
+
#
|
|
15
|
+
# # Register subscriber classes (instantiation deferred)
|
|
16
|
+
# registry.register(PaymentEventSubscriber)
|
|
17
|
+
# registry.register(MetricsSubscriber)
|
|
18
|
+
#
|
|
19
|
+
# # Or register instances directly
|
|
20
|
+
# registry.register_instance(CustomSubscriber.new(some_config))
|
|
21
|
+
#
|
|
22
|
+
# # Start all subscribers
|
|
23
|
+
# registry.start_all!
|
|
24
|
+
#
|
|
25
|
+
# @example Stopping all subscribers
|
|
26
|
+
# registry.stop_all!
|
|
27
|
+
#
|
|
28
|
+
class SubscriberRegistry
|
|
29
|
+
include Singleton
|
|
30
|
+
|
|
31
|
+
attr_reader :logger, :subscribers
|
|
32
|
+
|
|
33
|
+
def initialize
|
|
34
|
+
@logger = TaskerCore::Logger.instance
|
|
35
|
+
@subscribers = []
|
|
36
|
+
@started = false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Register a subscriber class
|
|
40
|
+
#
|
|
41
|
+
# The class will be instantiated when start_all! is called.
|
|
42
|
+
#
|
|
43
|
+
# @param subscriber_class [Class] A subclass of BaseSubscriber
|
|
44
|
+
# @return [void]
|
|
45
|
+
def register(subscriber_class)
|
|
46
|
+
unless subscriber_class < BaseSubscriber
|
|
47
|
+
raise ArgumentError, "#{subscriber_class} must be a subclass of BaseSubscriber"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
logger.info "SubscriberRegistry: Registered #{subscriber_class.name}"
|
|
51
|
+
@subscriber_classes ||= []
|
|
52
|
+
@subscriber_classes << subscriber_class
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Register a subscriber instance directly
|
|
56
|
+
#
|
|
57
|
+
# Use this when your subscriber needs custom initialization.
|
|
58
|
+
#
|
|
59
|
+
# @param subscriber [BaseSubscriber] A subscriber instance
|
|
60
|
+
# @return [void]
|
|
61
|
+
def register_instance(subscriber)
|
|
62
|
+
raise ArgumentError, "Expected BaseSubscriber, got #{subscriber.class}" unless subscriber.is_a?(BaseSubscriber)
|
|
63
|
+
|
|
64
|
+
logger.info "SubscriberRegistry: Registered instance of #{subscriber.class.name}"
|
|
65
|
+
@subscribers << subscriber
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Start all registered subscribers
|
|
69
|
+
#
|
|
70
|
+
# Instantiates registered classes and starts all subscribers.
|
|
71
|
+
#
|
|
72
|
+
# @return [void]
|
|
73
|
+
def start_all!
|
|
74
|
+
return if @started
|
|
75
|
+
|
|
76
|
+
# Instantiate registered classes
|
|
77
|
+
(@subscriber_classes || []).each do |klass|
|
|
78
|
+
@subscribers << klass.new
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Start all subscribers
|
|
82
|
+
@subscribers.each do |subscriber|
|
|
83
|
+
subscriber.start!
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
logger.error "Failed to start #{subscriber.class.name}: #{e.message}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
@started = true
|
|
89
|
+
logger.info "SubscriberRegistry: Started #{@subscribers.size} subscribers"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Stop all subscribers
|
|
93
|
+
#
|
|
94
|
+
# @return [void]
|
|
95
|
+
def stop_all!
|
|
96
|
+
return unless @started
|
|
97
|
+
|
|
98
|
+
@subscribers.each do |subscriber|
|
|
99
|
+
subscriber.stop!
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
logger.error "Failed to stop #{subscriber.class.name}: #{e.message}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
@started = false
|
|
105
|
+
logger.info 'SubscriberRegistry: Stopped all subscribers'
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Check if subscribers have been started
|
|
109
|
+
#
|
|
110
|
+
# @return [Boolean]
|
|
111
|
+
def started?
|
|
112
|
+
@started
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Get count of registered subscribers
|
|
116
|
+
#
|
|
117
|
+
# @return [Integer]
|
|
118
|
+
def count
|
|
119
|
+
@subscribers.size + (@subscriber_classes&.size || 0)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Get subscriber statistics
|
|
123
|
+
#
|
|
124
|
+
# @return [Hash]
|
|
125
|
+
def stats
|
|
126
|
+
{
|
|
127
|
+
started: @started,
|
|
128
|
+
subscriber_count: @subscribers.size,
|
|
129
|
+
active_count: @subscribers.count(&:active?),
|
|
130
|
+
subscribers: @subscribers.map do |s|
|
|
131
|
+
{
|
|
132
|
+
class: s.class.name,
|
|
133
|
+
active: s.active?,
|
|
134
|
+
patterns: s.class.patterns
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Reset the registry (for testing)
|
|
141
|
+
#
|
|
142
|
+
# @note This stops all subscribers first
|
|
143
|
+
def reset!
|
|
144
|
+
stop_all! if @started
|
|
145
|
+
@subscribers.clear
|
|
146
|
+
@subscriber_classes&.clear
|
|
147
|
+
@started = false
|
|
148
|
+
logger.info 'SubscriberRegistry: Reset'
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# TAS-65: Domain Events Module
|
|
4
|
+
#
|
|
5
|
+
# Provides infrastructure for custom domain event publishers.
|
|
6
|
+
# Domain events are business events (e.g., "order.processed", "payment.completed")
|
|
7
|
+
# published after step execution based on YAML declarations.
|
|
8
|
+
#
|
|
9
|
+
# @example Using the domain events infrastructure
|
|
10
|
+
# # Register custom publishers at bootstrap
|
|
11
|
+
# require 'tasker_core/domain_events'
|
|
12
|
+
#
|
|
13
|
+
# class PaymentEventPublisher < TaskerCore::DomainEvents::BasePublisher
|
|
14
|
+
# def name
|
|
15
|
+
# 'PaymentEventPublisher'
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# def transform_payload(step_result, event_declaration, step_context)
|
|
19
|
+
# {
|
|
20
|
+
# payment_id: step_result[:result][:payment_id],
|
|
21
|
+
# amount: step_result[:result][:amount],
|
|
22
|
+
# status: step_result[:success] ? 'succeeded' : 'failed'
|
|
23
|
+
# }
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# TaskerCore::DomainEvents::PublisherRegistry.instance.register(
|
|
28
|
+
# PaymentEventPublisher.new
|
|
29
|
+
# )
|
|
30
|
+
#
|
|
31
|
+
module TaskerCore
|
|
32
|
+
module DomainEvents
|
|
33
|
+
# Load domain events components
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Publishers
|
|
38
|
+
require_relative 'domain_events/base_publisher'
|
|
39
|
+
require_relative 'domain_events/publisher_registry'
|
|
40
|
+
|
|
41
|
+
# Subscribers
|
|
42
|
+
require_relative 'domain_events/base_subscriber'
|
|
43
|
+
require_relative 'domain_events/subscriber_registry'
|