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.
Files changed (87) hide show
  1. checksums.yaml +7 -0
  2. data/exe/nats_pubsub +44 -0
  3. data/lib/generators/nats_pubsub/config/config_generator.rb +174 -0
  4. data/lib/generators/nats_pubsub/config/templates/env.example.tt +46 -0
  5. data/lib/generators/nats_pubsub/config/templates/nats_pubsub.rb.tt +105 -0
  6. data/lib/generators/nats_pubsub/initializer/initializer_generator.rb +36 -0
  7. data/lib/generators/nats_pubsub/initializer/templates/nats_pubsub.rb +27 -0
  8. data/lib/generators/nats_pubsub/install/install_generator.rb +75 -0
  9. data/lib/generators/nats_pubsub/migrations/migrations_generator.rb +74 -0
  10. data/lib/generators/nats_pubsub/migrations/templates/create_nats_pubsub_inbox.rb.erb +88 -0
  11. data/lib/generators/nats_pubsub/migrations/templates/create_nats_pubsub_outbox.rb.erb +81 -0
  12. data/lib/generators/nats_pubsub/subscriber/subscriber_generator.rb +139 -0
  13. data/lib/generators/nats_pubsub/subscriber/templates/subscriber.rb.tt +117 -0
  14. data/lib/generators/nats_pubsub/subscriber/templates/subscriber_spec.rb.tt +116 -0
  15. data/lib/generators/nats_pubsub/subscriber/templates/subscriber_test.rb.tt +117 -0
  16. data/lib/nats_pubsub/active_record/publishable.rb +192 -0
  17. data/lib/nats_pubsub/cli.rb +105 -0
  18. data/lib/nats_pubsub/core/base_repository.rb +73 -0
  19. data/lib/nats_pubsub/core/config.rb +152 -0
  20. data/lib/nats_pubsub/core/config_presets.rb +139 -0
  21. data/lib/nats_pubsub/core/connection.rb +103 -0
  22. data/lib/nats_pubsub/core/constants.rb +190 -0
  23. data/lib/nats_pubsub/core/duration.rb +113 -0
  24. data/lib/nats_pubsub/core/error_action.rb +288 -0
  25. data/lib/nats_pubsub/core/event.rb +275 -0
  26. data/lib/nats_pubsub/core/health_check.rb +470 -0
  27. data/lib/nats_pubsub/core/logging.rb +72 -0
  28. data/lib/nats_pubsub/core/message_context.rb +193 -0
  29. data/lib/nats_pubsub/core/presets.rb +222 -0
  30. data/lib/nats_pubsub/core/retry_strategy.rb +71 -0
  31. data/lib/nats_pubsub/core/structured_logger.rb +141 -0
  32. data/lib/nats_pubsub/core/subject.rb +185 -0
  33. data/lib/nats_pubsub/instrumentation.rb +327 -0
  34. data/lib/nats_pubsub/middleware/active_record.rb +18 -0
  35. data/lib/nats_pubsub/middleware/chain.rb +92 -0
  36. data/lib/nats_pubsub/middleware/logging.rb +48 -0
  37. data/lib/nats_pubsub/middleware/retry_logger.rb +24 -0
  38. data/lib/nats_pubsub/middleware/structured_logging.rb +57 -0
  39. data/lib/nats_pubsub/models/event_model.rb +73 -0
  40. data/lib/nats_pubsub/models/inbox_event.rb +109 -0
  41. data/lib/nats_pubsub/models/model_codec_setup.rb +61 -0
  42. data/lib/nats_pubsub/models/model_utils.rb +57 -0
  43. data/lib/nats_pubsub/models/outbox_event.rb +113 -0
  44. data/lib/nats_pubsub/publisher/envelope_builder.rb +99 -0
  45. data/lib/nats_pubsub/publisher/fluent_batch.rb +262 -0
  46. data/lib/nats_pubsub/publisher/outbox_publisher.rb +97 -0
  47. data/lib/nats_pubsub/publisher/outbox_repository.rb +117 -0
  48. data/lib/nats_pubsub/publisher/publish_argument_parser.rb +108 -0
  49. data/lib/nats_pubsub/publisher/publish_result.rb +149 -0
  50. data/lib/nats_pubsub/publisher/publisher.rb +156 -0
  51. data/lib/nats_pubsub/rails/health_endpoint.rb +239 -0
  52. data/lib/nats_pubsub/railtie.rb +52 -0
  53. data/lib/nats_pubsub/subscribers/dlq_handler.rb +69 -0
  54. data/lib/nats_pubsub/subscribers/error_context.rb +137 -0
  55. data/lib/nats_pubsub/subscribers/error_handler.rb +110 -0
  56. data/lib/nats_pubsub/subscribers/graceful_shutdown.rb +128 -0
  57. data/lib/nats_pubsub/subscribers/inbox/inbox_message.rb +79 -0
  58. data/lib/nats_pubsub/subscribers/inbox/inbox_processor.rb +53 -0
  59. data/lib/nats_pubsub/subscribers/inbox/inbox_repository.rb +74 -0
  60. data/lib/nats_pubsub/subscribers/message_context.rb +86 -0
  61. data/lib/nats_pubsub/subscribers/message_processor.rb +225 -0
  62. data/lib/nats_pubsub/subscribers/message_router.rb +77 -0
  63. data/lib/nats_pubsub/subscribers/pool.rb +166 -0
  64. data/lib/nats_pubsub/subscribers/registry.rb +114 -0
  65. data/lib/nats_pubsub/subscribers/subscriber.rb +186 -0
  66. data/lib/nats_pubsub/subscribers/subscription_manager.rb +206 -0
  67. data/lib/nats_pubsub/subscribers/worker.rb +152 -0
  68. data/lib/nats_pubsub/tasks/install.rake +10 -0
  69. data/lib/nats_pubsub/testing/helpers.rb +199 -0
  70. data/lib/nats_pubsub/testing/matchers.rb +208 -0
  71. data/lib/nats_pubsub/testing/test_harness.rb +250 -0
  72. data/lib/nats_pubsub/testing.rb +157 -0
  73. data/lib/nats_pubsub/topology/overlap_guard.rb +88 -0
  74. data/lib/nats_pubsub/topology/stream.rb +102 -0
  75. data/lib/nats_pubsub/topology/stream_support.rb +170 -0
  76. data/lib/nats_pubsub/topology/subject_matcher.rb +77 -0
  77. data/lib/nats_pubsub/topology/topology.rb +24 -0
  78. data/lib/nats_pubsub/version.rb +8 -0
  79. data/lib/nats_pubsub/web/views/dashboard.erb +55 -0
  80. data/lib/nats_pubsub/web/views/inbox_detail.erb +91 -0
  81. data/lib/nats_pubsub/web/views/inbox_list.erb +62 -0
  82. data/lib/nats_pubsub/web/views/layout.erb +68 -0
  83. data/lib/nats_pubsub/web/views/outbox_detail.erb +77 -0
  84. data/lib/nats_pubsub/web/views/outbox_list.erb +62 -0
  85. data/lib/nats_pubsub/web.rb +181 -0
  86. data/lib/nats_pubsub.rb +290 -0
  87. 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