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,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NatsPubsub
4
+ # Immutable value object representing a NATS subject.
5
+ # Provides validation, normalization, and pattern matching for NATS subjects.
6
+ #
7
+ # NATS subjects are dot-separated strings that support wildcards:
8
+ # - '*' matches exactly one token
9
+ # - '>' matches one or more tokens (only valid at end)
10
+ #
11
+ # @example Creating a subject
12
+ # subject = Subject.new('production.myapp.users.created')
13
+ # subject.tokens # => ['production', 'myapp', 'users', 'created']
14
+ #
15
+ # @example Pattern matching
16
+ # pattern = Subject.new('production.*.users.*')
17
+ # pattern.matches?('production.myapp.users.created') # => true
18
+ #
19
+ # @example Wildcard subjects
20
+ # wildcard = Subject.new('production.myapp.>')
21
+ # wildcard.wildcard? # => true
22
+ # wildcard.matches?('production.myapp.users.created') # => true
23
+ class Subject
24
+ attr_reader :value, :tokens
25
+
26
+ # NATS subject constraints
27
+ MAX_LENGTH = 255
28
+ VALID_PATTERN = /\A[a-zA-Z0-9_.*>-]+(\.[a-zA-Z0-9_.*>-]+)*\z/
29
+
30
+ # Initialize a new Subject
31
+ #
32
+ # @param value [String, Subject] Subject string or another Subject
33
+ # @raise [ArgumentError] if subject is invalid
34
+ def initialize(value)
35
+ @value = value.is_a?(Subject) ? value.value : value.to_s
36
+ validate!
37
+ @tokens = @value.split('.')
38
+ freeze
39
+ end
40
+
41
+ # Build subject from domain, resource, and action
42
+ #
43
+ # @param env [String] Environment name
44
+ # @param app_name [String] Application name
45
+ # @param domain [String] Domain name
46
+ # @param resource [String] Resource type
47
+ # @param action [String] Action performed
48
+ # @return [Subject] New subject instance
49
+ def self.from_event(env:, app_name:, domain:, resource:, action:)
50
+ new("#{env}.#{app_name}.#{domain}.#{resource}.#{action}")
51
+ end
52
+
53
+ # Build subject for topic
54
+ #
55
+ # @param env [String] Environment name
56
+ # @param app_name [String] Application name
57
+ # @param topic [String] Topic name
58
+ # @return [Subject] New subject instance
59
+ def self.from_topic(env:, app_name:, topic:)
60
+ normalized_topic = normalize_topic(topic)
61
+ new("#{env}.#{app_name}.#{normalized_topic}")
62
+ end
63
+
64
+ # Normalize topic name for use in subjects
65
+ # Preserves dots for hierarchical topics and NATS wildcards
66
+ #
67
+ # @param topic [String] Topic name
68
+ # @return [String] Normalized topic name
69
+ def self.normalize_topic(topic)
70
+ topic.to_s.downcase.gsub(/[^a-z0-9_.>*-]/, '_')
71
+ end
72
+
73
+ # Check if subject contains wildcards
74
+ #
75
+ # @return [Boolean] True if contains * or >
76
+ def wildcard?
77
+ @value.include?('*') || @value.include?('>')
78
+ end
79
+
80
+ # Check if subject contains tail wildcard (>)
81
+ #
82
+ # @return [Boolean] True if ends with >
83
+ def tail_wildcard?
84
+ @value.end_with?('.>')
85
+ end
86
+
87
+ # Check if this subject/pattern matches another subject
88
+ # Note: Requires SubjectMatcher to be loaded
89
+ #
90
+ # @param other [String, Subject] Subject to match against
91
+ # @return [Boolean] True if matches
92
+ def matches?(other)
93
+ require_relative '../topology/subject_matcher' unless defined?(NatsPubsub::SubjectMatcher)
94
+ other_subject = other.is_a?(Subject) ? other : Subject.new(other)
95
+ SubjectMatcher.match?(@value, other_subject.value)
96
+ end
97
+
98
+ # Check if two subjects overlap (both could match the same message)
99
+ # Note: Requires SubjectMatcher to be loaded
100
+ #
101
+ # @param other [String, Subject] Subject to check overlap with
102
+ # @return [Boolean] True if subjects overlap
103
+ def overlaps?(other)
104
+ require_relative '../topology/subject_matcher' unless defined?(NatsPubsub::SubjectMatcher)
105
+ other_subject = other.is_a?(Subject) ? other : Subject.new(other)
106
+ SubjectMatcher.overlap?(@value, other_subject.value)
107
+ end
108
+
109
+ # Get the number of tokens in the subject
110
+ #
111
+ # @return [Integer] Token count
112
+ def token_count
113
+ @tokens.size
114
+ end
115
+
116
+ # Check if subject is empty
117
+ #
118
+ # @return [Boolean] True if empty
119
+ def empty?
120
+ @value.empty?
121
+ end
122
+
123
+ # String representation
124
+ #
125
+ # @return [String] Subject value
126
+ def to_s
127
+ @value
128
+ end
129
+
130
+ # Equality comparison
131
+ #
132
+ # @param other [Object] Object to compare
133
+ # @return [Boolean] True if equal
134
+ def ==(other)
135
+ case other
136
+ when Subject
137
+ @value == other.value
138
+ when String
139
+ @value == other
140
+ else
141
+ false
142
+ end
143
+ end
144
+
145
+ alias eql? ==
146
+
147
+ # Hash code for use in hash tables
148
+ #
149
+ # @return [Integer] Hash code
150
+ def hash
151
+ @value.hash
152
+ end
153
+
154
+ # Inspect representation
155
+ #
156
+ # @return [String] Inspection string
157
+ def inspect
158
+ "#<Subject:#{@value}>"
159
+ end
160
+
161
+ private
162
+
163
+ # Validate subject format
164
+ #
165
+ # @raise [ArgumentError] if invalid
166
+ def validate!
167
+ raise ArgumentError, 'Subject cannot be nil' if @value.nil?
168
+ raise ArgumentError, 'Subject cannot be empty' if @value.empty?
169
+ raise ArgumentError, "Subject too long (max #{MAX_LENGTH} chars)" if @value.length > MAX_LENGTH
170
+ raise ArgumentError, "Invalid subject format: #{@value}" unless @value =~ VALID_PATTERN
171
+ validate_wildcard_placement!
172
+ end
173
+
174
+ # Validate wildcard placement rules
175
+ #
176
+ # @raise [ArgumentError] if wildcard placement is invalid
177
+ def validate_wildcard_placement!
178
+ # '>' can only appear at the end
179
+ if @value.include?('>')
180
+ raise ArgumentError, "Wildcard '>' must be at the end of subject" unless @value.end_with?('.>')
181
+ raise ArgumentError, "Subject cannot contain multiple '>' wildcards" if @value.count('>') > 1
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,327 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NatsPubsub
4
+ # Instrumentation for NatsPubsub using ActiveSupport::Notifications
5
+ #
6
+ # Provides instrumentation hooks for monitoring and observability.
7
+ #
8
+ # @example Subscribe to all events
9
+ # ActiveSupport::Notifications.subscribe(/nats_pubsub/) do |name, start, finish, id, payload|
10
+ # duration = (finish - start) * 1000
11
+ # puts "#{name}: #{duration}ms"
12
+ # end
13
+ #
14
+ # @example Subscribe to specific events
15
+ # ActiveSupport::Notifications.subscribe('nats_pubsub.publish') do |*args|
16
+ # event = ActiveSupport::Notifications::Event.new(*args)
17
+ # puts "Published: #{event.payload[:subject]} in #{event.duration}ms"
18
+ # end
19
+ #
20
+ module Instrumentation
21
+ # Available instrumentation events:
22
+ #
23
+ # - nats_pubsub.publish - Message publishing
24
+ # - nats_pubsub.receive - Message reception
25
+ # - nats_pubsub.process - Message processing
26
+ # - nats_pubsub.subscribe - Subscription creation
27
+ # - nats_pubsub.health_check - Health check execution
28
+ # - nats_pubsub.outbox.enqueue - Outbox event enqueued
29
+ # - nats_pubsub.outbox.publish - Outbox event published
30
+ # - nats_pubsub.inbox.store - Inbox event stored
31
+ # - nats_pubsub.inbox.process - Inbox event processed
32
+ # - nats_pubsub.connection.connect - Connection established
33
+ # - nats_pubsub.connection.disconnect - Connection closed
34
+ # - nats_pubsub.error - Error occurred
35
+
36
+ class << self
37
+ # Check if ActiveSupport::Notifications is available
38
+ #
39
+ # @return [Boolean] True if available
40
+ def available?
41
+ defined?(ActiveSupport::Notifications)
42
+ end
43
+
44
+ # Instrument a block of code
45
+ #
46
+ # @param event [String] Event name (will be prefixed with 'nats_pubsub.')
47
+ # @param payload [Hash] Event payload
48
+ # @yield Block to instrument
49
+ # @return [Object] Block result
50
+ #
51
+ # @example
52
+ # Instrumentation.instrument('publish', subject: 'users.created') do
53
+ # # Publishing code
54
+ # end
55
+ def instrument(event, payload = {}, &block)
56
+ return yield unless available?
57
+
58
+ ActiveSupport::Notifications.instrument("nats_pubsub.#{event}", payload, &block)
59
+ end
60
+
61
+ # Publish an instrumentation event without a block
62
+ #
63
+ # @param event [String] Event name (will be prefixed with 'nats_pubsub.')
64
+ # @param payload [Hash] Event payload
65
+ #
66
+ # @example
67
+ # Instrumentation.publish('error', error: e, context: context)
68
+ def publish(event, payload = {})
69
+ return unless available?
70
+
71
+ ActiveSupport::Notifications.publish("nats_pubsub.#{event}", payload)
72
+ end
73
+
74
+ # Subscribe to instrumentation events
75
+ #
76
+ # @param pattern [String, Regexp] Event pattern to subscribe to
77
+ # @yield Block to execute for matching events
78
+ # @return [Object] Subscription handle
79
+ #
80
+ # @example Subscribe to all events
81
+ # Instrumentation.subscribe(/nats_pubsub/) do |event|
82
+ # puts "Event: #{event.name}, Duration: #{event.duration}ms"
83
+ # end
84
+ #
85
+ # @example Subscribe to specific event
86
+ # Instrumentation.subscribe('nats_pubsub.publish') do |event|
87
+ # puts "Published: #{event.payload[:subject]}"
88
+ # end
89
+ def subscribe(pattern = /nats_pubsub/, &block)
90
+ return unless available?
91
+
92
+ ActiveSupport::Notifications.subscribe(pattern) do |*args|
93
+ event = ActiveSupport::Notifications::Event.new(*args)
94
+ block.call(event)
95
+ end
96
+ end
97
+
98
+ # Unsubscribe from instrumentation events
99
+ #
100
+ # @param subscriber [Object] Subscription handle
101
+ def unsubscribe(subscriber)
102
+ return unless available?
103
+
104
+ ActiveSupport::Notifications.unsubscribe(subscriber)
105
+ end
106
+ end
107
+
108
+ # Publishing instrumentation
109
+ module Publisher
110
+ # Instrument message publishing
111
+ #
112
+ # @param subject [String] NATS subject
113
+ # @param payload [Hash] Message payload
114
+ # @yield Block performing the publish
115
+ def self.instrument_publish(subject:, **payload, &block)
116
+ Instrumentation.instrument('publish', { subject: subject }.merge(payload), &block)
117
+ end
118
+
119
+ # Instrument outbox enqueuing
120
+ #
121
+ # @param event_id [String] Event ID
122
+ # @param subject [String] NATS subject
123
+ # @yield Block performing the enqueue
124
+ def self.instrument_outbox_enqueue(event_id:, subject:, &block)
125
+ Instrumentation.instrument('outbox.enqueue', event_id: event_id, subject: subject, &block)
126
+ end
127
+
128
+ # Instrument outbox publishing
129
+ #
130
+ # @param event_id [String] Event ID
131
+ # @param subject [String] NATS subject
132
+ # @yield Block performing the publish
133
+ def self.instrument_outbox_publish(event_id:, subject:, &block)
134
+ Instrumentation.instrument('outbox.publish', event_id: event_id, subject: subject, &block)
135
+ end
136
+ end
137
+
138
+ # Subscriber instrumentation
139
+ module Subscriber
140
+ # Instrument message reception
141
+ #
142
+ # @param subject [String] NATS subject
143
+ # @param event_id [String] Event ID
144
+ # @yield Block receiving the message
145
+ def self.instrument_receive(subject:, event_id:, &block)
146
+ Instrumentation.instrument('receive', subject: subject, event_id: event_id, &block)
147
+ end
148
+
149
+ # Instrument message processing
150
+ #
151
+ # @param subject [String] NATS subject
152
+ # @param event_id [String] Event ID
153
+ # @param subscriber [String] Subscriber class name
154
+ # @yield Block processing the message
155
+ def self.instrument_process(subject:, event_id:, subscriber:, &block)
156
+ Instrumentation.instrument(
157
+ 'process',
158
+ subject: subject,
159
+ event_id: event_id,
160
+ subscriber: subscriber,
161
+ &block
162
+ )
163
+ end
164
+
165
+ # Instrument inbox storage
166
+ #
167
+ # @param event_id [String] Event ID
168
+ # @param subject [String] NATS subject
169
+ # @yield Block storing the event
170
+ def self.instrument_inbox_store(event_id:, subject:, &block)
171
+ Instrumentation.instrument('inbox.store', event_id: event_id, subject: subject, &block)
172
+ end
173
+
174
+ # Instrument inbox processing
175
+ #
176
+ # @param event_id [String] Event ID
177
+ # @param subject [String] NATS subject
178
+ # @yield Block processing the event
179
+ def self.instrument_inbox_process(event_id:, subject:, &block)
180
+ Instrumentation.instrument('inbox.process', event_id: event_id, subject: subject, &block)
181
+ end
182
+
183
+ # Instrument subscription creation
184
+ #
185
+ # @param subject [String] NATS subject pattern
186
+ # @param subscriber [String] Subscriber class name
187
+ def self.instrument_subscribe(subject:, subscriber:)
188
+ Instrumentation.publish('subscribe', subject: subject, subscriber: subscriber)
189
+ end
190
+ end
191
+
192
+ # Connection instrumentation
193
+ module Connection
194
+ # Instrument connection establishment
195
+ #
196
+ # @param urls [Array<String>] NATS server URLs
197
+ # @yield Block establishing connection
198
+ def self.instrument_connect(urls:, &block)
199
+ Instrumentation.instrument('connection.connect', urls: urls, &block)
200
+ end
201
+
202
+ # Instrument connection close
203
+ #
204
+ # @param urls [Array<String>] NATS server URLs
205
+ def self.instrument_disconnect(urls:)
206
+ Instrumentation.publish('connection.disconnect', urls: urls)
207
+ end
208
+ end
209
+
210
+ # Health check instrumentation
211
+ module HealthCheck
212
+ # Instrument health check execution
213
+ #
214
+ # @param type [Symbol] Health check type (:full, :quick)
215
+ # @yield Block performing health check
216
+ def self.instrument_check(type: :full, &block)
217
+ Instrumentation.instrument('health_check', type: type, &block)
218
+ end
219
+ end
220
+
221
+ # Error instrumentation
222
+ module Error
223
+ # Instrument error occurrence
224
+ #
225
+ # @param error [Exception] Error that occurred
226
+ # @param context [Hash] Error context
227
+ def self.instrument_error(error:, **context)
228
+ Instrumentation.publish(
229
+ 'error',
230
+ {
231
+ error_class: error.class.name,
232
+ error_message: error.message,
233
+ backtrace: error.backtrace&.first(5)
234
+ }.merge(context)
235
+ )
236
+ end
237
+ end
238
+
239
+ # Metrics collector for instrumentation events
240
+ #
241
+ # Collects metrics from ActiveSupport::Notifications events.
242
+ #
243
+ # @example Basic usage
244
+ # collector = NatsPubsub::Instrumentation::MetricsCollector.new
245
+ # collector.start
246
+ #
247
+ # # Later
248
+ # metrics = collector.metrics
249
+ # puts "Published: #{metrics[:publish][:count]}"
250
+ #
251
+ class MetricsCollector
252
+ attr_reader :metrics, :subscriber
253
+
254
+ def initialize
255
+ @metrics = Hash.new do |h, k|
256
+ h[k] = { count: 0, total_duration: 0.0, errors: 0 }
257
+ end
258
+ @subscriber = nil
259
+ @started_at = nil
260
+ end
261
+
262
+ # Start collecting metrics
263
+ def start
264
+ return unless Instrumentation.available?
265
+ return if @subscriber
266
+
267
+ @started_at = Time.now
268
+ @subscriber = Instrumentation.subscribe do |event|
269
+ record_event(event)
270
+ end
271
+ end
272
+
273
+ # Stop collecting metrics
274
+ def stop
275
+ return unless @subscriber
276
+
277
+ Instrumentation.unsubscribe(@subscriber)
278
+ @subscriber = nil
279
+ end
280
+
281
+ # Reset metrics
282
+ def reset
283
+ @metrics.clear
284
+ @started_at = Time.now
285
+ end
286
+
287
+ # Get metrics summary
288
+ #
289
+ # @return [Hash] Metrics summary
290
+ def summary
291
+ {
292
+ uptime: uptime,
293
+ events: @metrics.transform_values do |stats|
294
+ {
295
+ count: stats[:count],
296
+ total_duration_ms: stats[:total_duration].round(2),
297
+ avg_duration_ms: avg_duration(stats),
298
+ errors: stats[:errors]
299
+ }
300
+ end
301
+ }
302
+ end
303
+
304
+ private
305
+
306
+ def record_event(event)
307
+ name = event.name.sub('nats_pubsub.', '')
308
+
309
+ @metrics[name][:count] += 1
310
+ @metrics[name][:total_duration] += event.duration if event.duration
311
+ @metrics[name][:errors] += 1 if name.include?('error')
312
+ end
313
+
314
+ def avg_duration(stats)
315
+ return 0.0 if stats[:count].zero?
316
+
317
+ (stats[:total_duration] / stats[:count]).round(2)
318
+ end
319
+
320
+ def uptime
321
+ return 0.0 unless @started_at
322
+
323
+ Time.now - @started_at
324
+ end
325
+ end
326
+ end
327
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NatsPubsub
4
+ module Middleware
5
+ # Middleware that ensures ActiveRecord connections are properly managed
6
+ class ActiveRecord
7
+ def call(_subscriber, _payload, _metadata, &)
8
+ if defined?(::ActiveRecord::Base)
9
+ ::ActiveRecord::Base.connection_pool.with_connection(&)
10
+ else
11
+ yield
12
+ end
13
+ ensure
14
+ ::ActiveRecord::Base.clear_active_connections! if defined?(::ActiveRecord::Base)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NatsPubsub
4
+ module Middleware
5
+ # Middleware chain for processing messages through multiple middleware layers.
6
+ # Similar to Rack middleware or Sidekiq middleware.
7
+ class Chain
8
+ def initialize
9
+ @entries = []
10
+ end
11
+
12
+ # Add middleware to the chain
13
+ #
14
+ # @param klass [Class] Middleware class
15
+ # @param args [Array] Arguments to pass to middleware constructor
16
+ # @param kwargs [Hash] Keyword arguments to pass to middleware constructor
17
+ def add(klass, *args, **kwargs)
18
+ @entries << [klass, args, kwargs]
19
+ end
20
+
21
+ # Remove middleware from the chain
22
+ #
23
+ # @param klass [Class] Middleware class to remove
24
+ def remove(klass)
25
+ @entries.delete_if { |(k, _, _)| k == klass }
26
+ end
27
+
28
+ # Clear all middleware
29
+ def clear
30
+ @entries.clear
31
+ end
32
+
33
+ # Check if chain is empty
34
+ #
35
+ # @return [Boolean]
36
+ def empty?
37
+ @entries.empty?
38
+ end
39
+
40
+ # Get count of middleware in chain
41
+ #
42
+ # @return [Integer]
43
+ def size
44
+ @entries.size
45
+ end
46
+
47
+ # Invoke the middleware chain
48
+ #
49
+ # @param subscriber [Object] Subscriber instance
50
+ # @param payload [Hash] Event payload
51
+ # @param metadata [Hash] Event metadata
52
+ # @yield Block to execute after all middleware
53
+ def invoke(subscriber, payload, metadata, &)
54
+ chain = build_chain
55
+ traverse(chain, subscriber, payload, metadata, &)
56
+ end
57
+
58
+ private
59
+
60
+ # Build middleware instances from entries
61
+ #
62
+ # @return [Array] Array of middleware instances
63
+ def build_chain
64
+ @entries.map do |(klass, args, kwargs)|
65
+ if kwargs.empty?
66
+ klass.new(*args)
67
+ else
68
+ klass.new(*args, **kwargs)
69
+ end
70
+ end
71
+ end
72
+
73
+ # Recursively traverse the middleware chain
74
+ #
75
+ # @param chain [Array] Remaining middleware in chain
76
+ # @param subscriber [Object] Subscriber instance
77
+ # @param payload [Hash] Event payload
78
+ # @param metadata [Hash] Event metadata
79
+ # @yield Block to execute at the end
80
+ def traverse(chain, subscriber, payload, metadata, &block)
81
+ if chain.empty?
82
+ yield
83
+ else
84
+ middleware = chain.shift
85
+ middleware.call(subscriber, payload, metadata) do
86
+ traverse(chain, subscriber, payload, metadata, &block)
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../core/logging'
4
+
5
+ module NatsPubsub
6
+ module Middleware
7
+ # Middleware that logs message processing start and completion
8
+ class Logging
9
+ def call(subscriber, _payload, metadata)
10
+ start = Time.now
11
+
12
+ log_start(subscriber, metadata)
13
+
14
+ yield
15
+
16
+ elapsed = ((Time.now - start) * 1000).round(2)
17
+ log_complete(subscriber, metadata, elapsed)
18
+ rescue StandardError => e
19
+ elapsed = ((Time.now - start) * 1000).round(2)
20
+ log_error(subscriber, metadata, elapsed, e)
21
+ raise
22
+ end
23
+
24
+ private
25
+
26
+ def log_start(subscriber, metadata)
27
+ NatsPubsub::Logging.info(
28
+ "Processing #{metadata[:subject]} with #{subscriber.class.name} (attempt #{metadata[:deliveries]})",
29
+ tag: 'NatsPubsub::Middleware::Logging'
30
+ )
31
+ end
32
+
33
+ def log_complete(subscriber, _metadata, elapsed)
34
+ NatsPubsub::Logging.info(
35
+ "Completed #{subscriber.class.name} in #{elapsed}ms",
36
+ tag: 'NatsPubsub::Middleware::Logging'
37
+ )
38
+ end
39
+
40
+ def log_error(subscriber, _metadata, elapsed, error)
41
+ NatsPubsub::Logging.error(
42
+ "Failed #{subscriber.class.name} after #{elapsed}ms: #{error.class} #{error.message}",
43
+ tag: 'NatsPubsub::Middleware::Logging'
44
+ )
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../core/logging'
4
+
5
+ module NatsPubsub
6
+ module Middleware
7
+ # Middleware that logs retry attempts
8
+ class RetryLogger
9
+ def call(subscriber, _payload, metadata)
10
+ if metadata[:deliveries] && metadata[:deliveries] > 1
11
+ max_deliver = NatsPubsub.config.max_deliver
12
+
13
+ NatsPubsub::Logging.warn(
14
+ "Retrying #{metadata[:subject]} with #{subscriber.class.name} " \
15
+ "(attempt #{metadata[:deliveries]}/#{max_deliver})",
16
+ tag: 'NatsPubsub::Middleware::RetryLogger'
17
+ )
18
+ end
19
+
20
+ yield
21
+ end
22
+ end
23
+ end
24
+ end