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,470 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NatsPubsub
4
+ module Core
5
+ # Health check for NatsPubsub components
6
+ #
7
+ # Provides comprehensive health checking for connection, JetStream, and streams.
8
+ #
9
+ # @example Basic health check
10
+ # status = NatsPubsub::Core::HealthCheck.check
11
+ # puts "Status: #{status.status}"
12
+ # puts "Healthy: #{status.healthy?}"
13
+ #
14
+ # @example Quick health check
15
+ # status = NatsPubsub::Core::HealthCheck.quick_check
16
+ # puts "Status: #{status.status}"
17
+ #
18
+ class HealthCheck
19
+ # Health check result
20
+ #
21
+ # @!attribute [r] status
22
+ # @return [Symbol] Overall status (:healthy, :degraded, :unhealthy)
23
+ # @!attribute [r] components
24
+ # @return [Hash] Component health details
25
+ # @!attribute [r] timestamp
26
+ # @return [Time] Check timestamp
27
+ # @!attribute [r] duration
28
+ # @return [Float] Check duration in milliseconds
29
+ #
30
+ class Result
31
+ attr_reader :status, :components, :timestamp, :duration
32
+
33
+ def initialize(status:, components:, timestamp:, duration:)
34
+ @status = status
35
+ @components = components
36
+ @timestamp = timestamp
37
+ @duration = duration
38
+ freeze
39
+ end
40
+
41
+ # Check if system is healthy
42
+ #
43
+ # @return [Boolean] True if healthy
44
+ def healthy?
45
+ status == :healthy
46
+ end
47
+
48
+ # Check if system is degraded
49
+ #
50
+ # @return [Boolean] True if degraded
51
+ def degraded?
52
+ status == :degraded
53
+ end
54
+
55
+ # Check if system is unhealthy
56
+ #
57
+ # @return [Boolean] True if unhealthy
58
+ def unhealthy?
59
+ status == :unhealthy
60
+ end
61
+
62
+ # Convert to hash
63
+ #
64
+ # @return [Hash] Hash representation
65
+ def to_h
66
+ {
67
+ status: status,
68
+ healthy: healthy?,
69
+ components: components,
70
+ timestamp: timestamp.iso8601,
71
+ duration_ms: duration
72
+ }
73
+ end
74
+
75
+ alias to_hash to_h
76
+
77
+ # Convert to JSON
78
+ #
79
+ # @return [String] JSON string
80
+ def to_json(*_args)
81
+ require 'json'
82
+ to_h.to_json
83
+ end
84
+ end
85
+
86
+ # Component health details
87
+ #
88
+ # @!attribute [r] status
89
+ # @return [Symbol] Component status (:healthy, :unhealthy)
90
+ # @!attribute [r] message
91
+ # @return [String, nil] Status message
92
+ # @!attribute [r] details
93
+ # @return [Hash, nil] Additional details
94
+ #
95
+ class ComponentHealth
96
+ attr_reader :status, :message, :details
97
+
98
+ def initialize(status:, message: nil, details: nil)
99
+ @status = status
100
+ @message = message
101
+ @details = details
102
+ freeze
103
+ end
104
+
105
+ def healthy?
106
+ status == :healthy
107
+ end
108
+
109
+ def unhealthy?
110
+ status == :unhealthy
111
+ end
112
+
113
+ def to_h
114
+ {
115
+ status: status,
116
+ healthy: healthy?,
117
+ message: message,
118
+ details: details
119
+ }.compact
120
+ end
121
+
122
+ alias to_hash to_h
123
+ end
124
+
125
+ class << self
126
+ # Perform comprehensive health check
127
+ #
128
+ # Checks:
129
+ # - NATS connection
130
+ # - JetStream availability
131
+ # - Stream configuration
132
+ # - Outbox pattern (if enabled)
133
+ # - Inbox pattern (if enabled)
134
+ # - Connection pool
135
+ #
136
+ # @return [Result] Health check result
137
+ def check
138
+ start_time = Time.now
139
+ components = {}
140
+
141
+ # Check NATS connection
142
+ components[:connection] = check_connection
143
+
144
+ # Check JetStream
145
+ components[:jetstream] = check_jetstream
146
+
147
+ # Check streams
148
+ components[:streams] = check_streams if components[:jetstream].healthy?
149
+
150
+ # Check outbox (if enabled)
151
+ outbox_health = check_outbox
152
+ components[:outbox] = outbox_health if outbox_health
153
+
154
+ # Check inbox (if enabled)
155
+ inbox_health = check_inbox
156
+ components[:inbox] = inbox_health if inbox_health
157
+
158
+ # Check connection pool
159
+ components[:connection_pool] = check_connection_pool
160
+
161
+ # Determine overall status
162
+ status = determine_status(components)
163
+
164
+ duration = ((Time.now - start_time) * 1000).round(2)
165
+
166
+ Result.new(
167
+ status: status,
168
+ components: components.transform_values(&:to_h),
169
+ timestamp: Time.now,
170
+ duration: duration
171
+ )
172
+ rescue StandardError => e
173
+ Result.new(
174
+ status: :unhealthy,
175
+ components: {
176
+ error: ComponentHealth.new(
177
+ status: :unhealthy,
178
+ message: "Health check failed: #{e.message}"
179
+ ).to_h
180
+ },
181
+ timestamp: Time.now,
182
+ duration: ((Time.now - start_time) * 1000).round(2)
183
+ )
184
+ end
185
+
186
+ # Perform quick health check
187
+ #
188
+ # Only checks NATS connection for fast response.
189
+ #
190
+ # @return [Result] Health check result
191
+ def quick_check
192
+ start_time = Time.now
193
+
194
+ connection_health = check_connection
195
+
196
+ Result.new(
197
+ status: connection_health.healthy? ? :healthy : :unhealthy,
198
+ components: { connection: connection_health.to_h },
199
+ timestamp: Time.now,
200
+ duration: ((Time.now - start_time) * 1000).round(2)
201
+ )
202
+ rescue StandardError => e
203
+ Result.new(
204
+ status: :unhealthy,
205
+ components: {
206
+ error: ComponentHealth.new(
207
+ status: :unhealthy,
208
+ message: "Quick check failed: #{e.message}"
209
+ ).to_h
210
+ },
211
+ timestamp: Time.now,
212
+ duration: ((Time.now - start_time) * 1000).round(2)
213
+ )
214
+ end
215
+
216
+ # Rack middleware for health check endpoint
217
+ #
218
+ # @example Sinatra
219
+ # get '/health' do
220
+ # status, headers, body = NatsPubsub::Core::HealthCheck.middleware.call(env)
221
+ # [status, headers, body]
222
+ # end
223
+ #
224
+ # @example Rails
225
+ # get '/health', to: proc { |env|
226
+ # NatsPubsub::Core::HealthCheck.middleware.call(env)
227
+ # }
228
+ #
229
+ # @return [Proc] Rack middleware
230
+ def middleware
231
+ lambda do |_env|
232
+ result = check
233
+ status_code = result.healthy? ? 200 : (result.degraded? ? 200 : 503)
234
+
235
+ [
236
+ status_code,
237
+ { 'Content-Type' => 'application/json' },
238
+ [result.to_json]
239
+ ]
240
+ end
241
+ end
242
+
243
+ # Rack middleware for quick health check endpoint
244
+ #
245
+ # @return [Proc] Rack middleware
246
+ def quick_middleware
247
+ lambda do |_env|
248
+ result = quick_check
249
+ status_code = result.healthy? ? 200 : 503
250
+
251
+ [
252
+ status_code,
253
+ { 'Content-Type' => 'application/json' },
254
+ [result.to_json]
255
+ ]
256
+ end
257
+ end
258
+
259
+ private
260
+
261
+ # Check NATS connection
262
+ #
263
+ # @return [ComponentHealth] Connection health
264
+ def check_connection
265
+ nc = Connection.connection
266
+ return ComponentHealth.new(status: :unhealthy, message: 'Not connected') if nc.nil?
267
+
268
+ if nc.closed?
269
+ ComponentHealth.new(status: :unhealthy, message: 'Connection closed')
270
+ else
271
+ ComponentHealth.new(
272
+ status: :healthy,
273
+ message: 'Connected',
274
+ details: {
275
+ server_info: nc.server_info.to_h
276
+ }
277
+ )
278
+ end
279
+ rescue StandardError => e
280
+ ComponentHealth.new(
281
+ status: :unhealthy,
282
+ message: "Connection check failed: #{e.message}"
283
+ )
284
+ end
285
+
286
+ # Check JetStream availability
287
+ #
288
+ # @return [ComponentHealth] JetStream health
289
+ def check_jetstream
290
+ nc = Connection.connection
291
+ return ComponentHealth.new(status: :unhealthy, message: 'Not connected') if nc.nil?
292
+
293
+ jsm = nc.jetstream_manager
294
+ account_info = jsm.account_info
295
+
296
+ ComponentHealth.new(
297
+ status: :healthy,
298
+ message: 'JetStream available',
299
+ details: {
300
+ streams: account_info.streams,
301
+ consumers: account_info.consumers,
302
+ memory: account_info.memory,
303
+ storage: account_info.storage
304
+ }
305
+ )
306
+ rescue StandardError => e
307
+ ComponentHealth.new(
308
+ status: :unhealthy,
309
+ message: "JetStream check failed: #{e.message}"
310
+ )
311
+ end
312
+
313
+ # Check configured streams
314
+ #
315
+ # @return [ComponentHealth] Streams health
316
+ def check_streams
317
+ nc = Connection.connection
318
+ jsm = nc.jetstream_manager
319
+
320
+ # Get configured stream names from topology
321
+ expected_streams = Topology.stream_configs.keys
322
+
323
+ existing_streams = jsm.streams.map(&:config).map(&:name)
324
+
325
+ missing_streams = expected_streams - existing_streams
326
+
327
+ if missing_streams.empty?
328
+ ComponentHealth.new(
329
+ status: :healthy,
330
+ message: 'All streams configured',
331
+ details: {
332
+ expected: expected_streams.size,
333
+ existing: existing_streams.size,
334
+ streams: existing_streams
335
+ }
336
+ )
337
+ else
338
+ ComponentHealth.new(
339
+ status: :unhealthy,
340
+ message: 'Missing streams',
341
+ details: {
342
+ expected: expected_streams.size,
343
+ existing: existing_streams.size,
344
+ missing: missing_streams
345
+ }
346
+ )
347
+ end
348
+ rescue StandardError => e
349
+ ComponentHealth.new(
350
+ status: :unhealthy,
351
+ message: "Streams check failed: #{e.message}"
352
+ )
353
+ end
354
+
355
+ # Check outbox health (if enabled)
356
+ #
357
+ # @return [ComponentHealth, nil] Outbox health or nil if disabled
358
+ def check_outbox
359
+ return nil unless NatsPubsub.config.use_outbox
360
+
361
+ model = NatsPubsub.config.outbox_model.constantize
362
+
363
+ pending_count = model.pending.count
364
+ failed_count = model.failed.count
365
+ stale_count = model.stale_publishing(5.minutes.ago).count
366
+
367
+ status = if stale_count > 0 || failed_count > 100
368
+ :unhealthy
369
+ elsif pending_count > 1000
370
+ :degraded
371
+ else
372
+ :healthy
373
+ end
374
+
375
+ ComponentHealth.new(
376
+ status: status,
377
+ message: 'Outbox operational',
378
+ details: {
379
+ pending: pending_count,
380
+ failed: failed_count,
381
+ stale: stale_count
382
+ }
383
+ )
384
+ rescue StandardError => e
385
+ ComponentHealth.new(
386
+ status: :unhealthy,
387
+ message: "Outbox check failed: #{e.message}"
388
+ )
389
+ end
390
+
391
+ # Check inbox health (if enabled)
392
+ #
393
+ # @return [ComponentHealth, nil] Inbox health or nil if disabled
394
+ def check_inbox
395
+ return nil unless NatsPubsub.config.use_inbox
396
+
397
+ model = NatsPubsub.config.inbox_model.constantize
398
+
399
+ unprocessed_count = model.unprocessed.count
400
+ failed_count = model.failed.count
401
+
402
+ status = if failed_count > 100
403
+ :unhealthy
404
+ elsif unprocessed_count > 1000
405
+ :degraded
406
+ else
407
+ :healthy
408
+ end
409
+
410
+ ComponentHealth.new(
411
+ status: status,
412
+ message: 'Inbox operational',
413
+ details: {
414
+ unprocessed: unprocessed_count,
415
+ failed: failed_count
416
+ }
417
+ )
418
+ rescue StandardError => e
419
+ ComponentHealth.new(
420
+ status: :unhealthy,
421
+ message: "Inbox check failed: #{e.message}"
422
+ )
423
+ end
424
+
425
+ # Check connection pool health
426
+ #
427
+ # @return [ComponentHealth] Connection pool health
428
+ def check_connection_pool
429
+ config = NatsPubsub.config
430
+
431
+ ComponentHealth.new(
432
+ status: :healthy,
433
+ message: 'Connection pool configured',
434
+ details: {
435
+ pool_size: config.connection_pool_size,
436
+ pool_timeout: config.connection_pool_timeout
437
+ }
438
+ )
439
+ rescue StandardError => e
440
+ ComponentHealth.new(
441
+ status: :unhealthy,
442
+ message: "Connection pool check failed: #{e.message}"
443
+ )
444
+ end
445
+
446
+ # Determine overall status from components
447
+ #
448
+ # @param components [Hash] Component health map
449
+ # @return [Symbol] Overall status
450
+ def determine_status(components)
451
+ statuses = components.values.map(&:status)
452
+
453
+ if statuses.all? { |s| s == :healthy }
454
+ :healthy
455
+ elsif statuses.any? { |s| s == :unhealthy }
456
+ # If connection or jetstream is unhealthy, system is unhealthy
457
+ if components[:connection]&.unhealthy? || components[:jetstream]&.unhealthy?
458
+ :unhealthy
459
+ else
460
+ # Otherwise degraded
461
+ :degraded
462
+ end
463
+ else
464
+ :degraded
465
+ end
466
+ end
467
+ end
468
+ end
469
+ end
470
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+ require 'logger'
5
+
6
+ module NatsPubsub
7
+ # Logging helpers that route to the configured logger when available,
8
+ # falling back to Rails.logger or STDOUT.
9
+ module Logging
10
+ module_function
11
+
12
+ def logger
13
+ NatsPubsub.config.logger ||
14
+ (defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger) ||
15
+ default_logger
16
+ end
17
+
18
+ def default_logger
19
+ @default_logger ||= Logger.new($stdout)
20
+ end
21
+
22
+ def log(level, msg, tag: nil)
23
+ message = tag ? "[#{tag}] #{msg}" : msg
24
+ logger.public_send(level, message)
25
+ end
26
+
27
+ def debug(msg, tag: nil)
28
+ log(:debug, msg, tag: tag)
29
+ end
30
+
31
+ def info(msg, tag: nil)
32
+ log(:info, msg, tag: tag)
33
+ end
34
+
35
+ def warn(msg, tag: nil)
36
+ log(:warn, msg, tag: tag)
37
+ end
38
+
39
+ def error(msg, tag: nil)
40
+ log(:error, msg, tag: tag)
41
+ end
42
+
43
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
44
+ def sanitize_url(url)
45
+ uri = URI.parse(url)
46
+ return url unless uri.user || uri.password
47
+
48
+ userinfo =
49
+ if uri.password # user:pass → keep user, mask pass
50
+ "#{uri.user}:***"
51
+ else # token-only userinfo → mask entirely
52
+ '***'
53
+ end
54
+
55
+ host = uri.host || ''
56
+ port = uri.port ? ":#{uri.port}" : ''
57
+ path = uri.path.to_s # omit query on purpose to avoid leaking tokens
58
+ frag = uri.fragment ? "##{uri.fragment}" : ''
59
+
60
+ "#{uri.scheme}://#{userinfo}@#{host}#{port}#{path}#{frag}"
61
+ rescue URI::InvalidURIError
62
+ # Fallback: redact any userinfo before the '@'
63
+ url.gsub(%r{(nats|tls)://([^@/]+)@}i) do
64
+ scheme = Regexp.last_match(1)
65
+ creds = Regexp.last_match(2)
66
+ masked = creds&.include?(':') ? "#{creds&.split(':', 2)&.first}:***" : '***'
67
+ "#{scheme}://#{masked}@"
68
+ end
69
+ end
70
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
71
+ end
72
+ end