tasker-rb 0.1.3-arm64-darwin
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/README.md +55 -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/client.rb +165 -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.bundle +0 -0
- data/lib/tasker_core/template_discovery.rb +181 -0
- data/lib/tasker_core/test_environment.rb +313 -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 +161 -0
- metadata +292 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TaskerCore
|
|
4
|
+
module DomainEvents
|
|
5
|
+
# TAS-65: Base class for custom domain event publishers
|
|
6
|
+
#
|
|
7
|
+
# Domain event publishers transform step execution results into business events.
|
|
8
|
+
# They are declared in task template YAML via the `publisher:` field and registered
|
|
9
|
+
# at bootstrap time.
|
|
10
|
+
#
|
|
11
|
+
# @example Creating a custom publisher
|
|
12
|
+
# class PaymentEventPublisher < TaskerCore::DomainEvents::BasePublisher
|
|
13
|
+
# def name
|
|
14
|
+
# 'PaymentEventPublisher'
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# def transform_payload(step_result, event_declaration)
|
|
18
|
+
# # Extract business-specific payload from step result
|
|
19
|
+
# {
|
|
20
|
+
# payment_id: step_result[:result][:payment_id],
|
|
21
|
+
# amount: step_result[:result][:amount],
|
|
22
|
+
# currency: step_result[:result][:currency],
|
|
23
|
+
# status: step_result[:success] ? 'succeeded' : 'failed'
|
|
24
|
+
# }
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# def should_publish?(step_result, event_declaration)
|
|
28
|
+
# # Only publish for successful payments
|
|
29
|
+
# step_result[:success] && step_result[:result][:payment_id].present?
|
|
30
|
+
# end
|
|
31
|
+
# end
|
|
32
|
+
#
|
|
33
|
+
# @example Registering a custom publisher
|
|
34
|
+
# TaskerCore::DomainEvents::PublisherRegistry.instance.register(
|
|
35
|
+
# PaymentEventPublisher.new
|
|
36
|
+
# )
|
|
37
|
+
#
|
|
38
|
+
# @example YAML declaration
|
|
39
|
+
# steps:
|
|
40
|
+
# - name: process_payment
|
|
41
|
+
# publishes_events:
|
|
42
|
+
# - name: payment.processed
|
|
43
|
+
# publisher: PaymentEventPublisher
|
|
44
|
+
# delivery_mode: durable
|
|
45
|
+
# condition: success
|
|
46
|
+
#
|
|
47
|
+
class BasePublisher
|
|
48
|
+
attr_reader :logger
|
|
49
|
+
|
|
50
|
+
def initialize
|
|
51
|
+
@logger = TaskerCore::Logger.instance
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The publisher name used for registry lookup
|
|
55
|
+
# Must match the `publisher:` field in YAML
|
|
56
|
+
#
|
|
57
|
+
# @return [String] The publisher name
|
|
58
|
+
def name
|
|
59
|
+
raise NotImplementedError, "#{self.class} must implement #name"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Transform step result into business event payload
|
|
63
|
+
#
|
|
64
|
+
# Override this to customize the event payload for your domain.
|
|
65
|
+
# The default implementation returns the step result as-is.
|
|
66
|
+
#
|
|
67
|
+
# @param step_result [Hash] The step execution result
|
|
68
|
+
# - :success [Boolean] Whether the step succeeded
|
|
69
|
+
# - :result [Hash] The step handler's return value
|
|
70
|
+
# - :metadata [Hash] Execution metadata
|
|
71
|
+
# @param event_declaration [Hash] The event declaration from YAML
|
|
72
|
+
# - :name [String] The event name (e.g., "order.processed")
|
|
73
|
+
# - :delivery_mode [String] "durable" or "fast"
|
|
74
|
+
# - :condition [String] "success", "failure", or "always"
|
|
75
|
+
# @param step_context [Hash] The step execution context
|
|
76
|
+
# - :task [Hash] Task information
|
|
77
|
+
# - :workflow_step [Hash] Workflow step information
|
|
78
|
+
# - :step_definition [Hash] Step definition from YAML
|
|
79
|
+
#
|
|
80
|
+
# @return [Hash] The business event payload to publish
|
|
81
|
+
def transform_payload(step_result, _event_declaration, _step_context = nil)
|
|
82
|
+
step_result[:result] || {}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Determine if this event should be published
|
|
86
|
+
#
|
|
87
|
+
# Override this to add custom publishing conditions beyond the
|
|
88
|
+
# YAML `condition:` field. The YAML condition is evaluated first,
|
|
89
|
+
# then this method is called.
|
|
90
|
+
#
|
|
91
|
+
# @param step_result [Hash] The step execution result
|
|
92
|
+
# @param event_declaration [Hash] The event declaration from YAML
|
|
93
|
+
# @param step_context [Hash] The step execution context
|
|
94
|
+
#
|
|
95
|
+
# @return [Boolean] true if the event should be published
|
|
96
|
+
def should_publish?(_step_result, _event_declaration, _step_context = nil)
|
|
97
|
+
true
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Add additional metadata to the event
|
|
101
|
+
#
|
|
102
|
+
# Override this to add custom metadata fields to the event.
|
|
103
|
+
# Default returns empty hash.
|
|
104
|
+
#
|
|
105
|
+
# @param step_result [Hash] The step execution result
|
|
106
|
+
# @param event_declaration [Hash] The event declaration from YAML
|
|
107
|
+
# @param step_context [Hash] The step execution context
|
|
108
|
+
#
|
|
109
|
+
# @return [Hash] Additional metadata to merge into event metadata
|
|
110
|
+
def additional_metadata(_step_result, _event_declaration, _step_context = nil)
|
|
111
|
+
{}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Hook called before publishing
|
|
115
|
+
#
|
|
116
|
+
# Override for pre-publish validation, logging, or metrics.
|
|
117
|
+
# Raise an exception to prevent publishing.
|
|
118
|
+
#
|
|
119
|
+
# @param event_name [String] The event name
|
|
120
|
+
# @param payload [Hash] The transformed payload
|
|
121
|
+
# @param metadata [Hash] The event metadata
|
|
122
|
+
def before_publish(event_name, _payload, _metadata)
|
|
123
|
+
logger.debug "Publishing event: #{event_name}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Hook called after successful publishing
|
|
127
|
+
#
|
|
128
|
+
# Override for post-publish logging, metrics, or cleanup.
|
|
129
|
+
#
|
|
130
|
+
# @param event_name [String] The event name
|
|
131
|
+
# @param payload [Hash] The transformed payload
|
|
132
|
+
# @param metadata [Hash] The event metadata
|
|
133
|
+
def after_publish(event_name, _payload, _metadata)
|
|
134
|
+
logger.debug "Event published: #{event_name}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Hook called if publishing fails
|
|
138
|
+
#
|
|
139
|
+
# Override for error handling, logging, or fallback behavior.
|
|
140
|
+
# Default logs the error but does not re-raise.
|
|
141
|
+
#
|
|
142
|
+
# @param event_name [String] The event name
|
|
143
|
+
# @param error [Exception] The error that occurred
|
|
144
|
+
# @param payload [Hash] The transformed payload
|
|
145
|
+
def on_publish_error(event_name, error, _payload)
|
|
146
|
+
logger.error "Failed to publish event #{event_name}: #{error.message}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# ========================================================================
|
|
150
|
+
# CROSS-LANGUAGE STANDARD API (TAS-96)
|
|
151
|
+
# ========================================================================
|
|
152
|
+
|
|
153
|
+
# Cross-language standard: Publish an event with unified context
|
|
154
|
+
#
|
|
155
|
+
# This method coordinates the existing hooks (should_publish?, transform_payload,
|
|
156
|
+
# additional_metadata, before_publish, after_publish) into a single publish call.
|
|
157
|
+
#
|
|
158
|
+
# @param ctx [Hash] Step event context containing:
|
|
159
|
+
# - :event_name [String] The event name (e.g., "payment.processed")
|
|
160
|
+
# - :step_result [Hash] The step execution result
|
|
161
|
+
# - :event_declaration [Hash] The event declaration from YAML
|
|
162
|
+
# - :step_context [Hash, TaskerCore::Types::StepContext] Step execution context
|
|
163
|
+
# @return [Boolean] true if event was published, false if skipped
|
|
164
|
+
#
|
|
165
|
+
# @example Publishing with context
|
|
166
|
+
# ctx = {
|
|
167
|
+
# event_name: 'payment.processed',
|
|
168
|
+
# step_result: { success: true, result: { payment_id: '123' } },
|
|
169
|
+
# event_declaration: { name: 'payment.processed', delivery_mode: 'durable' },
|
|
170
|
+
# step_context: step_context
|
|
171
|
+
# }
|
|
172
|
+
# publisher.publish(ctx)
|
|
173
|
+
def publish(ctx)
|
|
174
|
+
event_name = ctx[:event_name]
|
|
175
|
+
step_result = ctx[:step_result]
|
|
176
|
+
event_declaration = ctx[:event_declaration] || {}
|
|
177
|
+
step_context = ctx[:step_context]
|
|
178
|
+
|
|
179
|
+
# Check publishing conditions
|
|
180
|
+
return false unless should_publish?(step_result, event_declaration, step_context)
|
|
181
|
+
|
|
182
|
+
# Transform payload
|
|
183
|
+
payload = transform_payload(step_result, event_declaration, step_context)
|
|
184
|
+
|
|
185
|
+
# Build metadata
|
|
186
|
+
base_metadata = {
|
|
187
|
+
publisher: name,
|
|
188
|
+
published_at: Time.now.utc.iso8601
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
# Add step context info if available
|
|
192
|
+
if step_context.respond_to?(:task_uuid)
|
|
193
|
+
base_metadata[:task_uuid] = step_context.task_uuid
|
|
194
|
+
base_metadata[:step_uuid] = step_context.step_uuid
|
|
195
|
+
base_metadata[:step_name] = step_context.step_name if step_context.respond_to?(:step_name)
|
|
196
|
+
base_metadata[:namespace] = step_context.namespace_name if step_context.respond_to?(:namespace_name)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
metadata = base_metadata.merge(additional_metadata(step_result, event_declaration, step_context))
|
|
200
|
+
|
|
201
|
+
begin
|
|
202
|
+
# Pre-publish hook
|
|
203
|
+
before_publish(event_name, payload, metadata)
|
|
204
|
+
|
|
205
|
+
# Actual publishing is handled by the event router/bridge
|
|
206
|
+
# This method just prepares and validates the event
|
|
207
|
+
# Subclasses can override to perform actual publishing
|
|
208
|
+
|
|
209
|
+
# Post-publish hook
|
|
210
|
+
after_publish(event_name, payload, metadata)
|
|
211
|
+
|
|
212
|
+
true
|
|
213
|
+
rescue StandardError => e
|
|
214
|
+
on_publish_error(event_name, e, payload)
|
|
215
|
+
false
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -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
|