nats_pubsub 1.0.0
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/exe/nats_pubsub +44 -0
- data/lib/generators/nats_pubsub/config/config_generator.rb +174 -0
- data/lib/generators/nats_pubsub/config/templates/env.example.tt +46 -0
- data/lib/generators/nats_pubsub/config/templates/nats_pubsub.rb.tt +105 -0
- data/lib/generators/nats_pubsub/initializer/initializer_generator.rb +36 -0
- data/lib/generators/nats_pubsub/initializer/templates/nats_pubsub.rb +27 -0
- data/lib/generators/nats_pubsub/install/install_generator.rb +75 -0
- data/lib/generators/nats_pubsub/migrations/migrations_generator.rb +74 -0
- data/lib/generators/nats_pubsub/migrations/templates/create_nats_pubsub_inbox.rb.erb +88 -0
- data/lib/generators/nats_pubsub/migrations/templates/create_nats_pubsub_outbox.rb.erb +81 -0
- data/lib/generators/nats_pubsub/subscriber/subscriber_generator.rb +139 -0
- data/lib/generators/nats_pubsub/subscriber/templates/subscriber.rb.tt +117 -0
- data/lib/generators/nats_pubsub/subscriber/templates/subscriber_spec.rb.tt +116 -0
- data/lib/generators/nats_pubsub/subscriber/templates/subscriber_test.rb.tt +117 -0
- data/lib/nats_pubsub/active_record/publishable.rb +192 -0
- data/lib/nats_pubsub/cli.rb +105 -0
- data/lib/nats_pubsub/core/base_repository.rb +73 -0
- data/lib/nats_pubsub/core/config.rb +152 -0
- data/lib/nats_pubsub/core/config_presets.rb +139 -0
- data/lib/nats_pubsub/core/connection.rb +103 -0
- data/lib/nats_pubsub/core/constants.rb +190 -0
- data/lib/nats_pubsub/core/duration.rb +113 -0
- data/lib/nats_pubsub/core/error_action.rb +288 -0
- data/lib/nats_pubsub/core/event.rb +275 -0
- data/lib/nats_pubsub/core/health_check.rb +470 -0
- data/lib/nats_pubsub/core/logging.rb +72 -0
- data/lib/nats_pubsub/core/message_context.rb +193 -0
- data/lib/nats_pubsub/core/presets.rb +222 -0
- data/lib/nats_pubsub/core/retry_strategy.rb +71 -0
- data/lib/nats_pubsub/core/structured_logger.rb +141 -0
- data/lib/nats_pubsub/core/subject.rb +185 -0
- data/lib/nats_pubsub/instrumentation.rb +327 -0
- data/lib/nats_pubsub/middleware/active_record.rb +18 -0
- data/lib/nats_pubsub/middleware/chain.rb +92 -0
- data/lib/nats_pubsub/middleware/logging.rb +48 -0
- data/lib/nats_pubsub/middleware/retry_logger.rb +24 -0
- data/lib/nats_pubsub/middleware/structured_logging.rb +57 -0
- data/lib/nats_pubsub/models/event_model.rb +73 -0
- data/lib/nats_pubsub/models/inbox_event.rb +109 -0
- data/lib/nats_pubsub/models/model_codec_setup.rb +61 -0
- data/lib/nats_pubsub/models/model_utils.rb +57 -0
- data/lib/nats_pubsub/models/outbox_event.rb +113 -0
- data/lib/nats_pubsub/publisher/envelope_builder.rb +99 -0
- data/lib/nats_pubsub/publisher/fluent_batch.rb +262 -0
- data/lib/nats_pubsub/publisher/outbox_publisher.rb +97 -0
- data/lib/nats_pubsub/publisher/outbox_repository.rb +117 -0
- data/lib/nats_pubsub/publisher/publish_argument_parser.rb +108 -0
- data/lib/nats_pubsub/publisher/publish_result.rb +149 -0
- data/lib/nats_pubsub/publisher/publisher.rb +156 -0
- data/lib/nats_pubsub/rails/health_endpoint.rb +239 -0
- data/lib/nats_pubsub/railtie.rb +52 -0
- data/lib/nats_pubsub/subscribers/dlq_handler.rb +69 -0
- data/lib/nats_pubsub/subscribers/error_context.rb +137 -0
- data/lib/nats_pubsub/subscribers/error_handler.rb +110 -0
- data/lib/nats_pubsub/subscribers/graceful_shutdown.rb +128 -0
- data/lib/nats_pubsub/subscribers/inbox/inbox_message.rb +79 -0
- data/lib/nats_pubsub/subscribers/inbox/inbox_processor.rb +53 -0
- data/lib/nats_pubsub/subscribers/inbox/inbox_repository.rb +74 -0
- data/lib/nats_pubsub/subscribers/message_context.rb +86 -0
- data/lib/nats_pubsub/subscribers/message_processor.rb +225 -0
- data/lib/nats_pubsub/subscribers/message_router.rb +77 -0
- data/lib/nats_pubsub/subscribers/pool.rb +166 -0
- data/lib/nats_pubsub/subscribers/registry.rb +114 -0
- data/lib/nats_pubsub/subscribers/subscriber.rb +186 -0
- data/lib/nats_pubsub/subscribers/subscription_manager.rb +206 -0
- data/lib/nats_pubsub/subscribers/worker.rb +152 -0
- data/lib/nats_pubsub/tasks/install.rake +10 -0
- data/lib/nats_pubsub/testing/helpers.rb +199 -0
- data/lib/nats_pubsub/testing/matchers.rb +208 -0
- data/lib/nats_pubsub/testing/test_harness.rb +250 -0
- data/lib/nats_pubsub/testing.rb +157 -0
- data/lib/nats_pubsub/topology/overlap_guard.rb +88 -0
- data/lib/nats_pubsub/topology/stream.rb +102 -0
- data/lib/nats_pubsub/topology/stream_support.rb +170 -0
- data/lib/nats_pubsub/topology/subject_matcher.rb +77 -0
- data/lib/nats_pubsub/topology/topology.rb +24 -0
- data/lib/nats_pubsub/version.rb +8 -0
- data/lib/nats_pubsub/web/views/dashboard.erb +55 -0
- data/lib/nats_pubsub/web/views/inbox_detail.erb +91 -0
- data/lib/nats_pubsub/web/views/inbox_list.erb +62 -0
- data/lib/nats_pubsub/web/views/layout.erb +68 -0
- data/lib/nats_pubsub/web/views/outbox_detail.erb +77 -0
- data/lib/nats_pubsub/web/views/outbox_list.erb +62 -0
- data/lib/nats_pubsub/web.rb +181 -0
- data/lib/nats_pubsub.rb +290 -0
- metadata +225 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'oj'
|
|
4
|
+
require_relative '../core/logging'
|
|
5
|
+
require_relative '../core/error_action'
|
|
6
|
+
require_relative 'dlq_handler'
|
|
7
|
+
require_relative 'message_context'
|
|
8
|
+
require_relative 'error_context'
|
|
9
|
+
require_relative 'error_handler'
|
|
10
|
+
|
|
11
|
+
module NatsPubsub
|
|
12
|
+
module Subscribers
|
|
13
|
+
# Simple exponential backoff strategy for transient failures.
|
|
14
|
+
class BackoffStrategy
|
|
15
|
+
TRANSIENT_ERRORS = [Timeout::Error, IOError].freeze
|
|
16
|
+
MAX_EXPONENT = 6
|
|
17
|
+
MAX_DELAY = 60
|
|
18
|
+
MIN_DELAY = 1
|
|
19
|
+
|
|
20
|
+
# Calculates bounded backoff delay in seconds based on attempt number and error type
|
|
21
|
+
#
|
|
22
|
+
# @param attempt_number [Integer] Delivery attempt count
|
|
23
|
+
# @param error [Exception] Error that occurred
|
|
24
|
+
# @return [Integer] Delay in seconds
|
|
25
|
+
def calculate_backoff_delay(attempt_number, error)
|
|
26
|
+
base = transient_error?(error) ? 0.5 : 2.0
|
|
27
|
+
power = [attempt_number - 1, MAX_EXPONENT].min
|
|
28
|
+
raw = (base * (2**power)).to_i
|
|
29
|
+
raw.clamp(MIN_DELAY, MAX_DELAY)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Check if error is transient (temporary) vs permanent
|
|
35
|
+
#
|
|
36
|
+
# @param error [Exception] Error to check
|
|
37
|
+
# @return [Boolean] True if error is transient
|
|
38
|
+
def transient_error?(error)
|
|
39
|
+
TRANSIENT_ERRORS.any? { |k| error.is_a?(k) }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Orchestrates parse → handler → ack/nak → DLQ
|
|
44
|
+
class MessageProcessor
|
|
45
|
+
UNRECOVERABLE_ERRORS = [ArgumentError, TypeError].freeze
|
|
46
|
+
|
|
47
|
+
def initialize(jts, handler, dlq: nil, backoff: nil, subscriber: nil)
|
|
48
|
+
@jts = jts
|
|
49
|
+
@handler = handler
|
|
50
|
+
@dlq = dlq || DlqHandler.new(jts)
|
|
51
|
+
@backoff = backoff || BackoffStrategy.new
|
|
52
|
+
@subscriber = subscriber
|
|
53
|
+
@error_handler = subscriber ? ErrorHandler.new(subscriber) : nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def handle_message(msg)
|
|
57
|
+
ctx = MessageContext.build(msg)
|
|
58
|
+
event = parse_message(msg, ctx)
|
|
59
|
+
return unless event
|
|
60
|
+
|
|
61
|
+
ensure_dlq_stream if NatsPubsub.config.use_dlq
|
|
62
|
+
|
|
63
|
+
process_event(msg, event, ctx)
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
Logging.error(
|
|
66
|
+
"Processor crashed event_id=#{ctx&.event_id} subject=#{ctx&.subject} seq=#{ctx&.seq} " \
|
|
67
|
+
"deliveries=#{ctx&.deliveries} err=#{e.class}: #{e.message}",
|
|
68
|
+
tag: 'NatsPubsub::Subscribers::MessageProcessor'
|
|
69
|
+
)
|
|
70
|
+
safe_nak(msg)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def parse_message(msg, ctx)
|
|
76
|
+
data = msg.data
|
|
77
|
+
Oj.load(data, mode: :strict)
|
|
78
|
+
rescue Oj::ParseError => e
|
|
79
|
+
error_ctx = ErrorContext.from_exception(e, reason: 'malformed_json')
|
|
80
|
+
published = @dlq.publish_to_dlq(msg, ctx, error_context: error_ctx)
|
|
81
|
+
msg.ack if published || !NatsPubsub.config.use_dlq
|
|
82
|
+
safe_nak(msg) unless published || !NatsPubsub.config.use_dlq
|
|
83
|
+
Logging.warn(
|
|
84
|
+
"Malformed JSON → DLQ event_id=#{ctx.event_id} subject=#{ctx.subject} " \
|
|
85
|
+
"seq=#{ctx.seq} deliveries=#{ctx.deliveries}: #{error_ctx.to_log_string}",
|
|
86
|
+
tag: 'NatsPubsub::Subscribers::MessageProcessor'
|
|
87
|
+
)
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def process_event(msg, event, ctx)
|
|
92
|
+
@handler.call(event, ctx.subject, ctx.deliveries)
|
|
93
|
+
msg.ack
|
|
94
|
+
Logging.info(
|
|
95
|
+
"ACK event_id=#{ctx.event_id} subject=#{ctx.subject} seq=#{ctx.seq} deliveries=#{ctx.deliveries}",
|
|
96
|
+
tag: 'NatsPubsub::Subscribers::MessageProcessor'
|
|
97
|
+
)
|
|
98
|
+
rescue *UNRECOVERABLE_ERRORS => e
|
|
99
|
+
error_ctx = ErrorContext.from_exception(e, reason: 'unrecoverable')
|
|
100
|
+
published = @dlq.publish_to_dlq(msg, ctx, error_context: error_ctx)
|
|
101
|
+
msg.ack if published || !NatsPubsub.config.use_dlq
|
|
102
|
+
safe_nak(msg, ctx, e) unless published || !NatsPubsub.config.use_dlq
|
|
103
|
+
Logging.warn(
|
|
104
|
+
"DLQ (unrecoverable) event_id=#{ctx.event_id} subject=#{ctx.subject} " \
|
|
105
|
+
"seq=#{ctx.seq} deliveries=#{ctx.deliveries} err=#{error_ctx.to_log_string}",
|
|
106
|
+
tag: 'NatsPubsub::Subscribers::MessageProcessor'
|
|
107
|
+
)
|
|
108
|
+
rescue StandardError => e
|
|
109
|
+
acknowledge_or_retry(msg, ctx, e)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Decide whether to acknowledge message or retry based on delivery attempts
|
|
113
|
+
# Now uses ErrorHandler for fine-grained error handling
|
|
114
|
+
#
|
|
115
|
+
# @param msg [NATS::Msg] NATS message
|
|
116
|
+
# @param ctx [MessageContext] Message context
|
|
117
|
+
# @param error [Exception] Error that occurred
|
|
118
|
+
def acknowledge_or_retry(msg, ctx, error)
|
|
119
|
+
error_ctx = ErrorContext.from_exception(error, reason: 'processing_failed')
|
|
120
|
+
|
|
121
|
+
# Use ErrorHandler if available (subscriber has on_error method)
|
|
122
|
+
action = if @error_handler
|
|
123
|
+
event_data = parse_event_from_msg(msg)
|
|
124
|
+
@error_handler.handle_error(error, event_data, ctx, ctx.deliveries)
|
|
125
|
+
else
|
|
126
|
+
determine_legacy_action(ctx, error)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
execute_error_action(action, msg, ctx, error, error_ctx)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Execute the determined error action
|
|
133
|
+
def execute_error_action(action, msg, ctx, error, error_ctx)
|
|
134
|
+
case action
|
|
135
|
+
when Core::ErrorAction::RETRY
|
|
136
|
+
safe_nak(msg, ctx, error)
|
|
137
|
+
Logging.warn(
|
|
138
|
+
"NAK (retry) event_id=#{ctx.event_id} subject=#{ctx.subject} seq=#{ctx.seq} " \
|
|
139
|
+
"deliveries=#{ctx.deliveries} err=#{error_ctx.to_log_string}",
|
|
140
|
+
tag: 'NatsPubsub::Subscribers::MessageProcessor'
|
|
141
|
+
)
|
|
142
|
+
when Core::ErrorAction::DISCARD
|
|
143
|
+
msg.ack
|
|
144
|
+
Logging.warn(
|
|
145
|
+
"ACK (discard) event_id=#{ctx.event_id} subject=#{ctx.subject} seq=#{ctx.seq} " \
|
|
146
|
+
"deliveries=#{ctx.deliveries} err=#{error_ctx.to_log_string}",
|
|
147
|
+
tag: 'NatsPubsub::Subscribers::MessageProcessor'
|
|
148
|
+
)
|
|
149
|
+
when Core::ErrorAction::DLQ
|
|
150
|
+
published = @dlq.publish_to_dlq(msg, ctx, error_context: error_ctx)
|
|
151
|
+
if published || !NatsPubsub.config.use_dlq
|
|
152
|
+
msg.term
|
|
153
|
+
else
|
|
154
|
+
safe_nak(msg, ctx, error)
|
|
155
|
+
end
|
|
156
|
+
Logging.warn(
|
|
157
|
+
"DLQ event_id=#{ctx.event_id} subject=#{ctx.subject} seq=#{ctx.seq} " \
|
|
158
|
+
"deliveries=#{ctx.deliveries} err=#{error_ctx.to_log_string}",
|
|
159
|
+
tag: 'NatsPubsub::Subscribers::MessageProcessor'
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Legacy behavior for backward compatibility
|
|
165
|
+
def determine_legacy_action(ctx, error)
|
|
166
|
+
max_deliver = NatsPubsub.config.max_deliver.to_i
|
|
167
|
+
dlq_max_attempts = NatsPubsub.config.dlq_max_attempts.to_i
|
|
168
|
+
|
|
169
|
+
if ctx.deliveries >= max_deliver || ctx.deliveries >= dlq_max_attempts
|
|
170
|
+
Core::ErrorAction::DLQ
|
|
171
|
+
else
|
|
172
|
+
Core::ErrorAction::RETRY
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Parse event data from message for error handler
|
|
177
|
+
def parse_event_from_msg(msg)
|
|
178
|
+
Oj.load(msg.data, mode: :strict)
|
|
179
|
+
rescue Oj::ParseError
|
|
180
|
+
{}
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def safe_nak(msg, ctx = nil, error = nil)
|
|
184
|
+
delay = @backoff.calculate_backoff_delay(ctx&.deliveries.to_i, error) if ctx
|
|
185
|
+
if delay
|
|
186
|
+
msg.nak(next_delivery_delay: delay)
|
|
187
|
+
else
|
|
188
|
+
msg.nak
|
|
189
|
+
end
|
|
190
|
+
rescue StandardError => e
|
|
191
|
+
Logging.error(
|
|
192
|
+
"Failed to NAK event_id=#{ctx&.event_id} deliveries=#{ctx&.deliveries}: " \
|
|
193
|
+
"#{e.class} #{e.message}",
|
|
194
|
+
tag: 'NatsPubsub::Subscribers::MessageProcessor'
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def ensure_dlq_stream
|
|
199
|
+
return if @dlq_stream_checked
|
|
200
|
+
|
|
201
|
+
dlq_stream = NatsPubsub.config.dlq_stream_name
|
|
202
|
+
@jts.stream_info(dlq_stream)
|
|
203
|
+
@dlq_stream_checked = true
|
|
204
|
+
rescue NATS::JetStream::Error::NotFound
|
|
205
|
+
Logging.info(
|
|
206
|
+
"Creating DLQ stream #{dlq_stream} for subject #{NatsPubsub.config.dlq_subject}",
|
|
207
|
+
tag: 'NatsPubsub::Subscribers::MessageProcessor'
|
|
208
|
+
)
|
|
209
|
+
@jts.add_stream(
|
|
210
|
+
name: dlq_stream,
|
|
211
|
+
subjects: [NatsPubsub.config.dlq_subject],
|
|
212
|
+
retention: :limits,
|
|
213
|
+
max_age: 60 * 60 * 24 * 30 # 30 days in seconds
|
|
214
|
+
)
|
|
215
|
+
@dlq_stream_checked = true
|
|
216
|
+
rescue StandardError => e
|
|
217
|
+
Logging.error(
|
|
218
|
+
"Failed to ensure DLQ stream: #{e.class} #{e.message}",
|
|
219
|
+
tag: 'NatsPubsub::Subscribers::MessageProcessor'
|
|
220
|
+
)
|
|
221
|
+
raise
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../core/logging'
|
|
4
|
+
require_relative '../subscribers/registry'
|
|
5
|
+
|
|
6
|
+
module NatsPubsub
|
|
7
|
+
module Subscribers
|
|
8
|
+
# Routes incoming messages to the appropriate subscribers
|
|
9
|
+
class MessageRouter
|
|
10
|
+
def initialize(registry = nil)
|
|
11
|
+
@registry = registry || Registry.instance
|
|
12
|
+
@middleware = NatsPubsub.config.server_middleware
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Route a message to all matching subscribers
|
|
16
|
+
#
|
|
17
|
+
# @param event [Hash] Parsed event envelope
|
|
18
|
+
# @param subject [String] NATS subject
|
|
19
|
+
# @param deliveries [Integer] Delivery attempt count
|
|
20
|
+
def route(event, subject, deliveries)
|
|
21
|
+
subscribers = @registry.subscribers_for(subject)
|
|
22
|
+
|
|
23
|
+
if subscribers.empty?
|
|
24
|
+
Logging.warn(
|
|
25
|
+
"No subscribers found for subject: #{subject}",
|
|
26
|
+
tag: 'NatsPubsub::Subscribers::MessageRouter'
|
|
27
|
+
)
|
|
28
|
+
return
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
metadata = build_metadata(event, subject, deliveries)
|
|
32
|
+
|
|
33
|
+
subscribers.each do |subscriber_class|
|
|
34
|
+
execute_subscriber(subscriber_class, event['payload'] || event, metadata)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def build_metadata(event, subject, deliveries)
|
|
41
|
+
{
|
|
42
|
+
subject: subject,
|
|
43
|
+
deliveries: deliveries,
|
|
44
|
+
event_id: event['event_id'],
|
|
45
|
+
domain: event['domain'],
|
|
46
|
+
resource: event['resource'],
|
|
47
|
+
action: event['action'],
|
|
48
|
+
occurred_at: event['occurred_at'],
|
|
49
|
+
trace_id: event['trace_id'],
|
|
50
|
+
producer: event['producer'],
|
|
51
|
+
# Legacy support
|
|
52
|
+
event_type: event['event_type'],
|
|
53
|
+
resource_type: event['resource_type']
|
|
54
|
+
}.compact
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def execute_subscriber(subscriber_class, payload, metadata)
|
|
58
|
+
subscriber = subscriber_class.new
|
|
59
|
+
|
|
60
|
+
@middleware.invoke(subscriber, payload, metadata) do
|
|
61
|
+
subscriber.handle(payload, metadata)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
Logging.info(
|
|
65
|
+
"Handled #{metadata[:subject]} with #{subscriber_class.name}",
|
|
66
|
+
tag: 'NatsPubsub::Subscribers::MessageRouter'
|
|
67
|
+
)
|
|
68
|
+
rescue StandardError => e
|
|
69
|
+
Logging.error(
|
|
70
|
+
"Subscriber #{subscriber_class.name} failed: #{e.class} #{e.message}",
|
|
71
|
+
tag: 'NatsPubsub::Subscribers::MessageRouter'
|
|
72
|
+
)
|
|
73
|
+
raise # Re-raise to trigger NATS retry/DLQ logic
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../core/logging'
|
|
4
|
+
require_relative 'worker'
|
|
5
|
+
require_relative 'message_router'
|
|
6
|
+
require_relative 'registry'
|
|
7
|
+
|
|
8
|
+
module NatsPubsub
|
|
9
|
+
module Subscribers
|
|
10
|
+
# Manages a pool of subscriber workers for processing messages.
|
|
11
|
+
# Formerly known as ConsumerPool - renamed for consistency with Subscriber terminology.
|
|
12
|
+
class Pool
|
|
13
|
+
def initialize(registry: nil, concurrency: 5)
|
|
14
|
+
@registry = registry || Registry.instance
|
|
15
|
+
@concurrency = concurrency
|
|
16
|
+
@workers = []
|
|
17
|
+
@running = true
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def start!
|
|
21
|
+
setup_workers
|
|
22
|
+
|
|
23
|
+
Logging.info(
|
|
24
|
+
"Started #{@workers.size} worker(s) for #{@registry.all_subscribers.size} subscriber(s)",
|
|
25
|
+
tag: 'NatsPubsub::Subscribers::Pool'
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Wait for all worker threads
|
|
29
|
+
@workers.each { |w| w[:thread].join }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def stop!
|
|
33
|
+
@running = false
|
|
34
|
+
@workers.each { |w| w[:worker].stop! }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def setup_workers
|
|
40
|
+
# In pubsub mode, create workers for subscription patterns
|
|
41
|
+
if NatsPubsub.config.pubsub_mode
|
|
42
|
+
setup_pubsub_workers
|
|
43
|
+
else
|
|
44
|
+
setup_legacy_worker
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def setup_pubsub_workers
|
|
49
|
+
pattern_groups = group_by_patterns
|
|
50
|
+
|
|
51
|
+
if pattern_groups.empty?
|
|
52
|
+
Logging.warn(
|
|
53
|
+
'No subscription patterns found',
|
|
54
|
+
tag: 'NatsPubsub::Subscribers::Pool'
|
|
55
|
+
)
|
|
56
|
+
return
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
pattern_groups.each do |pattern, subscribers|
|
|
60
|
+
create_worker_for_pattern(pattern, subscribers)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def setup_legacy_worker
|
|
65
|
+
# Legacy: single worker for destination_subject
|
|
66
|
+
@concurrency.times do |i|
|
|
67
|
+
worker = Worker.new do |event, subject, deliveries|
|
|
68
|
+
# Process with message router
|
|
69
|
+
router = MessageRouter.new(@registry)
|
|
70
|
+
router.route(event, subject, deliveries)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
thread = Thread.new do
|
|
74
|
+
Thread.current.name = "worker-legacy-#{i}"
|
|
75
|
+
|
|
76
|
+
loop do
|
|
77
|
+
break unless @running
|
|
78
|
+
|
|
79
|
+
worker.run!
|
|
80
|
+
rescue StandardError => e
|
|
81
|
+
Logging.error(
|
|
82
|
+
"Worker crashed: #{e.class} #{e.message}",
|
|
83
|
+
tag: 'NatsPubsub::Subscribers::Pool'
|
|
84
|
+
)
|
|
85
|
+
Logging.error(e.backtrace.join("\n"), tag: 'NatsPubsub::Subscribers::Pool')
|
|
86
|
+
sleep 5
|
|
87
|
+
retry if @running
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
@workers << { worker: worker, thread: thread, pattern: 'legacy' }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def group_by_patterns
|
|
96
|
+
groups = Hash.new { |h, k| h[k] = [] }
|
|
97
|
+
|
|
98
|
+
@registry.all_subscribers.each do |sub_class|
|
|
99
|
+
sub_class.subscriptions.each do |subscription|
|
|
100
|
+
groups[subscription[:pattern]] << sub_class
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
groups
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def create_worker_for_pattern(pattern, subscribers)
|
|
108
|
+
durable_name = generate_durable_name(pattern)
|
|
109
|
+
|
|
110
|
+
Logging.info(
|
|
111
|
+
"Creating worker for pattern: #{pattern} (durable: #{durable_name})",
|
|
112
|
+
tag: 'NatsPubsub::Subscribers::Pool'
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Create one worker (it will handle batches internally)
|
|
116
|
+
worker = Worker.new(
|
|
117
|
+
durable_name: durable_name,
|
|
118
|
+
filter_subject: pattern
|
|
119
|
+
) do |event, subject, deliveries|
|
|
120
|
+
route_to_subscribers(event, subject, deliveries, subscribers)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
thread = Thread.new do
|
|
124
|
+
Thread.current.name = "worker-#{sanitize_pattern(pattern)}"
|
|
125
|
+
|
|
126
|
+
loop do
|
|
127
|
+
break unless @running
|
|
128
|
+
|
|
129
|
+
begin
|
|
130
|
+
worker.run!
|
|
131
|
+
rescue StandardError => e
|
|
132
|
+
Logging.error(
|
|
133
|
+
"Worker crashed for #{pattern}: #{e.class} #{e.message}",
|
|
134
|
+
tag: 'NatsPubsub::Subscribers::Pool'
|
|
135
|
+
)
|
|
136
|
+
Logging.error(e.backtrace.join("\n"), tag: 'NatsPubsub::Subscribers::Pool')
|
|
137
|
+
sleep 5
|
|
138
|
+
retry if @running
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
@workers << { worker: worker, thread: thread, pattern: pattern }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def route_to_subscribers(event, subject, deliveries, _subscribers)
|
|
147
|
+
router = MessageRouter.new(@registry)
|
|
148
|
+
router.route(event, subject, deliveries)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def generate_durable_name(pattern)
|
|
152
|
+
sanitized = sanitize_pattern(pattern)
|
|
153
|
+
"#{NatsPubsub.config.app_name}-#{sanitized}"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def sanitize_pattern(pattern)
|
|
157
|
+
pattern
|
|
158
|
+
.gsub('.>', '-all')
|
|
159
|
+
.gsub('.*', '-wildcard')
|
|
160
|
+
.tr('.', '-')
|
|
161
|
+
.gsub(/[^a-zA-Z0-9\-_]/, '')
|
|
162
|
+
.slice(0, 100) # Limit length for NATS
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'singleton'
|
|
4
|
+
require_relative '../core/logging'
|
|
5
|
+
require_relative '../topology/subject_matcher'
|
|
6
|
+
|
|
7
|
+
module NatsPubsub
|
|
8
|
+
module Subscribers
|
|
9
|
+
# Registry for auto-discovering and managing subscriber classes.
|
|
10
|
+
# Maintains a list of all subscribers and their subscription patterns.
|
|
11
|
+
class Registry
|
|
12
|
+
include Singleton
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@subscribers = []
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Register a subscriber class
|
|
20
|
+
#
|
|
21
|
+
# @param subscriber_class [Class] Subscriber class to register
|
|
22
|
+
def register(subscriber_class)
|
|
23
|
+
@mutex.synchronize do
|
|
24
|
+
unless @subscribers.include?(subscriber_class)
|
|
25
|
+
@subscribers << subscriber_class
|
|
26
|
+
Logging.debug(
|
|
27
|
+
"Registered subscriber: #{subscriber_class.name}",
|
|
28
|
+
tag: 'NatsPubsub::Subscribers::Registry'
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Get all registered subscribers
|
|
35
|
+
#
|
|
36
|
+
# @return [Array<Class>] Array of subscriber classes
|
|
37
|
+
def all_subscribers
|
|
38
|
+
@subscribers.dup
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Find subscribers that match a given subject
|
|
42
|
+
#
|
|
43
|
+
# @param subject [String] NATS subject to match
|
|
44
|
+
# @return [Array<Class>] Matching subscriber classes
|
|
45
|
+
def subscribers_for(subject)
|
|
46
|
+
@subscribers.select do |sub_class|
|
|
47
|
+
sub_class.subscriptions.any? do |subscription|
|
|
48
|
+
SubjectMatcher.match?(subscription[:pattern], subject)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Auto-discover subscribers from Rails app
|
|
54
|
+
def discover_subscribers!
|
|
55
|
+
if defined?(Rails)
|
|
56
|
+
# Ensure app is loaded
|
|
57
|
+
Rails.application.eager_load! unless Rails.configuration.cache_classes
|
|
58
|
+
|
|
59
|
+
# Load all subscriber files
|
|
60
|
+
subscriber_paths = [
|
|
61
|
+
Rails.root.join('app/subscribers/**/*_subscriber.rb'),
|
|
62
|
+
Rails.root.join('app/handlers/**/*_handler.rb') # Support both names
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
subscriber_paths.each do |pattern|
|
|
66
|
+
Dir[pattern].each do |file|
|
|
67
|
+
require_dependency file
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Find all classes that include Subscriber module
|
|
73
|
+
discovered = ObjectSpace.each_object(Class).select do |klass|
|
|
74
|
+
klass < Object && klass.included_modules.include?(NatsPubsub::Subscriber)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
discovered.each { |subscriber_class| register(subscriber_class) }
|
|
78
|
+
|
|
79
|
+
Logging.info(
|
|
80
|
+
"Discovered and registered #{@subscribers.size} subscriber(s)",
|
|
81
|
+
tag: 'NatsPubsub::Subscribers::Registry'
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
log_subscriber_details if @subscribers.any?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Get all unique subscription patterns across all subscribers
|
|
88
|
+
#
|
|
89
|
+
# @return [Array<String>] Unique subscription patterns
|
|
90
|
+
def all_subscription_patterns
|
|
91
|
+
@subscribers.flat_map do |sub_class|
|
|
92
|
+
sub_class.subscriptions.map { |sub| sub[:pattern] }
|
|
93
|
+
end.uniq
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Clear all registered subscribers (useful for testing)
|
|
97
|
+
def clear!
|
|
98
|
+
@mutex.synchronize { @subscribers.clear }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def log_subscriber_details
|
|
104
|
+
@subscribers.each do |sub_class|
|
|
105
|
+
patterns = sub_class.subscriptions.map { |s| s[:pattern] }.join(', ')
|
|
106
|
+
Logging.info(
|
|
107
|
+
" └─ #{sub_class.name}: [#{patterns}]",
|
|
108
|
+
tag: 'NatsPubsub::Subscribers::Registry'
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|